@geravant/sinain 1.9.0 → 1.10.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.
package/.env.example CHANGED
@@ -3,8 +3,18 @@
3
3
  # The launcher reads this file on every start. sinain-core and sinain-agent
4
4
  # inherit all vars via the launcher's process environment.
5
5
 
6
- # ── Required ─────────────────────────────────────────────────────────────────
6
+ # ── Context Analysis (HUD summarizer) ────────────────────────────────────────
7
+ # Provider: openrouter (cloud, needs API key) or ollama (local, free)
8
+ ANALYSIS_PROVIDER=openrouter
9
+ ANALYSIS_MODEL=google/gemini-2.5-flash-lite
10
+ # ANALYSIS_VISION_MODEL=google/gemini-2.5-flash # auto-upgrade for image ticks
11
+ # ANALYSIS_ENDPOINT= # default per provider; override for custom
12
+ # ANALYSIS_API_KEY= # uses OPENROUTER_API_KEY if not set
13
+ # ANALYSIS_FALLBACK_MODELS=google/gemini-2.5-flash,anthropic/claude-3.5-haiku
14
+
15
+ # ── API Keys ─────────────────────────────────────────────────────────────────
7
16
  OPENROUTER_API_KEY= # get one free at https://openrouter.ai
17
+ # used by context analysis + transcription
8
18
 
9
19
  # ── Privacy ──────────────────────────────────────────────────────────────────
10
20
  PRIVACY_MODE=standard # off | standard | strict | paranoid
@@ -20,6 +30,11 @@ SINAIN_CORE_URL=http://localhost:9500
20
30
  SINAIN_POLL_INTERVAL=5 # seconds between escalation polls
21
31
  SINAIN_HEARTBEAT_INTERVAL=900 # seconds between heartbeat ticks (15 min)
22
32
  SINAIN_WORKSPACE=~/.openclaw/workspace # knowledge files, curation scripts, playbook
33
+ # SINAIN_ALLOWED_TOOLS=mcp__sinain # MCP tools auto-approved for bare agent
34
+ SINAIN_AGENT_MAX_TURNS=5 # max tool-use turns for escalation responses
35
+ SINAIN_SPAWN_MAX_TURNS=25 # max tool-use turns for spawn tasks (Shift+Enter)
36
+ # auto-derived from mcp-config.json if unset
37
+ # format: mcp__<server> (all) | mcp__<server>__<tool> (specific)
23
38
 
24
39
  # ── Escalation ───────────────────────────────────────────────────────────────
25
40
  ESCALATION_MODE=rich # off | selective | focus | rich
@@ -27,7 +42,7 @@ ESCALATION_MODE=rich # off | selective | focus | rich
27
42
  # selective: score-based (errors, questions trigger it)
28
43
  # focus: always escalate every tick
29
44
  # rich: always escalate with maximum context
30
- ESCALATION_COOLDOWN_MS=30000
45
+ # ESCALATION_COOLDOWN_MS=10000
31
46
  # ESCALATION_TRANSPORT=auto # ws | http | auto
32
47
  # auto = WS when gateway connected, HTTP fallback
33
48
  # http = bare agent only (no gateway)
@@ -70,18 +85,6 @@ TRANSCRIPTION_LANGUAGE=en-US
70
85
  # LOCAL_WHISPER_MODEL=~/models/ggml-large-v3-turbo.bin
71
86
  # LOCAL_WHISPER_TIMEOUT_MS=15000
72
87
 
73
- # ── Local Agent Loop ─────────────────────────────────────────────────────────
74
- AGENT_ENABLED=true
75
- AGENT_MODEL=google/gemini-2.5-flash-lite
76
- # AGENT_FALLBACK_MODELS=google/gemini-2.5-flash,anthropic/claude-3.5-haiku
77
- AGENT_MAX_TOKENS=300
78
- AGENT_TEMPERATURE=0.3
79
- AGENT_PUSH_TO_FEED=true
80
- AGENT_DEBOUNCE_MS=3000
81
- AGENT_MAX_INTERVAL_MS=30000
82
- AGENT_COOLDOWN_MS=10000
83
- AGENT_MAX_AGE_MS=120000 # context window lookback (2 min)
84
-
85
88
  # ── OpenClaw / NemoClaw Gateway ──────────────────────────────────────────────
86
89
  # Leave blank to run without a gateway (bare agent mode).
87
90
  # The setup wizard fills these in if you have an OpenClaw gateway.
package/HEARTBEAT.md CHANGED
@@ -59,4 +59,4 @@ SINAIN_BACKUP_REPO=<git-url> npx sinain
59
59
  - Token printed at end (or visible in Brev dashboard → Gateway Token)
60
60
  - Mac side: `./setup-nemoclaw.sh` → 5 prompts → overlay starts
61
61
 
62
- Memory is git-backed via `git_backup.sh` on every heartbeat tick. New instances restore instantly via `SINAIN_BACKUP_REPO`.
62
+ Memory is backed up via knowledge snapshots to `~/.sinain/knowledge-snapshots/`. New instances restore instantly via `SINAIN_BACKUP_REPO`.
package/README.md CHANGED
@@ -29,13 +29,12 @@ Five lifecycle hooks, one tool, four commands, and a background service:
29
29
 
