@geravant/sinain 1.24.1 → 1.26.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
@@ -16,6 +16,17 @@ ANALYSIS_MODEL=google/gemini-2.5-flash-lite
16
16
  OPENROUTER_API_KEY= # get one free at https://openrouter.ai
17
17
  # used by context analysis + transcription
18
18
 
19
+ # ── Local Mode (unified — fully offline, zero cloud) ─────────────────────────
20
+ # SINAIN_LOCAL_* is the primary namespace. Setting SINAIN_LOCAL_MODE=true
21
+ # auto-derives analyzer, vision, and transcription config from the two model
22
+ # vars below (see sinain-core/src/config.ts). The legacy LOCAL_VISION_* vars
23
+ # are still honored as a fallback but are deprecated — prefer SINAIN_LOCAL_*.
24
+ # Quick start: cp .env.paranoid .env (or ./start.sh --paranoid)
25
+ # Prereqs: ollama serve && ollama pull phi4-mini qwen2.5vl:7b
26
+ # SINAIN_LOCAL_MODE=false # true → offline analyzer + vision + STT
27
+ # SINAIN_LOCAL_LLM=phi4-mini # Ollama model: analyzer + distiller
28
+ # SINAIN_LOCAL_VISION=qwen2.5vl:7b # Ollama model: screen OCR / scene (sense_client)
29
+
19
30
  # ── Privacy ──────────────────────────────────────────────────────────────────
20
31
  PRIVACY_MODE=standard # off | standard | strict | paranoid
21
32
  # standard: auto-redacts credentials before cloud APIs
@@ -86,7 +97,7 @@ TRANSCRIPTION_LANGUAGE=en-US
86
97
  # Install: brew install whisper-cpp
87
98
  # Models: https://huggingface.co/ggerganov/whisper.cpp/tree/main
88
99
  # LOCAL_WHISPER_BIN=whisper-cli
89
- # LOCAL_WHISPER_MODEL=~/models/ggml-large-v3-turbo.bin
100
+ # LOCAL_WHISPER_MODEL=~/.sinain/models/whisper/ggml-large-v3-turbo.bin
90
101
  # LOCAL_WHISPER_TIMEOUT_MS=15000
91
102
 
92
103
  # ── OpenClaw / NemoClaw Gateway ──────────────────────────────────────────────
package/config-shared.js CHANGED
@@ -531,34 +531,185 @@ async function setupLocalGateway(existing) {
531
531
  };
532
532
  }
533
533
 