30
30
  | Tool | Purpose |
31
31
  |---|---|
32
- | `sinain_heartbeat_tick` | Executes all heartbeat mechanical work (git backup, signal analysis, insight synthesis, log writing). Returns structured JSON with results, recommended actions, and Telegram output. |
32
+ | `sinain_heartbeat_tick` | Executes all heartbeat mechanical work (signal analysis, insight synthesis, log writing). Returns structured JSON with results, recommended actions, and Telegram output. |
33
33
 
34
34
  The heartbeat tool accepts `{ sessionSummary: string, idle: boolean }` and runs:
35
- 1. `bash sinain-memory/git_backup.sh` (30s timeout)
36
- 2. `uv run python3 sinain-memory/signal_analyzer.py` (60s timeout)
37
- 3. `uv run python3 sinain-memory/insight_synthesizer.py` (60s timeout)
38
- 4. Writes log entry to `memory/playbook-logs/YYYY-MM-DD.jsonl`
35
+ 1. `uv run python3 sinain-memory/signal_analyzer.py` (60s timeout)
36
+ 2. `uv run python3 sinain-memory/insight_synthesizer.py` (60s timeout)
37
+ 3. Writes log entry to `memory/playbook-logs/YYYY-MM-DD.jsonl`
39
38
 
40
39
  ### Commands
41
40
 
@@ -114,8 +113,6 @@ Also ensures these directories exist:
114
113
  - `memory/`, `memory/playbook-archive/`, `memory/playbook-logs/`
115
114
  - `memory/eval-logs/`, `memory/eval-reports/`
116
115
 
117
- The `git_backup.sh` script is automatically made executable (chmod 755) after sync.
118
-
119
116
  After syncing modules, the plugin generates `memory/sinain-playbook-effective.md` — a merged view of active module patterns (sorted by priority) plus the base playbook.
120
117
 
121
118
  ## Heartbeat Compliance Validation
package/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * - Strips <private> tags from tool results before persistence
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, chmodSync, copyFileSync } from "node:fs";
11
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, copyFileSync } from "node:fs";
12
12
  import { join, dirname } from "node:path";
13
13
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
14
14
 
@@ -358,8 +358,6 @@ export default function sinainHudPlugin(api: OpenClawPluginApi): void {
358
358
  const memorySource = cfg.memoryPath ? api.resolvePath(cfg.memoryPath) : undefined;
359
359
  if (memorySource) {
360
360
  store.deployDir(memorySource, "sinain-memory");
361
- const gbPath = join(workspaceDir, "sinain-memory", "git_backup.sh");
362
- if (existsSync(gbPath)) try { chmodSync(gbPath, 0o755); } catch {}
363
361
  }
364
362
 
365
363
  const modulesSource = cfg.modulesPath ? api.resolvePath(cfg.modulesPath) : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
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": {
@@ -158,8 +158,10 @@ class VisionOCR:
158
158
 
159
159
  # Execute
160
160
  handler = VNImageRequestHandler.alloc().initWithCGImage_options_(cg_image, None)
161
- success = handler.performRequests_error_([request], objc.nil)
162
- if not success[0]:
161
+ result = handler.performRequests_error_([request], objc.nil)
162
+ # PyObjC may return (bool, error) tuple or just bool depending on version
163
+ success = result[0] if isinstance(result, tuple) else result
164
+ if not success:
163
165
  return OCRResult(text="", confidence=0, word_count=0)
164
166
 
165
167
  results = request.results()
@@ -172,7 +174,8 @@ class VisionOCR:
172
174
 
173
175
  for observation in results:
174
176
  candidate = observation.topCandidates_(1)
175
- if not candidate:
177
+ # PyObjC may return bool instead of list depending on version
178
+ if not candidate or isinstance(candidate, bool):
176
179
  continue
177
180
  text = candidate[0].string()
178
181
  conf = candidate[0].confidence()
@@ -47,7 +47,6 @@ When responding to escalations:
47
47
 
48
48
  1. Call `sinain_heartbeat_tick` with a brief session summary
49
49
  2. The tool runs the full pipeline automatically:
50
- - Git backup of memory directory
51
50
  - Signal analysis (detects opportunities from session patterns)
52
51
  - **Session distillation** — fetches new feed items from sinain-core, distills patterns/learnings
53
52
  - **Knowledge integration** — updates playbook (working memory) and knowledge graph (long-term memory)
@@ -5,7 +5,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
5
 
6
6
  # Load .env as fallback — does NOT override vars already in the environment
7
7
  # (e.g. vars set by the launcher from ~/.sinain/.env)
8
- if [ -f "$SCRIPT_DIR/.env" ]; then
8
+ # Load project root .env (single config for all subsystems)
9
+ ENV_FILE="$SCRIPT_DIR/../.env"
10
+ if [ -f "$ENV_FILE" ]; then
9
11
  while IFS='=' read -r key val; do
10
12
  # Skip comments and blank lines
11
13
  [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
@@ -19,7 +21,7 @@ if [ -f "$SCRIPT_DIR/.env" ]; then
19
21
  if [ -z "${!key+x}" ]; then
20
22
  export "$key=$val"
21
23
  fi
22
- done < "$SCRIPT_DIR/.env"
24
+ done < "$ENV_FILE"
23
25
  fi
24
26
 
25
27
  MCP_CONFIG="${MCP_CONFIG:-$SCRIPT_DIR/mcp-config.json}"
@@ -28,6 +30,8 @@ POLL_INTERVAL="${SINAIN_POLL_INTERVAL:-2}"
28
30
  HEARTBEAT_INTERVAL="${SINAIN_HEARTBEAT_INTERVAL:-900}" # 15 minutes
29
31
  AGENT="${SINAIN_AGENT:-claude}"
30
32
  WORKSPACE="${SINAIN_WORKSPACE:-$HOME/.openclaw/workspace}"
33
+ AGENT_MAX_TURNS="${SINAIN_AGENT_MAX_TURNS:-5}"
34
+ SPAWN_MAX_TURNS="${SINAIN_SPAWN_MAX_TURNS:-25}"
31
35
 
32
36
  # Build allowed tools list for Claude's --allowedTools flag.
33
37
  # SINAIN_ALLOWED_TOOLS in .env overrides; otherwise auto-derive from MCP config.
@@ -64,10 +68,11 @@ invoke_agent() {
64
68
  local prompt="$1"
65
69
  case "$AGENT" in
66
70
  claude)
71
+ local turns="${2:-$AGENT_MAX_TURNS}"
67
72
  claude --enable-auto-mode \
68
73
  --mcp-config "$MCP_CONFIG" \
69
74
  ${ALLOWED_TOOLS:+--allowedTools $ALLOWED_TOOLS} \
70
- --max-turns 5 --output-format text \
75
+ --max-turns "$turns" --output-format text \
71
76
  -p "$prompt"
72
77
  ;;
73
78
  codex)
@@ -89,9 +94,10 @@ invoke_agent() {
89
94
  fi
90
95
  ;;
91
96
  goose)
97
+ local turns="${2:-$AGENT_MAX_TURNS}"
92
98
  GOOSE_MODE=auto goose run --text "$prompt" \
93
99
  --output-format text \
94
- --max-turns 10
100
+ --max-turns "$turns"
95
101
  ;;
96
102
  aider)
97
103
  # No MCP support — signal pipe mode
@@ -267,7 +273,7 @@ while true; do
267
273
  SPAWN_PROMPT="You have a background task to complete. Task: $SPAWN_TASK
268
274
 
269
275
  Complete this task thoroughly. Use sinain_get_knowledge and sinain_knowledge_query if you need context from past sessions. Summarize your findings concisely."
270
- SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" || echo "ERROR: agent invocation failed")
276
+ SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" "$SPAWN_MAX_TURNS" || echo "ERROR: agent invocation failed")
271
277
  else
272
278
  # Pipe path: agent gets task text directly
273
279
  SPAWN_RESULT=$(invoke_pipe "Background task: $SPAWN_TASK" || echo "No output")
@@ -1,13 +1,10 @@
1
- import type { AgentConfig, AgentResult, ContextWindow, RecorderStatus, RecordCommand } from "../types.js";
1
+ import type { AnalysisConfig, AgentResult, ContextWindow, RecorderStatus, RecordCommand } from "../types.js";
2
2
  import { normalizeAppName } from "./context-window.js";
3
3
  import { log, error } from "../log.js";
4
4
  import { levelFor, applyLevel } from "../privacy/index.js";
5
5
 
6
6
  const TAG = "agent";
7
7
 
8
- /** Guard: only one Ollama vision call at a time (latest-wins, skip if busy). */
9
- let ollamaInFlight = false;
10
-
11
8
  /**
12
9
  * Model-specific timeouts in milliseconds.
13
10
  * Only increases timeouts for slow models to avoid false timeouts.
@@ -211,75 +208,54 @@ function parseTask(parsed: any): string | undefined {
211
208
  */