534
- export async function stepPrivacy(existing, label = "Privacy mode") {
534
+ /**
535
+ * Local mode: run everything on-device with Ollama + whisper.cpp.
536
+ *
537
+ * Returns null (skip) or { llm, vision } model names.
538
+ * When enabled, also checks Ollama is reachable and offers to pull models.
539
+ */
540
+ export async function stepLocalMode(existing, label = "Local mode (Ollama)") {
541
+ const currentEnabled = existing.SINAIN_LOCAL_MODE === "true";
542
+ const enable = guard(await p.confirm({
543
+ message: `${label} — run analysis + OCR on your machine, no cloud?`,
544
+ initialValue: currentEnabled,
545
+ }));
546
+
547
+ if (!enable) return null;
548
+
549
+ // Check Ollama
550
+ let ollamaOk = false;
551
+ let availableModels = [];
552
+ const s = p.spinner();
553
+ s.start("Checking Ollama...");
554
+ try {
555
+ const res = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(3000) });
556
+ if (res.ok) {
557
+ const data = await res.json();
558
+ availableModels = (data.models || []).map((m) => m.name);
559
+ ollamaOk = true;
560
+ s.stop(c.green(`Ollama running (${availableModels.length} models).`));
561
+ } else {
562
+ s.stop(c.yellow("Ollama responded but returned an error."));
563
+ }
564
+ } catch {
565
+ s.stop(c.yellow("Ollama not reachable at localhost:11434."));
566
+ }
567
+
568
+ if (!ollamaOk) {
569
+ p.note(
570
+ "Install and start Ollama first:\n" +
571
+ " brew install ollama && ollama serve\n" +
572
+ "Then re-run setup.",
573
+ "Ollama required",
574
+ );
575
+ const proceed = guard(await p.confirm({
576
+ message: "Continue anyway? (config will be saved, but won't work until Ollama runs)",
577
+ initialValue: false,
578
+ }));
579
+ if (!proceed) return null;
580
+ }
581
+
582
+ // LLM model (analysis + distillation)
583
+ const currentLlm = existing.SINAIN_LOCAL_LLM || "phi4-mini";
584
+ const llmOptions = [
585
+ { value: "phi4-mini", label: "phi4-mini", hint: "2.5 GB — fast, good quality (recommended)" },
586
+ { value: "gemma3:4b", label: "gemma3:4b", hint: "2.5 GB — Google, competitive quality" },
587
+ { value: "llama3.2:3b", label: "llama3.2:3b", hint: "2.0 GB — Meta, smallest" },
588
+ ];
589
+ // Add current model if it's custom and not in the list
590
+ if (!llmOptions.some((o) => o.value === currentLlm)) {
591
+ llmOptions.push({ value: currentLlm, label: currentLlm, hint: "currently configured" });
592
+ }
593
+ llmOptions.push({ value: "custom", label: "Custom", hint: "Enter any Ollama model name" });
594
+
595
+ let llm = guard(await p.select({
596
+ message: "LLM model (analysis + knowledge distillation)",
597
+ options: llmOptions,
598
+ initialValue: llmOptions.some((o) => o.value === currentLlm) ? currentLlm : "custom",
599
+ }));
600
+
601
+ if (llm === "custom") {
602
+ llm = guard(await p.text({
603
+ message: "Ollama model name for LLM",
604
+ placeholder: "model-name or model-name:tag",
605
+ validate: (val) => { if (!val) return "Model name required"; },
606
+ }));
607
+ }
608
+
609
+ // Vision model (screen OCR)
610
+ const currentVision = existing.SINAIN_LOCAL_VISION || "qwen2.5vl:7b";
611
+ const visionOptions = [
612
+ { value: "qwen2.5vl:7b", label: "qwen2.5vl:7b", hint: "4.7 GB — best OCR quality (recommended)" },
613
+ { value: "gemma4:e2b", label: "gemma4:e2b", hint: "5.2 GB — Google multimodal, new" },
614
+ { value: "llava:7b", label: "llava:7b", hint: "4.7 GB — general purpose vision" },
615
+ { value: "moondream", label: "moondream", hint: "1.7 GB — fastest, lower quality" },
616
+ ];
617
+ if (!visionOptions.some((o) => o.value === currentVision)) {
618
+ visionOptions.push({ value: currentVision, label: currentVision, hint: "currently configured" });
619
+ }
620
+ visionOptions.push({ value: "custom", label: "Custom", hint: "Enter any Ollama vision model" });
621
+
622
+ let vision = guard(await p.select({
623
+ message: "Vision model (screen OCR)",
624
+ options: visionOptions,
625
+ initialValue: visionOptions.some((o) => o.value === currentVision) ? currentVision : "custom",
626
+ }));
627
+
628
+ if (vision === "custom") {
629
+ vision = guard(await p.text({
630
+ message: "Ollama model name for vision",
631
+ placeholder: "model-name:tag",
632
+ validate: (val) => { if (!val) return "Model name required"; },
633
+ }));
634
+ }
635
+
636
+ // Offer to pull missing models
637
+ if (ollamaOk) {
638
+ const missing = [llm, vision].filter((m) => !availableModels.some((a) => a.startsWith(m)));
639
+ if (missing.length > 0) {
640
+ const pull = guard(await p.confirm({
641
+ message: `Pull missing models? (${missing.join(", ")})`,
642
+ initialValue: true,
643
+ }));
644
+ if (pull) {
645
+ for (const model of missing) {
646
+ const sp = p.spinner();
647
+ sp.start(`Pulling ${model}...`);
648
+ try {
649
+ execFileSync("ollama", ["pull", model], { stdio: "pipe", timeout: 600_000 });
650
+ sp.stop(c.green(`${model} pulled.`));
651
+ } catch {
652
+ sp.stop(c.yellow(`Failed to pull ${model} — pull manually: ollama pull ${model}`));
653
+ }
654
+ }
655
+ }
656
+ }
657
+ }
658
+
659
+ return { llm, vision };
660
+ }
661
+
662
+ export async function stepPrivacy(existing, label = "Privacy mode", { localModeEnabled = false } = {}) {
535
663
  const current = existing.PRIVACY_MODE || "standard";
536
- return guard(await p.select({
664
+
665
+ const options = [
666
+ {
667
+ value: "off",
668
+ label: "Off",
669
+ hint: "No filtering — screen text, credentials, everything sent to cloud",
670
+ },
671
+ {
672
+ value: "standard",
673
+ label: "Standard",
674
+ hint: "Auto-redacts cards, API keys, tokens before sending to cloud",
675
+ },
676
+ {
677
+ value: "strict",
678
+ label: "Strict",
679
+ hint: "Only summaries leave your machine, no raw screen text or audio",
680
+ },
681
+ ];
682
+
683
+ if (localModeEnabled) {
684
+ options.push({
685
+ value: "paranoid",
686
+ label: "Paranoid",
687
+ hint: "Zero cloud calls — all processing stays on-device via Ollama + Whisper",
688
+ });
689
+ } else {
690
+ options.push({
691
+ value: "paranoid",
692
+ label: "Paranoid",
693
+ hint: c.dim("Requires local mode — enable it first"),
694
+ });
695
+ }
696
+
697
+ const choice = guard(await p.select({
537
698
  message: label,
538
- options: [
539
- {
540
- value: "off",
541
- label: "Off",
542
- hint: "No filtering — screen text, credentials, everything sent to cloud",
543
- },
544
- {
545
- value: "standard",
546
- label: "Standard",
547
- hint: "Auto-redacts cards, API keys, tokens before sending to cloud",
548
- },
549
- {
550
- value: "strict",
551
- label: "Strict",
552
- hint: "Only summaries leave your machine, no raw screen text or audio",
553
- },
554
- {
555
- value: "paranoid",
556
- label: "Paranoid",
557
- hint: "Zero cloud calls — needs Whisper + Ollama installed or nothing works",
558
- },
559
- ],
560
- initialValue: current,
699
+ options,
700
+ initialValue: current === "paranoid" && !localModeEnabled ? "standard" : current,
561
701
  }));
702
+
703
+ if (choice === "paranoid" && !localModeEnabled) {
704
+ p.log.warn("Paranoid mode requires local mode (Ollama + Whisper). Enable local mode first.");
705
+ return guard(await p.select({
706
+ message: `${label} (local mode not enabled)`,
707
+ options: options.slice(0, 3),
708
+ initialValue: "standard",
709
+ }));
710
+ }
711
+
712
+ return choice;
562
713
  }
563
714
 
564
715
  export async function stepModel(existing, label = "AI model for HUD analysis") {
package/config.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import * as p from "@clack/prompts";
7
7
  import {
8
8
  c, guard, readEnv, writeEnv, summarizeConfig, runHealthCheck,
9
- stepApiKey, stepTranscription, stepGateway, stepPrivacy, stepModel, stepAgent,
9
+ stepApiKey, stepTranscription, stepGateway, stepPrivacy, stepModel, stepAgent, stepLocalMode,
10
10
  ENV_PATH, IS_WINDOWS, HOME, PKG_DIR,
11
11
  } from "./config-shared.js";
12
12
  import fs from "fs";
@@ -16,6 +16,7 @@ import path from "path";
16
16
 
17
17
  const SECTIONS = [
18
18
  { value: "apikey", label: "API Key", hint: "OpenRouter API key" },
19
+ { value: "localmode", label: "Local Mode", hint: "Ollama + Whisper, zero cloud" },
19
20
  { value: "transcription", label: "Transcription", hint: "Cloud or local whisper" },
20
21
  { value: "model", label: "Model", hint: "AI model for analysis" },
21
22
  { value: "privacy", label: "Privacy", hint: "Standard / strict / paranoid" },
@@ -48,9 +49,26 @@ async function runSection(section, existing) {
48
49
  const model = await stepModel(existing);
49
50
  return { AGENT_MODEL: model };
50
51
  }
52
+ case "localmode": {
53
+ const result = await stepLocalMode(existing);
54
+ if (result) {
55
+ return {
56
+ SINAIN_LOCAL_MODE: "true",
57
+ SINAIN_LOCAL_LLM: result.llm,
58
+ SINAIN_LOCAL_VISION: result.vision,
59
+ };
60
+ }
61
+ return { SINAIN_LOCAL_MODE: "" };
62
+ }
51
63
  case "privacy": {
52
- const mode = await stepPrivacy(existing);
53
- return { PRIVACY_MODE: mode };
64
+ const localModeEnabled = existing.SINAIN_LOCAL_MODE === "true";
65
+ const mode = await stepPrivacy(existing, "Privacy mode", { localModeEnabled });
66
+ const vars = { PRIVACY_MODE: mode };
67
+ if (mode === "paranoid" && localModeEnabled) {
68
+ vars.PRIVACY_OCR_AGENT_GATEWAY = "redacted";
69
+ vars.PRIVACY_AUDIO_AGENT_GATEWAY = "redacted";
70
+ }
71
+ return vars;
54
72
  }
55
73
  case "gateway": {
56
74
  return await stepGateway(existing);
package/launcher.js CHANGED
@@ -106,6 +106,20 @@ async function main() {
106
106
  // Load user config
107
107
  loadUserEnv();
108
108
 
109
+ // Propagate unified local mode config to component-level vars
110
+ if (process.env.SINAIN_LOCAL_MODE === "true") {
111
+ const llm = process.env.SINAIN_LOCAL_LLM || "phi4-mini";
112
+ const vision = process.env.SINAIN_LOCAL_VISION || "qwen2.5vl:7b";
113
+ if (!process.env.LOCAL_VISION_ENABLED) process.env.LOCAL_VISION_ENABLED = "true";
114
+ if (!process.env.LOCAL_VISION_MODEL) process.env.LOCAL_VISION_MODEL = vision;
115
+ if (!process.env.ANALYSIS_PROVIDER) process.env.ANALYSIS_PROVIDER = "ollama";
116
+ if (!process.env.ANALYSIS_MODEL) process.env.ANALYSIS_MODEL = llm;
117
+ if (!process.env.TRANSCRIPTION_BACKEND) process.env.TRANSCRIPTION_BACKEND = "local";
118
+ if (!process.env.SINAIN_FAST_MODEL) process.env.SINAIN_FAST_MODEL = `ollama/${llm}`;
119
+ if (!process.env.SINAIN_SMART_MODEL) process.env.SINAIN_SMART_MODEL = `ollama/${llm}`;
120
+ log(`${MAGENTA}LOCAL MODE${RESET} — LLM: ${llm}, Vision: ${vision}`);
121
+ }
122
+
109
123
  // Ensure Ollama is running (if local vision enabled)
110
124
  if (process.env.LOCAL_VISION_ENABLED === "true") {
111
125
  await ensureOllama();
@@ -162,10 +176,11 @@ async function main() {
162
176
  color: CYAN,
163
177
  });
164
178
 
165
- // Health check
166
- const healthy = await healthCheck("http://localhost:9500/health", 20);
179
+ // Health check (local mode needs longer — cold model load + startup distillation)
180
+ const healthTimeout = process.env.SINAIN_LOCAL_MODE === "true" ? 45 : 20;
181
+ const healthy = await healthCheck("http://localhost:9500/health", healthTimeout);
167
182
  if (!healthy) {
168
- fail("sinain-core did not become healthy after 20s");
183
+ fail(`sinain-core did not become healthy after ${healthTimeout}s`);
169
184
  }
170
185
  ok("sinain-core healthy on :9500");
171
186
 
@@ -398,8 +413,7 @@ async function preflight() {
398
413
  if (fs.existsSync(prebuiltApp)) {
399
414
  ok("overlay: pre-built app");
400
415
  } else {
401
- warn("no overlay available — run: sinain setup-overlay");
402
- skipOverlay = true;
416
+ warn("no overlay available — will auto-download from GitHub Releases");
403
417
  }
404
418
  }
405
419
 
package/onboard.js CHANGED
@@ -8,8 +8,8 @@ import fs from "fs";
8
8
  import path from "path";
9
9
  import { execFileSync } from "child_process";
10
10
  import {
11
- c, guard, maskKey, readEnv, writeEnv, writeAgentsConfig, summarizeConfig, runHealthCheck,
12
- stepApiKey, stepTranscription, stepGateway, stepPrivacy, stepModel,
11
+ c, guard, cmdExists, maskKey, readEnv, writeEnv, writeAgentsConfig, summarizeConfig, runHealthCheck,
12
+ stepApiKey, stepTranscription, stepGateway, stepPrivacy, stepModel, stepLocalMode,
13
13
  HOME, SINAIN_DIR, ENV_PATH, PKG_DIR, IS_WINDOWS, IS_MAC,
14
14
  } from "./config-shared.js";
15
15
  import { stepMcpInstall, detectMcpAgents } from "./mcp-register.js";
@@ -68,9 +68,12 @@ async function stepOverlay(existing) {
68
68
  const label = choice === "download" ? "Downloading overlay..." : "Building overlay from source...";
69
69
  s.start(label);
70
70
  try {
71
- // setup-overlay.js handles both modes via process.argv
72
- if (choice === "source") process.argv.push("--from-source");
73
- await import("./setup-overlay.js");
71
+ const { downloadOverlay, buildFromSource } = await import("./setup-overlay.js");
72
+ if (choice === "source") {
73
+ await buildFromSource();
74
+ } else {
75
+ await downloadOverlay({ silent: true });
76
+ }
74
77
  s.stop(c.green("Overlay installed."));
75
78
  } catch (err) {
76
79
  s.stop(c.yellow(`Failed: ${err.message}`));
@@ -130,6 +133,11 @@ export async function runOnboard(args = {}) {
130
133
  label: "QuickStart",
131
134
  hint: "Get running in 2 minutes. Configure details later.",
132
135
  },
136
+ {
137
+ value: "local",
138
+ label: "Local / Paranoid",
139
+ hint: "Fully offline — Ollama + Whisper, zero cloud calls.",
140
+ },
133
141
  {
134
142
  value: "advanced",
135
143
  label: "Advanced",
@@ -139,7 +147,7 @@ export async function runOnboard(args = {}) {
139
147
  initialValue: "quickstart",
140
148
  }));
141
149
 
142
- const totalSteps = flow === "quickstart" ? 2 : 6;
150
+ const totalSteps = flow === "quickstart" ? 2 : flow === "local" ? 4 : 6;
143
151
 
144
152
  // ── Collect vars ────────────────────────────────────────────────────────
145
153
 
@@ -149,12 +157,130 @@ export async function runOnboard(args = {}) {
149
157
  // complete so we don't churn ~/.sinain/agents.json on every prompt.
150
158
  let agentsPatch = {};
151
159
 
152
- // Step 1: API key (both flows)
153
- const apiKey = await stepApiKey(base, `[1/${totalSteps}] OpenRouter API key`);
154
- vars.OPENROUTER_API_KEY = apiKey;
155
- p.log.success("API key saved.");
160
+ // Step 1: API key (quickstart + advanced only — local mode skips cloud)
161
+ if (flow !== "local") {
162
+ const apiKey = await stepApiKey(base, `[1/${totalSteps}] OpenRouter API key`);
163
+ vars.OPENROUTER_API_KEY = apiKey;
164
+ p.log.success("API key saved.");
165
+ }
166
+
167
+ if (flow === "local") {
168
+ // ── Local / Paranoid flow ─────────────────────────────────────────────
169
+ // Step 1: Local models (Ollama)
170
+ const localResult = await stepLocalMode(base, `[1/${totalSteps}] Local models`);
171
+ if (localResult) {
172
+ vars.SINAIN_LOCAL_MODE = "true";
173
+ vars.SINAIN_LOCAL_LLM = localResult.llm;
174
+ vars.SINAIN_LOCAL_VISION = localResult.vision;
175
+ p.log.success(`LLM: ${localResult.llm}, Vision: ${localResult.vision}`);
176
+ } else {
177
+ p.log.warn("Local mode cancelled — switching to QuickStart defaults.");
178
+ vars.TRANSCRIPTION_BACKEND = "openrouter";
179
+ vars.PRIVACY_MODE = "standard";
180
+ vars.AGENT_MODEL = "google/gemini-2.5-flash-lite";
181
+ }
182
+
183
+ // Step 2: Whisper setup (if local mode enabled)
184
+ if (vars.SINAIN_LOCAL_MODE === "true") {
185
+ vars.TRANSCRIPTION_BACKEND = "local";
186
+ const hasWhisper = !IS_WINDOWS && cmdExists("whisper-cli");
187
+ if (hasWhisper) {
188
+ p.log.success(`[2/${totalSteps}] whisper-cli found — local transcription enabled.`);
189
+ } else if (IS_MAC) {
190
+ const install = guard(await p.confirm({
191
+ message: `[2/${totalSteps}] whisper-cli not found. Install via Homebrew?`,
192
+ initialValue: true,
193
+ }));
194
+ if (install) {
195
+ const s = p.spinner();
196
+ s.start("Installing whisper-cpp...");
197
+ try {
198
+ execFileSync("brew", ["install", "whisper-cpp"], { stdio: "pipe" });
199
+ s.stop(c.green("whisper-cpp installed."));
200
+ } catch {
201
+ s.stop(c.yellow("Install failed — audio transcription won't work offline."));
202
+ }
203
+ }
204
+ }
205
+ // Check whisper model
206
+ const modelDir = path.join(HOME, "models");
207
+ const modelPath = path.join(modelDir, "ggml-large-v3-turbo.bin");
208
+ if (fs.existsSync(modelPath)) {
209
+ vars.LOCAL_WHISPER_MODEL = modelPath;
210
+ p.log.info(`Whisper model: ${c.dim(modelPath)}`);
211
+ } else {
212
+ const download = guard(await p.confirm({
213
+ message: "Download Whisper model (~1.5 GB)?",
214
+ initialValue: true,
215
+ }));
216
+ if (download) {
217
+ const s = p.spinner();
218
+ s.start("Downloading Whisper model...");
219
+ try {
220
+ fs.mkdirSync(modelDir, { recursive: true });
221
+ execFileSync("curl", [
222
+ "-L", "--progress-bar",
223
+ "-o", modelPath,
224
+ "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin",
225
+ ], { stdio: "inherit" });
226
+ s.stop(c.green("Model downloaded."));
227
+ vars.LOCAL_WHISPER_MODEL = modelPath;
228
+ } catch {
229
+ s.stop(c.yellow("Download failed. Run manually later."));
230
+ }
231
+ }
232
+ }
233
+
234
+ // Step 3: Privacy — default to paranoid since user chose local mode
235
+ vars.PRIVACY_MODE = "paranoid";
236
+ const privacy = await stepPrivacy(base, `[3/${totalSteps}] Privacy mode`, { localModeEnabled: true });
237
+ vars.PRIVACY_MODE = privacy;
238
+ p.log.success(`Privacy: ${privacy}.`);
239
+
240
+ // Privacy overrides for escalation (redacted OCR+audio in escalation)
241
+ if (privacy === "paranoid") {
242
+ vars.PRIVACY_OCR_AGENT_GATEWAY = "redacted";
243
+ vars.PRIVACY_AUDIO_AGENT_GATEWAY = "redacted";
244
+ }
245
+ }
156
246
 
157
- if (flow === "quickstart") {
247
+ // Step 4: Gateway (optional works with local mode too)
248
+ const hasExistingGateway = (() => {
249
+ try {
250
+ const agentsPath = path.join(SINAIN_DIR, "agents.json");
251
+ if (!fs.existsSync(agentsPath)) return false;
252
+ const cfg = JSON.parse(fs.readFileSync(agentsPath, "utf-8"));
253
+ return !!cfg?.profiles?.openclaw;
254
+ } catch { return false; }
255
+ })();
256
+ const enableGateway = guard(await p.confirm({
257
+ message: `[4/${totalSteps}] Enable OpenClaw gateway? (escalation agent for deeper analysis)`,
258
+ initialValue: hasExistingGateway,
259
+ }));
260
+ if (enableGateway) {
261
+ const gatewayResult = await stepGateway(base, "OpenClaw gateway");
262
+ Object.assign(vars, gatewayResult.envVars);
263
+ Object.assign(agentsPatch, gatewayResult.agentsPatch);
264
+ } else {
265
+ agentsPatch.openclawProfile = null;
266
+ }
267
+ agentsPatch.default = base.SINAIN_AGENT || "claude";
268
+
269
+ p.note(
270
+ [
271
+ `Local mode: ${vars.SINAIN_LOCAL_MODE === "true" ? c.green("enabled") : "disabled"}`,
272
+ vars.SINAIN_LOCAL_LLM ? ` LLM: ${vars.SINAIN_LOCAL_LLM}` : null,
273
+ vars.SINAIN_LOCAL_VISION ? ` Vision: ${vars.SINAIN_LOCAL_VISION}` : null,
274
+ `Transcription: ${vars.TRANSCRIPTION_BACKEND}`,
275
+ `Privacy: ${vars.PRIVACY_MODE}`,
276
+ `OpenClaw gateway: ${enableGateway ? "enabled" : "disabled"}`,
277
+ "",
278
+ `Start with: ./start.sh --paranoid`,
279
+ `Change later: sinain config`,
280
+ ].filter(Boolean).join("\n"),
281
+ "Local mode summary",
282
+ );
283
+ } else if (flow === "quickstart") {
158
284
  // QuickStart: sensible defaults + a single opt-in question for OpenClaw.
159
285
  // Gateway integration is off by default; users who want it run Advanced
160
286
  // (or answer Yes here, which then walks them through stepGateway).
@@ -267,35 +393,14 @@ export async function runOnboard(args = {}) {
267
393
  }
268
394
  }
269
395
 
270
- // If Ollama is installed, offer to pull a local LLM for paranoid-mode
271
- // analysis. Mirrors the whisper download pattern — auto-acquire optional,
272
- // user can `ollama pull <model>` manually later if they skip here.
273
- let ollamaInstalled = false;
274
- try {
275
- execFileSync("ollama", ["--version"], { stdio: "ignore" });
276
- ollamaInstalled = true;
277
- } catch { /* ollama not on PATH */ }
278
-
279
- if (ollamaInstalled) {
280
- const pullOllama = guard(await p.confirm({
281
- message: "Pull an Ollama model for paranoid-mode analysis (~4.7 GB for llava)?",
282
- initialValue: true,
283
- }));
284
- if (pullOllama) {
285
- const modelName = guard(await p.text({
286
- message: "Ollama model to pull",
287
- placeholder: "llava",
288
- defaultValue: "llava",
289
- }));
290
- const s = p.spinner();
291
- s.start(`Pulling ${modelName} via Ollama (this can take several minutes)...`);
292
- try {
293
- execFileSync("ollama", ["pull", modelName], { stdio: "inherit" });
294
- s.stop(c.green(`Pulled ${modelName}.`));
295
- } catch {
296
- s.stop(c.yellow(`Pull failed. Run \`ollama pull ${modelName}\` manually later.`));
297
- }
298
- }
396
+ // Offer local mode (Ollama) enables paranoid privacy
397
+ const localResult = await stepLocalMode(base, "Local mode (Ollama)");
398
+ const localModeEnabled = !!localResult;
399
+ if (localResult) {
400
+ vars.SINAIN_LOCAL_MODE = "true";
401
+ vars.SINAIN_LOCAL_LLM = localResult.llm;
402
+ vars.SINAIN_LOCAL_VISION = localResult.vision;
403
+ p.log.success(`Local mode: LLM=${localResult.llm}, Vision=${localResult.vision}`);
299
404
  }
300
405
 
301
406
  // OpenClaw gateway is opt-in: most users run sinain in standalone mode
@@ -333,8 +438,12 @@ export async function runOnboard(args = {}) {
333
438
  p.log.info("Standalone mode (no gateway).");
334
439
  }
335
440
 
336
- const privacy = await stepPrivacy(base, "[4/6] Privacy mode");
441
+ const privacy = await stepPrivacy(base, "[4/6] Privacy mode", { localModeEnabled });
337
442
  vars.PRIVACY_MODE = privacy;
443
+ if (privacy === "paranoid" && localModeEnabled) {
444
+ vars.PRIVACY_OCR_AGENT_GATEWAY = "redacted";
445
+ vars.PRIVACY_AUDIO_AGENT_GATEWAY = "redacted";
446
+ }
338
447
  p.log.success(`Privacy: ${privacy}.`);
339
448
 
340
449
  const model = await stepModel(base, "[5/6] AI model for HUD analysis");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.24.1",
3
+ "version": "1.26.0",
4
4
  "description": "Context OS — ambient intelligence for builders. Captures screen + audio, distills into a private knowledge graph, accessible from MCP, web UI, and HUD overlay.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@ DEFAULTS = {
41
41
  "vision": {
42
42
  "enabled": False,
43
43
  "backend": "ollama",
44
- "model": "llava",
44
+ "model": "qwen2.5vl:7b",
45
45
  "ollamaUrl": "http://localhost:11434",
46
46
  "timeout": 10.0,
47
47
  "throttleSeconds": 5,
@@ -1,7 +1,7 @@
1
1
  """Ollama Vision — local multimodal inference for screen scene understanding.
2
2
 
3
- Provides a thin client for Ollama's vision models (llava, llama3.2-vision,
4
- moondream, nanollava). Used by sense_client for scene descriptions and
3
+ Provides a thin client for Ollama's vision models (qwen2.5vl, llama3.2-vision,
4
+ moondream, llava). Used by sense_client for scene descriptions and
5
5
  optionally by sinain-core's agent analyzer for local vision analysis.
6
6
 
7
7
  Falls back gracefully when Ollama is unavailable — never crashes the pipeline.
@@ -37,9 +37,9 @@ class OllamaVision:
37
37
 
38
38
  def __init__(
39
39
  self,
40
- model: str = "llava",
40
+ model: str = "qwen2.5vl:7b",
41
41
  base_url: str = "http://localhost:11434",
42
- timeout: float = 10.0,
42
+ timeout: float = 30.0,
43
43
  max_tokens: int = 200,
44
44
  ):
45
45
  self.model = model
@@ -56,7 +56,7 @@ class VisionProvider(ABC):
56
56
  class OllamaVisionProvider(VisionProvider):
57
57
  """Local vision via Ollama HTTP API."""
58
58
 
59
- def __init__(self, model: str = "llava", base_url: str = "http://localhost:11434",
59
+ def __init__(self, model: str = "qwen2.5vl:7b", base_url: str = "http://localhost:11434",
60
60
  timeout: float = 10.0, max_tokens: int = 200):
61
61
  from .ollama_vision import OllamaVision
62
62
  self._client = OllamaVision(model=model, base_url=base_url,
@@ -172,19 +172,30 @@ def create_vision(config: dict) -> Optional[VisionProvider]:
172
172
 
173
173
  Priority:
174
174
  1. Paranoid privacy or no API key → local only (Ollama)
175
- 2. LOCAL_VISION_ENABLED=true → local (Ollama)
175
+ 2. SINAIN_LOCAL_MODE=true / SINAIN_LOCAL_VISION set → local (Ollama)
176
176
  3. API key available → cloud (OpenRouter)
177
177
  4. Nothing available → None (vision disabled, OCR still works)
178
+
179
+ Env-var namespace: SINAIN_LOCAL_* is primary. The legacy LOCAL_VISION_*
180
+ vars are still honored as a fallback for older .env files; sinain-core's
181
+ config.ts also bridges SINAIN_LOCAL_* → LOCAL_VISION_* for compatibility.
178
182
  """
179
183
  privacy = os.environ.get("PRIVACY_MODE", "off")
180
184
  api_key = os.environ.get("OPENROUTER_API_KEY", "")
181
185
  vision_cfg = config.get("vision", {})
182
186
 
187
+ # Primary: SINAIN_LOCAL_MODE / SINAIN_LOCAL_VISION. Legacy: LOCAL_VISION_*.
183
188
  local_enabled = (
184
189
  vision_cfg.get("enabled", False)
190
+ or os.environ.get("SINAIN_LOCAL_MODE", "").lower() == "true"
191
+ or bool(os.environ.get("SINAIN_LOCAL_VISION", ""))
185
192
  or os.environ.get("LOCAL_VISION_ENABLED", "").lower() == "true"
186
193
  )
187
- local_model = os.environ.get("LOCAL_VISION_MODEL", vision_cfg.get("model", "llava"))
194
+ local_model = (
195
+ os.environ.get("SINAIN_LOCAL_VISION")
196
+ or os.environ.get("LOCAL_VISION_MODEL")
197
+ or vision_cfg.get("model", "qwen2.5vl:7b")
198
+ )
188
199
  local_url = vision_cfg.get("ollamaUrl", "http://localhost:11434")
189
200
  local_timeout = vision_cfg.get("timeout", 10.0)
190
201
 
package/setup-overlay.js CHANGED
@@ -199,7 +199,7 @@ ${GREEN}✓${RESET} Overlay ready!
199
199
 
200
200
  // ── Build from source (legacy) ───────────────────────────────────────────────
201
201
 
202
- async function buildFromSource() {
202
+ export async function buildFromSource() {
203
203
  // Check flutter
204
204
  try {
205
205
  execSync("which flutter", { stdio: "pipe" });
@@ -197,6 +197,11 @@ agent_has_mcp() {
197
197
  type=$(prof_get_or "$check" type "$check")
198
198
  case "$type" in
199
199
  claude|openclaude|codex|goose) return 0 ;;
200
+ # Hermes is an MCP client, but headless tool approval (calling back into
201
+ # sinain_respond) depends on its yolo/approval config. Default to pipe
202
+ # mode (self-contained text oracle); opt in to the claude-style MCP flow
203
+ # with HERMES_USE_MCP=true once approval is configured (see startup block).
204
+ hermes) [ "${HERMES_USE_MCP:-false}" = "true" ] && return 0 || return 1 ;;
200
205
  junie) $JUNIE_HAS_MCP ;;
201
206
  *) return 1 ;;
202
207
  esac
@@ -330,6 +335,16 @@ invoke_agent() {
330
335
  --no-session \
331
336
  --max-turns "$turns"
332
337
  ;;
338
+ hermes)
339
+ # MCP mode (reached only when HERMES_USE_MCP=true — see agent_has_mcp).
340
+ # Hermes loads the sinain MCP server from ~/.hermes/config.yaml
341
+ # (registered at startup) and calls sinain_respond / sinain_knowledge_query
342
+ # itself, mirroring the claude flow. `-z/--oneshot` prints only the final
343
+ # text to stdout and auto-bypasses approvals (no TTY hang). Turn budget
344
+ # comes from config.yaml (max_turns, default 60) — there's no top-level
345
+ # --max-turns flag. `--toolsets`/`-t` can narrow tools if needed.
346
+ "$bin" -z "$prompt"
347
+ ;;
333
348
  aider)
334
349
  return 1 # No MCP support — caller falls back to invoke_pipe
335
350
  ;;
@@ -376,6 +391,17 @@ invoke_pipe() {
376
391
  aider)
377
392
  "$bin" --yes -m "$msg"
378
393
  ;;
394
+ hermes)
395
+ # Hermes one-shot: `-z/--oneshot` sends a single prompt and prints ONLY
396
+ # the final response text to stdout (no banner/spinner/tool previews,
397
+ # no session-id line) — and auto-bypasses tool approvals, so it never
398
+ # hangs waiting on a TTY. Tools, memory, and skills still load. The
399
+ # escalation message already includes full screen/audio/digest context,
400
+ # so Hermes answers as a self-contained oracle using its own configured
401
+ # model (set via `hermes model`/`hermes setup`) — no sinain MCP needed.
402
+ # For the richer flow where Hermes calls sinain tools, set HERMES_USE_MCP=true.
403
+ "$bin" -z "$msg" 2>/dev/null
404
+ ;;
379
405
  *)
380
406
  # Generic: pipe message to stdin to whatever binary the profile names
381
407
  echo "$msg" | "$bin" 2>/dev/null
@@ -455,6 +481,46 @@ print(' sinain extension added to ' + config_path)
455
481
  fi
456
482
  fi
457
483
 
484
+ # Hermes: auto-register sinain MCP server in ~/.hermes/config.yaml (opt-in).
485
+ # Only when HERMES_USE_MCP=true and hermes is the selected agent — pipe mode
486
+ # (the default) is a black-box text oracle and needs none of this. Hermes
487
+ # reads MCP servers from config.yaml under the `mcp_servers` key (stdio:
488
+ # command + args + env). ruamel.yaml (a Hermes core dep) preserves the
489
+ # user's comments/formatting; falls back to PyYAML if unavailable.
490
+ if [ "${HERMES_USE_MCP:-false}" = "true" ] && [ "$AGENT" = "hermes" ]; then
491
+ TSX_BIN="$(cd "$SCRIPT_DIR/.." && pwd)/sinain-core/node_modules/.bin/tsx"
492
+ MCP_ENTRY="$(cd "$SCRIPT_DIR/.." && pwd)/sinain-mcp-server/index.ts"
493
+ HERMES_CONFIG="${HERMES_CONFIG_DIR:-$HOME/.hermes}/config.yaml"
494
+ if [ -f "$HERMES_CONFIG" ] && ! grep -q "sinain:" "$HERMES_CONFIG" 2>/dev/null; then
495
+ echo "Registering sinain MCP server with hermes ($HERMES_CONFIG)..."
496
+ python3 -c "
497
+ import sys
498
+ try:
499
+ from ruamel.yaml import YAML
500
+ _y = YAML()
501
+ load = _y.load
502
+ def dump(cfg, f): _y.dump(cfg, f)
503
+ except Exception:
504
+ import yaml as _py
505
+ load = _py.safe_load
506
+ def dump(cfg, f): _py.safe_dump(cfg, f, default_flow_style=False, sort_keys=False)
507
+ path, tsx, entry, core, ws = sys.argv[1:6]
508
+ with open(path) as f:
509
+ cfg = load(f) or {}
510
+ cfg.setdefault('mcp_servers', {})['sinain'] = {
511
+ 'command': tsx,
512
+ 'args': [entry],
513
+ 'env': {'SINAIN_CORE_URL': core, 'SINAIN_WORKSPACE': ws},
514
+ }
515
+ with open(path, 'w') as f:
516
+ dump(cfg, f)
517
+ print(' sinain mcp_server added to ' + path)
518
+ " "$HERMES_CONFIG" "$TSX_BIN" "$MCP_ENTRY" "$CORE_URL" "$WORKSPACE"
519
+ elif [ ! -f "$HERMES_CONFIG" ]; then
520
+ echo " ⚠ HERMES_USE_MCP=true but $HERMES_CONFIG missing — run \`hermes setup\` first"
521
+ fi
522
+ fi
523
+
458
524
  # Ollama warmup — pin the backing model so each agent invocation hits hot weights.
459
525
  # openclaude + Ollama via the OpenAI-compat endpoint does NOT forward keep_alive,
460
526
  # so we ping Ollama's native /api/generate once with keep_alive=-1 (persistent).
@@ -498,7 +564,7 @@ fi
498
564
  # Built-in defaults are 1:1 (profile name == binary == type). Users can
499
565
  # override fields or add custom profiles by editing sinain-agent/agents.json.
500
566
  # Profiles whose binaries aren't in PATH are silently skipped.
501
- for default_name in claude openclaude codex goose junie aider; do
567
+ for default_name in claude openclaude codex goose junie aider hermes; do
502
568
  prof_set "$default_name" bin "$default_name"
503
569
  prof_set "$default_name" type "$default_name"
504
570
  done
@@ -366,7 +366,8 @@ async function callOllama(
366
366
  ): Promise<AgentResult> {
367
367
  const start = Date.now();
368
368
  const controller = new AbortController();
369
- const timeout = setTimeout(() => controller.abort(), config.timeout);
369
+ // Local Ollama models need more time than cloud APIs (cold start + generation)
370
+ const timeout = setTimeout(() => controller.abort(), Math.max(config.timeout, 45_000));
370
371
 
371
372
  try {
372
373
  const imageB64List = (images || []).map((img) => img.data);
@@ -186,6 +186,25 @@ export function loadConfig(): CoreConfig {
186
186
  gainDb: intEnv("MIC_GAIN_DB", 0),
187
187
  };
188
188
 
189
+ // ── Local mode: unified config ──────────────────────────────────────────
190
+ // SINAIN_LOCAL_MODE=true auto-derives all component config from two vars:
191
+ // SINAIN_LOCAL_LLM=phi4-mini → analyzer + distiller
192
+ // SINAIN_LOCAL_VISION=qwen2.5vl:7b → sense_client (propagated via start.sh)
193
+ // Must run BEFORE transcriptionConfig / analysisConfig are read.
194
+ const localMode = boolEnv("SINAIN_LOCAL_MODE", false);
195
+ if (localMode) {
196
+ const localLlm = env("SINAIN_LOCAL_LLM", "phi4-mini");
197
+ const localVision = env("SINAIN_LOCAL_VISION", "qwen2.5vl:7b");
198
+ if (!process.env.ANALYSIS_PROVIDER) process.env.ANALYSIS_PROVIDER = "ollama";
199
+ if (!process.env.ANALYSIS_MODEL) process.env.ANALYSIS_MODEL = localLlm;
200
+ if (!process.env.ANALYSIS_VISION_MODEL) process.env.ANALYSIS_VISION_MODEL = localLlm;
201
+ if (!process.env.TRANSCRIPTION_BACKEND) process.env.TRANSCRIPTION_BACKEND = "local";
202
+ if (!process.env.LOCAL_VISION_ENABLED) process.env.LOCAL_VISION_ENABLED = "true";
203
+ if (!process.env.LOCAL_VISION_MODEL) process.env.LOCAL_VISION_MODEL = localVision;
204
+ if (!process.env.SINAIN_FAST_MODEL) process.env.SINAIN_FAST_MODEL = `ollama/${localLlm}`;
205
+ if (!process.env.SINAIN_SMART_MODEL) process.env.SINAIN_SMART_MODEL = `ollama/${localLlm}`;
206
+ }
207
+
189
208
  const transcriptionConfig: TranscriptionConfig = {
190
209
  backend: env("TRANSCRIPTION_BACKEND", "openrouter") as TranscriptionConfig["backend"],
191
210
  openrouterApiKey: env("OPENROUTER_API_KEY", ""),
@@ -193,7 +212,7 @@ export function loadConfig(): CoreConfig {
193
212
  language: env("TRANSCRIPTION_LANGUAGE", "en-US"),
194
213
  local: {
195
214
  bin: env("LOCAL_WHISPER_BIN", "whisper-cli"),
196
- modelPath: resolvePath(env("LOCAL_WHISPER_MODEL", "~/models/ggml-large-v3-turbo.bin")),
215
+ modelPath: resolvePath(env("LOCAL_WHISPER_MODEL", "~/.sinain/models/whisper/ggml-large-v3-turbo.bin")),
197
216
  language: env("TRANSCRIPTION_LANGUAGE", "en-US"),
198
217
  timeoutMs: intEnv("LOCAL_WHISPER_TIMEOUT_MS", 15000),
199
218
  },
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Download Manager — resumable, integrity-checked, atomic model downloads.
3
+ *
4
+ * SEED-001 Phase 4. Drives model-weight downloads into ~/.sinain/models/ for
5
+ * the first-run wizard (whisper model for T1/T2). Ollama models are pulled via
6
+ * Ollama's own /api/pull, NOT this manager.
7
+ *
8
+ * STATUS: SCAFFOLD. The download/verify/atomic-install core below is a working
9
+ * first implementation, but the manifest fetch + wizard wiring are marked TODO
10
+ * and it is not yet called from anywhere. See docs/dmg-distribution-spec.md §5.
11
+ *
12
+ * Design (SPEC §5a):
13
+ * - Resumable: HTTP Range requests; persists a `.part` file + byte offset.
14
+ * - Integrity: SHA-256 verified against the hosted manifest before promotion.
15
+ * - Atomic: download to `*.part` → verify → rename() into the final path,
16
+ * so the canonical path never holds a half-written model.
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { createReadStream, createWriteStream } from "node:fs";
21
+ import { mkdir, rename, stat } from "node:fs/promises";
22
+ import { dirname } from "node:path";
23
+ import { Readable } from "node:stream";
24
+ import { pipeline } from "node:stream/promises";
25
+ import { log, warn } from "../log.js";
26
+
27
+ const TAG = "download";
28
+
29
+ /** One downloadable artifact, as listed in the hosted models manifest. */
30
+ export interface ModelManifestEntry {
31
+ /** Stable identifier, e.g. "whisper-large-v3-turbo". */
32
+ id: string;
33
+ /** Absolute download URL. */
34
+ url: string;
35
+ /** Lowercase hex SHA-256 of the complete file. */
36
+ sha256: string;
37
+ /** Expected size in bytes (for progress + sanity check). */
38
+ sizeBytes: number;
39
+ /** Install tier this artifact belongs to. */
40
+ tier: "T1" | "T2";
41
+ /** Final on-disk path; `~` is expanded by the caller (see resolvePath). */
42
+ destPath: string;
43
+ }
44
+
45
+ export interface DownloadProgress {
46
+ id: string;
47
+ receivedBytes: number;
48
+ totalBytes: number;
49
+ /** 0..1, or null when total size is unknown. */
50
+ fraction: number | null;
51
+ }
52
+
53
+ export type ProgressHandler = (p: DownloadProgress) => void;
54
+
55
+ export class IntegrityError extends Error {
56
+ constructor(
57
+ public readonly id: string,
58
+ public readonly expected: string,
59
+ public readonly actual: string,
60
+ ) {
61
+ super(`integrity check failed for ${id}: expected ${expected}, got ${actual}`);
62
+ this.name = "IntegrityError";
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Download a single manifest entry with resume + integrity + atomic install.
68
+ * Returns the final installed path on success; throws IntegrityError on a
69
+ * checksum mismatch or Error on network/IO failure.
70
+ */
71
+ export async function downloadModel(
72
+ entry: ModelManifestEntry,
73
+ onProgress?: ProgressHandler,
74
+ signal?: AbortSignal,
75
+ ): Promise<string> {
76
+ const finalPath = entry.destPath;
77
+ const partPath = `${finalPath}.part`;
78
+
79
+ await mkdir(dirname(finalPath), { recursive: true });
80
+
81
+ // If a complete, valid file already exists, skip the download.
82
+ if (await fileMatches(finalPath, entry.sha256)) {
83
+ log(TAG, `${entry.id}: already present and verified`);
84
+ return finalPath;
85
+ }
86
+
87
+ // Resume from an existing partial download if present.
88
+ let startByte = 0;
89
+ try {
90
+ startByte = (await stat(partPath)).size;
91
+ } catch {
92
+ startByte = 0; // no .part yet
93
+ }
94
+
95
+ const headers: Record<string, string> = {};
96
+ if (startByte > 0) {
97
+ headers["Range"] = `bytes=${startByte}-`;
98
+ log(TAG, `${entry.id}: resuming from ${startByte} bytes`);
99
+ }
100
+
101
+ const res = await fetch(entry.url, { headers, signal });
102
+ if (!res.ok && res.status !== 206) {
103
+ throw new Error(`${entry.id}: download failed — HTTP ${res.status}`);
104
+ }
105
+ if (!res.body) {
106
+ throw new Error(`${entry.id}: response had no body`);
107
+ }
108
+
109
+ // If the server ignored Range (200 instead of 206), restart from scratch.
110
+ const append = res.status === 206 && startByte > 0;
111
+ if (!append) startByte = 0;
112
+
113
+ const total = entry.sizeBytes;
114
+ let received = startByte;
115
+
116
+ const fileStream = createWriteStream(partPath, { flags: append ? "a" : "w" });
117
+ const body = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
118
+ body.on("data", (chunk: Buffer) => {
119
+ received += chunk.length;
120
+ onProgress?.({
121
+ id: entry.id,
122
+ receivedBytes: received,
123
+ totalBytes: total,
124
+ fraction: total > 0 ? Math.min(received / total, 1) : null,
125
+ });
126
+ });
127
+
128
+ await pipeline(body, fileStream);
129
+
130
+ // Verify the completed .part before promoting it.
131
+ const actual = await sha256File(partPath);
132
+ if (actual !== entry.sha256) {
133
+ throw new IntegrityError(entry.id, entry.sha256, actual);
134
+ }
135
+
136
+ // Atomic install: rename is atomic within the same filesystem.
137
+ await rename(partPath, finalPath);
138
+ log(TAG, `${entry.id}: installed → ${finalPath}`);
139
+ return finalPath;
140
+ }
141
+
142
+ /** SHA-256 of a file as lowercase hex. */
143
+ export async function sha256File(path: string): Promise<string> {
144
+ const hash = createHash("sha256");
145
+ await pipeline(createReadStream(path), hash);
146
+ return hash.digest("hex");
147
+ }
148
+
149
+ /** True iff `path` exists and its SHA-256 equals `expectedSha256`. */
150
+ async function fileMatches(path: string, expectedSha256: string): Promise<boolean> {
151
+ try {
152
+ await stat(path);
153
+ } catch {
154
+ return false;
155
+ }
156
+ try {
157
+ return (await sha256File(path)) === expectedSha256;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Fetch the hosted models manifest (GitHub Pages).
165
+ *
166
+ * TODO(Phase 4): point at the real manifest URL once GitHub Pages hosting is
167
+ * set up (see docs/distribution/models-manifest.example.json for the schema),
168
+ * and decide how the manifest is versioned against app releases (open Q7).
169
+ */
170
+ export async function fetchManifest(_manifestUrl: string): Promise<ModelManifestEntry[]> {
171
+ throw new Error(
172
+ "fetchManifest not implemented — SEED-001 Phase 4. See docs/dmg-distribution-spec.md §5a.",
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Download every entry for a given tier, sequentially.
178
+ *
179
+ * TODO(Phase 5): wire this into the first-run wizard's tier-config step so the
180
+ * whisper model downloads with a progress bar after the user picks T1/T2.
181
+ */
182
+ export async function downloadForTier(
183
+ entries: ModelManifestEntry[],
184
+ tier: "T1" | "T2",
185
+ onProgress?: ProgressHandler,
186
+ signal?: AbortSignal,
187
+ ): Promise<string[]> {
188
+ const wanted = entries.filter((e) => e.tier === tier || (tier === "T2" && e.tier === "T1"));
189
+ const installed: string[] = [];
190
+ for (const entry of wanted) {
191
+ try {
192
+ installed.push(await downloadModel(entry, onProgress, signal));
193
+ } catch (err) {
194
+ warn(TAG, `failed to download ${entry.id}:`, err);
195
+ throw err;
196
+ }
197
+ }
198
+ return installed;
199
+ }
@@ -29,6 +29,14 @@ import { initPrivacy, levelFor, applyLevel } from "./privacy/index.js";
29
29
 
30
30
  const TAG = "core";
31
31
 
32
+ /**
33
+ * Python interpreter for the sinain-memory scripts (graph_query, page_renderer,
34
+ * distillers). In a packaged build the launcher sets SINAIN_PYTHON to the one
35
+ * interpreter that has the deps — bare "python3" can resolve to a dep-less
36
+ * install and make knowledge pages silently fall back to empty.
37
+ */
38
+ const PYTHON_BIN = process.env.SINAIN_PYTHON || "python3";
39
+
32
40
  /** Resolve workspace path, expanding leading ~ to HOME. */
33
41
  function resolveWorkspace(): string {
34
42
  const raw = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
@@ -70,7 +78,7 @@ async function queryKnowledgeFactsMulti(entities: string[], maxFacts: number): P
70
78
  try {
71
79
  const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts * 2), "--format", "json"];
72
80
  if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
73
- const out = execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" }).trim();
81
+ const out = execFileSync(PYTHON_BIN, args, { timeout: 5000, encoding: "utf-8" }).trim();
74
82
  if (out) {
75
83
  const parsed = JSON.parse(out);
76
84
  const facts = parsed.facts || parsed;
@@ -141,7 +149,7 @@ async function listKnowledgeEntitiesMulti(max: number): Promise<string> {
141
149
  for (const dbPath of dbPaths) {
142
150
  if (!existsSync(dbPath)) continue;
143
151
  try {
144
- const out = execFileSync("python3", [
152
+ const out = execFileSync(PYTHON_BIN, [
145
153
  scriptPath, "--db", dbPath, "--top", String(max), "--format", "json",
146
154
  ], { timeout: 5000, encoding: "utf-8" });
147
155
  const parsed = JSON.parse(out);
@@ -206,7 +214,7 @@ async function searchEntitiesMulti(query: string, limit: number): Promise<unknow
206
214
  for (const dbPath of resolveKnowledgeDbPaths()) {
207
215
  if (!existsSync(dbPath)) continue;
208
216
  try {
209
- const out = execFileSync("python3", [
217
+ const out = execFileSync(PYTHON_BIN, [
210
218
  scriptPath, "--db", dbPath,
211
219
  "--search-entities", query,
212
220
  "--search-limit", String(limit * 2), // 2x then de-dup
@@ -257,7 +265,7 @@ async function exportConceptBundle(
257
265
  if (opts.includePage) args.push("--include-page");
258
266
  try {
259
267
  // 30s budget — large 2-hop exports can take time on big graphs.
260
- const { stdout } = await pExecFile("python3", args,
268
+ const { stdout } = await pExecFile(PYTHON_BIN, args,
261
269
  { timeout: 30_000, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 });
262
270
  const parsed = JSON.parse(stdout);
263
271
  // If the export found at least one entity (the root), return it.
@@ -299,7 +307,7 @@ async function importConceptBundle(
299
307
  ];
300
308
  const { spawn } = await import("node:child_process");
301
309
  return await new Promise((resolve) => {
302
- const child = spawn("python3", args, { timeout: 30_000 });
310
+ const child = spawn(PYTHON_BIN, args, { timeout: 30_000 });
303
311
  let stdout = "";
304
312
  let stderr = "";
305
313
  child.stdout.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); });
@@ -348,7 +356,7 @@ async function retractOrRestoreFact(
348
356
  if (opts.undoToken) args.push("--undo-token", opts.undoToken);
349
357
  }
350
358
  try {
351
- const { stdout } = await pExecFile("python3", args, { timeout: 10_000, encoding: "utf-8" });
359
+ const { stdout } = await pExecFile(PYTHON_BIN, args, { timeout: 10_000, encoding: "utf-8" });
352
360
  const parsed = JSON.parse(stdout);
353
361
  if (parsed.ok) return parsed;
354
362
  // If error is "fact not found" try the next DB; otherwise return the error
@@ -384,7 +392,7 @@ async function renderEntityPageMulti(
384
392
  if (opts.refresh) args.push("--refresh");
385
393
  try {
386
394
  // 60s budget — LLM rendering for large entities can take 20-30s.
387
- const { stdout } = await pExecFile("python3", args, { timeout: 60_000, encoding: "utf-8" });
395
+ const { stdout } = await pExecFile(PYTHON_BIN, args, { timeout: 60_000, encoding: "utf-8" });
388
396
  const parsed = JSON.parse(stdout);
389
397
  if (parsed.fact_count > 0) return parsed;
390
398
  } catch (e) {
@@ -411,7 +419,7 @@ async function graphChildrenMulti(entity: string): Promise<unknown> {
411
419
  for (const dbPath of resolveKnowledgeDbPaths()) {
412
420
  if (!existsSync(dbPath)) continue;
413
421
  try {
414
- const out = execFileSync("python3", [
422
+ const out = execFileSync(PYTHON_BIN, [
415
423
  scriptPath, "--db", dbPath,
416
424
  "--graph-children", entity,
417
425
  "--graph-limit", "50",
@@ -458,7 +466,7 @@ if not result:
458
466
  result = store.entity_as_of("${entity}", d)
459
467
  print(json.dumps({k: v for k, v in result.items()}, ensure_ascii=False))
460
468
  `;
461
- const out = execFileSync("python3", ["-c", pyCode], {
469
+ const out = execFileSync(PYTHON_BIN, ["-c", pyCode], {
462
470
  timeout: 5000, encoding: "utf-8",
463
471
  }).trim();
464
472
  if (out && out !== "{}") return out;
@@ -486,7 +494,7 @@ async function exportKnowledgeMulti(domain: string | null, max: number): Promise
486
494
  for (const dbPath of dbPaths) {
487
495
  if (!existsSync(dbPath)) continue;
488
496
  try {
489
- const out = execFileSync("python3", [
497
+ const out = execFileSync(PYTHON_BIN, [
490
498
  scriptPath, "--db", dbPath, "--top", String(max), "--format", "json",
491
499
  ], { timeout: 5000, encoding: "utf-8" });
492
500
  const parsed = JSON.parse(out);
@@ -608,7 +616,7 @@ store.close()
608
616
  print(json.dumps(stats))
609
617
  `;
610
618
 
611
- const result = execFileSync("python3", ["-c", script], {
619
+ const result = execFileSync(PYTHON_BIN, ["-c", script], {
612
620
  input: JSON.stringify(graphOps),
613
621
  timeout: 10_000,
614
622
  encoding: "utf-8",