212
209
  export async function analyzeContext(
213
210
  contextWindow: ContextWindow,
214
- config: AgentConfig,
211
+ config: AnalysisConfig,
215
212
  recorderStatus: RecorderStatus | null = null,
216
213
  traitSystemPrompt?: string,
217
214
  ): Promise<AgentResult> {
218
215
  const userPrompt = buildUserPrompt(contextWindow, recorderStatus);
219
- // Apply privacy gating for images sent to OpenRouter
216
+
217
+ // Apply privacy gating for images based on provider
220
218
  let images = contextWindow.images || [];
219
+ const privacyDest = config.provider === "ollama" ? "local_llm" : "openrouter";
221
220
  try {
222
- const imgLevel = levelFor("screen_images", "openrouter");
223
- if (imgLevel === "none") {
224
- images = [];
225
- }
221
+ if (levelFor("screen_images", privacyDest) === "none") images = [];
226
222
  } catch { /* privacy not initialized, keep images */ }
223
+
227
224
  const systemPrompt = traitSystemPrompt ?? SYSTEM_PROMPT;
228
225
 
229
- // Try local Ollama first when enabled (handles both vision and text-only ticks)
230
- // Guard: skip if a previous Ollama call is still in-flight (avoids "no slots available")
231
- if (config.localVisionEnabled && !ollamaInFlight) {
232
- ollamaInFlight = true;
233
- try {
234
- const result = await callOllamaVision(systemPrompt, userPrompt, images, config);
235
- const mode = images.length > 0 ? "vision" : "text";
236
- log(TAG, `local ollama (${config.localVisionModel}, ${mode}): success`);
237
- return result;
238
- } catch (err: any) {
239
- log(TAG, `local ollama failed: ${err.message || err}, falling back to OpenRouter`);
240
- } finally {
241
- ollamaInFlight = false;
242
- }
226
+ if (config.provider === "ollama") {
227
+ return await callOllama(systemPrompt, userPrompt, images, config);
243
228
  }
244
229
 
245
- // Skip OpenRouter entirely if no API key (local-only mode)
246
- if (!config.openrouterApiKey) {
247
- if (config.localVisionEnabled) {
248
- throw new Error("local ollama failed and no OpenRouter API key — cannot analyze");
249
- }
250
- throw new Error("no OpenRouter API key configured");
230
+ // OpenRouter path: model chain with fallbacks
231
+ if (!config.apiKey) {
232
+ throw new Error("ANALYSIS_API_KEY / OPENROUTER_API_KEY not set");
251
233
  }
252
234
 
253
235
  const models = [config.model, ...config.fallbackModels];
254
-
255
- // Auto-upgrade: use vision model when images are present
256
- if (images.length > 0 && config.visionModel) {
257
- // Insert vision model at the front if not already there
258
- if (!models.includes(config.visionModel)) {
259
- models.unshift(config.visionModel);
260
- }
236
+ // Auto-upgrade to vision model when images are present
237
+ if (images.length > 0 && config.visionModel && !models.includes(config.visionModel)) {
238
+ models.unshift(config.visionModel);
261
239
  }
262
240
 
263
241
  let lastError: Error | null = null;
264
-
265
242
  for (const model of models) {
266
243
  try {
267
- return await callModel(systemPrompt, userPrompt, images, model, config);
244
+ return await callOpenRouter(systemPrompt, userPrompt, images, model, config);
268
245
  } catch (err: any) {
269
246
  lastError = err;
270
247
  log(TAG, `model ${model} failed: ${err.message || err}, trying next...`);
271
248
  }
272
249
  }
273
-
274
250
  throw lastError || new Error("all models failed");
275
251
  }
276
252
 
277
- async function callModel(
253
+ async function callOpenRouter(
278
254
  systemPrompt: string,
279
255
  userPrompt: string,
280
256
  images: ContextWindow["images"],
281
257
  model: string,
282
- config: AgentConfig,
258
+ config: AnalysisConfig,
283
259
  ): Promise<AgentResult> {
284
260
  const start = Date.now();
285
261
  const controller = new AbortController();
@@ -307,10 +283,10 @@ async function callModel(
307
283
 
308
284
  const imageCount = images?.length || 0;
309
285
 
310
- const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
286
+ const response = await fetch(config.endpoint, {
311
287
  method: "POST",
312
288
  headers: {
313
- "Authorization": `Bearer ${config.openrouterApiKey}`,
289
+ "Authorization": `Bearer ${config.apiKey}`,
314
290
  "Content-Type": "application/json",
315
291
  },
316
292
  body: JSON.stringify({
@@ -398,28 +374,27 @@ async function callModel(
398
374
  }
399
375
 
400
376
  /**
401
- * Call Ollama local vision model for image analysis.
402
- * Uses the /api/chat endpoint with base64 images.
403
- * Falls back to OpenRouter on any failure.
377
+ * Call Ollama local model for context analysis.
378
+ * Uses the /api/chat endpoint with optional base64 images.
404
379
  */
405
- async function callOllamaVision(
380
+ async function callOllama(
406
381
  systemPrompt: string,
407
382
  userPrompt: string,
408
383
  images: ContextWindow["images"],
409
- config: AgentConfig,
384
+ config: AnalysisConfig,
410
385
  ): Promise<AgentResult> {
411
386
  const start = Date.now();
412
387
  const controller = new AbortController();
413
- const timeout = setTimeout(() => controller.abort(), config.localVisionTimeout);
388
+ const timeout = setTimeout(() => controller.abort(), config.timeout);
414
389
 
415
390
  try {
416
391
  const imageB64List = (images || []).map((img) => img.data);
417
392
 
418
- const response = await fetch(`${config.localVisionUrl}/api/chat`, {
393
+ const response = await fetch(`${config.endpoint}/api/chat`, {
419
394
  method: "POST",
420
395
  headers: { "Content-Type": "application/json" },
421
396
  body: JSON.stringify({
422
- model: config.localVisionModel,
397
+ model: config.model,
423
398
  messages: [
424
399
  { role: "system", content: systemPrompt },
425
400
  { role: "user", content: userPrompt, images: imageB64List },
@@ -445,7 +420,7 @@ async function callOllamaVision(
445
420
  const tokensIn = data.prompt_eval_count || 0;
446
421
  const tokensOut = data.eval_count || 0;
447
422
 
448
- log(TAG, `ollama vision: model=${config.localVisionModel} latency=${latencyMs}ms tokens=${tokensIn}+${tokensOut}`);
423
+ log(TAG, `ollama vision: model=${config.model} latency=${latencyMs}ms tokens=${tokensIn}+${tokensOut}`);
449
424
 
450
425
  // Parse the response (same format as OpenRouter)
451
426
  // Parse JSON response (same logic as callModel)
@@ -459,7 +434,7 @@ async function callOllamaVision(
459
434
  task: parseTask(parsed),
460
435
  latencyMs,
461
436
  tokensIn, tokensOut,
462
- model: config.localVisionModel,
437
+ model: config.model,
463
438
  parsedOk: true,
464
439
  };
465
440
  } catch {
@@ -475,7 +450,7 @@ async function callOllamaVision(
475
450
  task: parseTask(parsed),
476
451
  latencyMs,
477
452
  tokensIn, tokensOut,
478
- model: config.localVisionModel,
453
+ model: config.model,
479
454
  parsedOk: true,
480
455
  };
481
456
  }
@@ -486,7 +461,7 @@ async function callOllamaVision(
486
461
  digest: content || "\u2014",
487
462
  latencyMs,
488
463
  tokensIn, tokensOut,
489
- model: config.localVisionModel,
464
+ model: config.model,
490
465
  parsedOk: false,
491
466
  };
492
467
  }
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
2
2
  import fs from "node:fs";
3
3
  import type { FeedBuffer } from "../buffers/feed-buffer.js";
4
4
  import type { SenseBuffer } from "../buffers/sense-buffer.js";
5
- import type { AgentConfig, AgentEntry, ContextWindow, EscalationMode, ContextRichness, RecorderStatus, SenseEvent, FeedbackRecord } from "../types.js";
5
+ import type { AnalysisConfig, AgentEntry, ContextWindow, EscalationMode, ContextRichness, RecorderStatus, SenseEvent, FeedbackRecord } from "../types.js";
6
6
  import type { Profiler } from "../profiler.js";
7
7
  import type { CostTracker } from "../cost/tracker.js";
8
8
  import { buildContextWindow, RICHNESS_PRESETS } from "./context-window.js";
@@ -18,7 +18,7 @@ const TAG = "agent";
18
18
  export interface AgentLoopDeps {
19
19
  feedBuffer: FeedBuffer;
20
20
  senseBuffer: SenseBuffer;
21
- agentConfig: AgentConfig;
21
+ agentConfig: AnalysisConfig;
22
22
  escalationMode: EscalationMode;
23
23
  situationMdPath: string;
24
24
  /** Called after analysis with digest + context for escalation check. */
@@ -106,9 +106,10 @@ export class AgentLoop extends EventEmitter {
106
106
  /** Start the agent loop. */
107
107
  start(): void {
108
108
  if (this.started) return;
109
- if (!this.deps.agentConfig.enabled || !this.deps.agentConfig.openrouterApiKey) {
110
- if (this.deps.agentConfig.enabled) {
111
- warn(TAG, "AGENT_ENABLED=true but OPENROUTER_API_KEY not set \u2014 agent disabled");
109
+ const ac = this.deps.agentConfig;
110
+ if (!ac.enabled || (ac.provider !== "ollama" && !ac.apiKey)) {
111
+ if (ac.enabled) {
112
+ warn(TAG, "AGENT_ENABLED=true but no API key and provider is not ollama \u2014 analysis disabled");
112
113
  }
113
114
  return;
114
115
  }
@@ -177,8 +178,8 @@ export class AgentLoop extends EventEmitter {
177
178
 
178
179
  /** Get config (safe — no API key). */
179
180
  getConfig(): Record<string, unknown> {
180
- const { openrouterApiKey, ...safe } = this.deps.agentConfig;
181
- return { ...safe, hasApiKey: !!openrouterApiKey, escalationMode: this.deps.escalationMode };
181
+ const { apiKey, ...safe } = this.deps.agentConfig;
182
+ return { ...safe, hasApiKey: !!apiKey, escalationMode: this.deps.escalationMode };
182
183
  }
183
184
 
184
185
  /** Get stats for /health. */
@@ -219,10 +220,10 @@ export class AgentLoop extends EventEmitter {
219
220
  if (updates.maxIntervalMs !== undefined) c.maxIntervalMs = Math.max(5000, parseInt(String(updates.maxIntervalMs)));
220
221
  if (updates.cooldownMs !== undefined) c.cooldownMs = Math.max(3000, parseInt(String(updates.cooldownMs)));
221
222
  if (updates.fallbackModels !== undefined) c.fallbackModels = Array.isArray(updates.fallbackModels) ? updates.fallbackModels : [];
222
- if (updates.openrouterApiKey !== undefined) c.openrouterApiKey = String(updates.openrouterApiKey);
223
+ if (updates.apiKey !== undefined) c.apiKey = String(updates.apiKey);
223
224
 
224
225
  // Restart loop if needed
225
- if (c.enabled && c.openrouterApiKey) {
226
+ if (c.enabled && (c.provider === "ollama" || c.apiKey)) {
226
227
  if (!this.started) this.start();
227
228
  else {
228
229
  // Reset max interval timer with new config
@@ -238,7 +239,7 @@ export class AgentLoop extends EventEmitter {
238
239
 
239
240
  private async run(): Promise<void> {
240
241
  if (this.running) return;
241
- if (!this.deps.agentConfig.openrouterApiKey) return;
242
+ if (this.deps.agentConfig.provider !== "ollama" && !this.deps.agentConfig.apiKey) return;
242
243
 
243
244
  // Cooldown: don't re-analyze within cooldownMs of last run (unless urgent)
244
245
  const isUrgent = this.urgentPending;
@@ -2,7 +2,7 @@ import { readFileSync, existsSync } from "node:fs";
2
2
  import { resolve, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import os from "node:os";
5
- import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig, AgentConfig, EscalationConfig, OpenClawConfig, EscalationMode, EscalationTransport, LearningConfig, TraitConfig, PrivacyConfig, PrivacyMatrix, PrivacyLevel, PrivacyRow } from "./types.js";
5
+ import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig, AnalysisConfig, EscalationConfig, OpenClawConfig, EscalationMode, EscalationTransport, LearningConfig, TraitConfig, PrivacyConfig, PrivacyMatrix, PrivacyLevel, PrivacyRow } from "./types.js";
6
6
  import { PRESETS } from "./privacy/presets.js";
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -178,25 +178,28 @@ export function loadConfig(): CoreConfig {
178
178
  },
179
179
  };
180
180
 
181
- const agentConfig: AgentConfig = {
181
+ const analysisProvider = env("ANALYSIS_PROVIDER", "openrouter") as import("./types.js").AnalysisProvider;
182
+ const defaultEndpoint = analysisProvider === "ollama"
183
+ ? "http://localhost:11434"
184
+ : "https://openrouter.ai/api/v1/chat/completions";
185
+
186
+ const agentConfig: import("./types.js").AnalysisConfig = {
182
187
  enabled: boolEnv("AGENT_ENABLED", true),
183
- model: env("AGENT_MODEL", "google/gemini-2.5-flash-lite"),
184
- visionModel: env("AGENT_VISION_MODEL", "google/gemini-2.5-flash"),
185
- visionEnabled: boolEnv("AGENT_VISION_ENABLED", true),
186
- localVisionEnabled: boolEnv("LOCAL_VISION_ENABLED", false),
187
- localVisionModel: env("LOCAL_VISION_MODEL", "llava"),
188
- localVisionUrl: env("LOCAL_VISION_URL", "http://localhost:11434"),
189
- localVisionTimeout: intEnv("LOCAL_VISION_TIMEOUT", 10000),
190
- openrouterApiKey: env("OPENROUTER_API_KEY", ""),
191
- maxTokens: intEnv("AGENT_MAX_TOKENS", 800),
192
- temperature: floatEnv("AGENT_TEMPERATURE", 0.3),
188
+ provider: analysisProvider,
189
+ model: env("ANALYSIS_MODEL", "google/gemini-2.5-flash-lite"),
190
+ visionModel: env("ANALYSIS_VISION_MODEL", "google/gemini-2.5-flash"),
191
+ endpoint: env("ANALYSIS_ENDPOINT", defaultEndpoint),
192
+ apiKey: env("ANALYSIS_API_KEY", env("OPENROUTER_API_KEY", "")),
193
+ maxTokens: intEnv("ANALYSIS_MAX_TOKENS", 800),
194
+ temperature: floatEnv("ANALYSIS_TEMPERATURE", 0.3),
195
+ fallbackModels: env("ANALYSIS_FALLBACK_MODELS", "google/gemini-2.5-flash,anthropic/claude-3.5-haiku")
196
+ .split(",").map(s => s.trim()).filter(Boolean),
197
+ timeout: intEnv("ANALYSIS_TIMEOUT", 15000),
193
198
  pushToFeed: boolEnv("AGENT_PUSH_TO_FEED", true),
194
199
  debounceMs: intEnv("AGENT_DEBOUNCE_MS", 3000),
195
200
  maxIntervalMs: intEnv("AGENT_MAX_INTERVAL_MS", 30000),
196
201
  cooldownMs: intEnv("AGENT_COOLDOWN_MS", 10000),
197
202
  maxAgeMs: intEnv("AGENT_MAX_AGE_MS", 120000),
198
- fallbackModels: env("AGENT_FALLBACK_MODELS", "google/gemini-2.5-flash,anthropic/claude-3.5-haiku")
199
- .split(",").map(s => s.trim()).filter(Boolean),
200
203
  historyLimit: intEnv("AGENT_HISTORY_LIMIT", 50),
201
204
  };
202
205
 
@@ -62,6 +62,15 @@ export function setupCommands(deps: CommandDeps): void {
62
62
  case "spawn_command": {
63
63
  const preview = msg.text.length > 60 ? msg.text.slice(0, 60) + "…" : msg.text;
64
64
  log(TAG, `spawn command received: "${preview}"`);
65
+ // Echo spawn command to all overlay clients as a feed item (green in UI)
66
+ wsHandler.broadcastRaw({
67
+ type: "feed",
68
+ text: `⚡ ${msg.text}`,
69
+ priority: "normal",
70
+ ts: Date.now(),
71
+ channel: "agent",
72
+ sender: "spawn",
73
+ } as any);
65
74
  if (deps.onSpawnCommand) {
66
75
  deps.onSpawnCommand(msg.text);
67
76
  } else {
@@ -226,6 +226,9 @@ export class WsHandler {
226
226
  case "user_command":
227
227
  log(TAG, `\u2190 user command: ${msg.text.slice(0, 100)}`);
228
228
  break;
229
+ case "spawn_command":
230
+ log(TAG, `\u2190 spawn command: ${msg.text.slice(0, 100)}`);
231
+ break;
229
232
  case "profiling":
230
233
  if (this.onProfilingCb) this.onProfilingCb(msg);
231
234
  return;
@@ -247,28 +247,31 @@ export interface StopResult {
247
247
  export type EscalationMode = "off" | "selective" | "focus" | "rich";
248
248
  export type ContextRichness = "lean" | "standard" | "rich";
249
249
 
250
- export interface AgentConfig {
250
+ export type AnalysisProvider = "openrouter" | "ollama";
251
+
252
+ export interface AnalysisConfig {
251
253
  enabled: boolean;
254
+ provider: AnalysisProvider;
252
255
  model: string;
253
256
  visionModel: string;
254
- visionEnabled: boolean;
255
- localVisionEnabled: boolean;
256
- localVisionModel: string;
257
- localVisionUrl: string;
258
- localVisionTimeout: number;
259
- openrouterApiKey: string;
257
+ endpoint: string;
258
+ apiKey: string;
260
259
  maxTokens: number;
261
260
  temperature: number;
261
+ fallbackModels: string[];
262
+ timeout: number;
263
+ // Loop timing
262
264
  pushToFeed: boolean;
263
265
  debounceMs: number;
264
266
  maxIntervalMs: number;
265
267
  cooldownMs: number;
266
268
  maxAgeMs: number;
267
- fallbackModels: string[];
268
- /** Maximum entries to keep in agent history buffer (default: 50) */
269
269
  historyLimit: number;
270
270
  }
271
271
 
272
+ /** @deprecated Use AnalysisConfig */
273
+ export type AgentConfig = AnalysisConfig;
274
+
272
275
  export interface AgentResult {
273
276
  hud: string;
274
277
  digest: string;
@@ -468,7 +471,7 @@ export interface CoreConfig {
468
471
  micConfig: AudioPipelineConfig;
469
472
  micEnabled: boolean;
470
473
  transcriptionConfig: TranscriptionConfig;
471
- agentConfig: AgentConfig;
474
+ agentConfig: AnalysisConfig;
472
475
  escalationConfig: EscalationConfig;
473
476
  openclawConfig: OpenClawConfig;
474
477
  situationMdPath: string;
@@ -43,7 +43,6 @@ function writeDistillState(workspaceDir: string, state: DistillState): void {
43
43
 
44
44
  export type HeartbeatResult = {
45
45
  status: string;
46
- gitBackup: string | null;
47
46
  signals: unknown[];
48
47
  recommendedAction: { action: string; task: string | null; confidence: number };
49
48
  output: unknown | null;
@@ -95,7 +94,6 @@ export class CurationEngine {
95
94
  const workspaceDir = this.store.getWorkspaceDir();
96
95
  const result: HeartbeatResult = {
97
96
  status: "ok",
98
- gitBackup: null,
99
97
  signals: [],
100
98
  recommendedAction: { action: "skip", task: null, confidence: 0 },
101
99
  output: null,
@@ -131,20 +129,6 @@ export class CurationEngine {
131
129
  const latencyMs: Record<string, number> = {};
132
130
  const heartbeatStart = Date.now();
133
131
 
134
- // 1. Git backup (30s timeout)
135
- try {
136
- const t0 = Date.now();
137
- const gitOut = await this.runScript(
138
- ["bash", "sinain-memory/git_backup.sh"],
139
- { timeoutMs: 30_000, cwd: workspaceDir },
140
- );
141
- latencyMs.gitBackup = Date.now() - t0;
142
- result.gitBackup = gitOut.stdout.trim() || "nothing to commit";
143
- } catch (err) {
144
- this.logger.warn(`sinain-hud: git backup error: ${String(err)}`);
145
- result.gitBackup = `error: ${String(err)}`;
146
- }
147
-
148
132
  // Current time string for memory scripts
149
133
  const hbTz = this.config.userTimezone;
150
134
  const currentTimeStr = new Date().toLocaleString("en-GB", {
@@ -291,7 +275,6 @@ export class CurationEngine {
291
275
  output: result.output,
292
276
  skipped: result.skipped,
293
277
  skipReason: result.skipReason,
294
- gitBackup: result.gitBackup,
295
278
  latencyMs,
296
279
  totalLatencyMs,
297
280
  };
@@ -59,4 +59,4 @@ SINAIN_BACKUP_REPO=<git-url> npx sinain
59
59
  - Token printed at end (or visible in Brev dashboard → Gateway Token)
60
60
  - Mac side: `./setup-nemoclaw.sh` → 5 prompts → overlay starts
61
61
 
62
- Memory is git-backed via `git_backup.sh` on every heartbeat tick. New instances restore instantly via `SINAIN_BACKUP_REPO`.
62
+ Memory is backed up via knowledge snapshots to `~/.sinain/knowledge-snapshots/`. New instances restore instantly via `SINAIN_BACKUP_REPO`.
@@ -317,23 +317,7 @@ server.tool(
317
317
  const results: string[] = [];
318
318
  const now = new Date().toISOString();
319
319
 
320
- // Step 1: git_backup.sh
321
- const gitBackupPath = resolve(SCRIPTS_DIR, "git_backup.sh");
322
- if (existsSync(gitBackupPath)) {
323
- try {
324
- const out = await new Promise<string>((res, rej) => {
325
- execFile("bash", [gitBackupPath, MEMORY_DIR], { timeout: 30_000 }, (err, stdout, stderr) => {
326
- if (err) rej(new Error(`git_backup failed: ${err.message}\n${stderr}`));
327
- else res(stdout);
328
- });
329
- });
330
- results.push(`[git_backup] ${out.trim() || "OK"}`);
331
- } catch (err: any) {
332
- results.push(`[git_backup] FAILED: ${err.message}`);
333
- }
334
- }
335
-
336
- // Step 2: signal_analyzer.py
320
+ // Step 1: signal_analyzer.py
337
321
  try {
338
322
  const out = await runScript([
339
323
  resolve(SCRIPTS_DIR, "signal_analyzer.py"),
@@ -346,7 +330,7 @@ server.tool(
346
330
  results.push(`[signal_analyzer] FAILED: ${err.message}`);
347
331
  }
348
332
 
349
- // Step 3: insight_synthesizer.py
333
+ // Step 2: insight_synthesizer.py
350
334
  try {
351
335
  const out = await runScript([
352
336
  resolve(SCRIPTS_DIR, "insight_synthesizer.py"),
@@ -358,7 +342,7 @@ server.tool(
358
342
  results.push(`[insight_synthesizer] FAILED: ${err.message}`);
359
343
  }
360
344
 
361
- // Step 4: memory_miner.py
345
+ // Step 3: memory_miner.py
362
346
  try {
363
347
  const out = await runScript([
364
348
  resolve(SCRIPTS_DIR, "memory_miner.py"),
@@ -369,7 +353,7 @@ server.tool(
369
353
  results.push(`[memory_miner] FAILED: ${err.message}`);
370
354
  }
371
355
 
372
- // Step 5: playbook_curator.py
356
+ // Step 4: playbook_curator.py
373
357
  try {
374
358
  const out = await runScript([
375
359
  resolve(SCRIPTS_DIR, "playbook_curator.py"),
@@ -1,23 +0,0 @@
1
- # sinain-agent configuration
2
- # Copy to .env and customize: cp .env.example .env
3
-
4
- # ── Agent ──
5
- SINAIN_AGENT=claude # claude | codex | junie | goose | aider | <custom command>
6
- # MCP agents (claude, codex, junie, goose) call sinain tools directly
7
- # Pipe agents (aider, custom) receive escalation text on stdin
8
-
9
- # ── Core connection ──
10
- SINAIN_CORE_URL=http://localhost:9500
11
-
12
- # ── Timing ──
13
- SINAIN_POLL_INTERVAL=5 # seconds between escalation polls
14
- SINAIN_HEARTBEAT_INTERVAL=900 # seconds between heartbeat ticks (15 min)
15
-
16
- # ── Workspace ──
17
- SINAIN_WORKSPACE=~/.openclaw/workspace # knowledge files, curation scripts, playbook
18
-
19
- # ── Tool permissions (Claude only) ──
20
- # Tools auto-approved without prompting (space-separated).
21
- # Default: auto-derived from MCP config server names (e.g. mcp__sinain).
22
- # Format: mcp__<server> (all tools) | mcp__<server>__<tool> (specific) | Bash(pattern)
23
- # SINAIN_ALLOWED_TOOLS=mcp__sinain mcp__github Bash(git *)
@@ -1,19 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Phase 1: Git backup — commit and push any uncommitted changes in the workspace.
3
- # Runs from the workspace root. Exits 0 on success or nothing to commit, 1 on push failure.
4
-
5
- set -euo pipefail
6
-
7
- changes=$(git status --porcelain 2>/dev/null || true)
8
-
9
- if [ -z "$changes" ]; then
10
- echo "nothing to commit"
11
- exit 0
12
- fi
13
-
14
- git add -A
15
- git commit -m "auto: heartbeat $(date -u +%Y-%m-%dT%H:%M:%SZ)"
16
- git push origin main
17
-
18
- # Output the commit hash
19
- git rev-parse --short HEAD