@geravant/sinain 1.24.1 → 1.25.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/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
 
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";
@@ -130,6 +130,11 @@ export async function runOnboard(args = {}) {
130
130
  label: "QuickStart",
131
131
  hint: "Get running in 2 minutes. Configure details later.",
132
132
  },
133
+ {
134
+ value: "local",
135
+ label: "Local / Paranoid",
136
+ hint: "Fully offline — Ollama + Whisper, zero cloud calls.",
137
+ },
133
138
  {
134
139
  value: "advanced",
135
140
  label: "Advanced",
@@ -139,7 +144,7 @@ export async function runOnboard(args = {}) {
139
144
  initialValue: "quickstart",
140
145
  }));
141
146
 
142
- const totalSteps = flow === "quickstart" ? 2 : 6;
147
+ const totalSteps = flow === "quickstart" ? 2 : flow === "local" ? 4 : 6;
143
148
 
144
149
  // ── Collect vars ────────────────────────────────────────────────────────
145
150
 
@@ -149,12 +154,130 @@ export async function runOnboard(args = {}) {
149
154
  // complete so we don't churn ~/.sinain/agents.json on every prompt.
150
155
  let agentsPatch = {};
151
156
 
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.");
157
+ // Step 1: API key (quickstart + advanced only — local mode skips cloud)
158
+ if (flow !== "local") {
159
+ const apiKey = await stepApiKey(base, `[1/${totalSteps}] OpenRouter API key`);
160
+ vars.OPENROUTER_API_KEY = apiKey;
161
+ p.log.success("API key saved.");
162
+ }
163
+
164
+ if (flow === "local") {
165
+ // ── Local / Paranoid flow ─────────────────────────────────────────────
166
+ // Step 1: Local models (Ollama)
167
+ const localResult = await stepLocalMode(base, `[1/${totalSteps}] Local models`);
168
+ if (localResult) {
169
+ vars.SINAIN_LOCAL_MODE = "true";
170
+ vars.SINAIN_LOCAL_LLM = localResult.llm;
171
+ vars.SINAIN_LOCAL_VISION = localResult.vision;
172
+ p.log.success(`LLM: ${localResult.llm}, Vision: ${localResult.vision}`);
173
+ } else {
174
+ p.log.warn("Local mode cancelled — switching to QuickStart defaults.");
175
+ vars.TRANSCRIPTION_BACKEND = "openrouter";
176
+ vars.PRIVACY_MODE = "standard";
177
+ vars.AGENT_MODEL = "google/gemini-2.5-flash-lite";
178
+ }
179
+
180
+ // Step 2: Whisper setup (if local mode enabled)
181
+ if (vars.SINAIN_LOCAL_MODE === "true") {
182
+ vars.TRANSCRIPTION_BACKEND = "local";
183
+ const hasWhisper = !IS_WINDOWS && cmdExists("whisper-cli");
184
+ if (hasWhisper) {
185
+ p.log.success(`[2/${totalSteps}] whisper-cli found — local transcription enabled.`);
186
+ } else if (IS_MAC) {
187
+ const install = guard(await p.confirm({
188
+ message: `[2/${totalSteps}] whisper-cli not found. Install via Homebrew?`,
189
+ initialValue: true,
190
+ }));
191
+ if (install) {
192
+ const s = p.spinner();
193
+ s.start("Installing whisper-cpp...");
194
+ try {
195
+ execFileSync("brew", ["install", "whisper-cpp"], { stdio: "pipe" });
196
+ s.stop(c.green("whisper-cpp installed."));
197
+ } catch {
198
+ s.stop(c.yellow("Install failed — audio transcription won't work offline."));
199
+ }
200
+ }
201
+ }
202
+ // Check whisper model
203
+ const modelDir = path.join(HOME, "models");
204
+ const modelPath = path.join(modelDir, "ggml-large-v3-turbo.bin");
205
+ if (fs.existsSync(modelPath)) {
206
+ vars.LOCAL_WHISPER_MODEL = modelPath;
207
+ p.log.info(`Whisper model: ${c.dim(modelPath)}`);
208
+ } else {
209
+ const download = guard(await p.confirm({
210
+ message: "Download Whisper model (~1.5 GB)?",
211
+ initialValue: true,
212
+ }));
213
+ if (download) {
214
+ const s = p.spinner();
215
+ s.start("Downloading Whisper model...");
216
+ try {
217
+ fs.mkdirSync(modelDir, { recursive: true });
218
+ execFileSync("curl", [
219
+ "-L", "--progress-bar",
220
+ "-o", modelPath,
221
+ "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin",
222
+ ], { stdio: "inherit" });
223
+ s.stop(c.green("Model downloaded."));
224
+ vars.LOCAL_WHISPER_MODEL = modelPath;
225
+ } catch {
226
+ s.stop(c.yellow("Download failed. Run manually later."));
227
+ }
228
+ }
229
+ }
230
+
231
+ // Step 3: Privacy — default to paranoid since user chose local mode
232
+ vars.PRIVACY_MODE = "paranoid";
233
+ const privacy = await stepPrivacy(base, `[3/${totalSteps}] Privacy mode`, { localModeEnabled: true });
234
+ vars.PRIVACY_MODE = privacy;
235
+ p.log.success(`Privacy: ${privacy}.`);
236
+
237
+ // Privacy overrides for escalation (redacted OCR+audio in escalation)
238
+ if (privacy === "paranoid") {
239
+ vars.PRIVACY_OCR_AGENT_GATEWAY = "redacted";
240
+ vars.PRIVACY_AUDIO_AGENT_GATEWAY = "redacted";
241
+ }
242
+ }
243
+
244
+ // Step 4: Gateway (optional — works with local mode too)
245
+ const hasExistingGateway = (() => {
246
+ try {
247
+ const agentsPath = path.join(SINAIN_DIR, "agents.json");
248
+ if (!fs.existsSync(agentsPath)) return false;
249
+ const cfg = JSON.parse(fs.readFileSync(agentsPath, "utf-8"));
250
+ return !!cfg?.profiles?.openclaw;
251
+ } catch { return false; }
252
+ })();
253
+ const enableGateway = guard(await p.confirm({
254
+ message: `[4/${totalSteps}] Enable OpenClaw gateway? (escalation agent for deeper analysis)`,
255
+ initialValue: hasExistingGateway,
256
+ }));
257
+ if (enableGateway) {
258
+ const gatewayResult = await stepGateway(base, "OpenClaw gateway");
259
+ Object.assign(vars, gatewayResult.envVars);
260
+ Object.assign(agentsPatch, gatewayResult.agentsPatch);
261
+ } else {
262
+ agentsPatch.openclawProfile = null;
263
+ }
264
+ agentsPatch.default = base.SINAIN_AGENT || "claude";
156
265
 
157
- if (flow === "quickstart") {
266
+ p.note(
267
+ [
268
+ `Local mode: ${vars.SINAIN_LOCAL_MODE === "true" ? c.green("enabled") : "disabled"}`,
269
+ vars.SINAIN_LOCAL_LLM ? ` LLM: ${vars.SINAIN_LOCAL_LLM}` : null,
270
+ vars.SINAIN_LOCAL_VISION ? ` Vision: ${vars.SINAIN_LOCAL_VISION}` : null,
271
+ `Transcription: ${vars.TRANSCRIPTION_BACKEND}`,
272
+ `Privacy: ${vars.PRIVACY_MODE}`,
273
+ `OpenClaw gateway: ${enableGateway ? "enabled" : "disabled"}`,
274
+ "",
275
+ `Start with: ./start.sh --paranoid`,
276
+ `Change later: sinain config`,
277
+ ].filter(Boolean).join("\n"),
278
+ "Local mode summary",
279
+ );
280
+ } else if (flow === "quickstart") {
158
281
  // QuickStart: sensible defaults + a single opt-in question for OpenClaw.
159
282
  // Gateway integration is off by default; users who want it run Advanced
160
283
  // (or answer Yes here, which then walks them through stepGateway).
@@ -267,35 +390,14 @@ export async function runOnboard(args = {}) {
267
390
  }
268
391
  }
269
392
 
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
- }
393
+ // Offer local mode (Ollama) enables paranoid privacy
394
+ const localResult = await stepLocalMode(base, "Local mode (Ollama)");
395
+ const localModeEnabled = !!localResult;
396
+ if (localResult) {
397
+ vars.SINAIN_LOCAL_MODE = "true";
398
+ vars.SINAIN_LOCAL_LLM = localResult.llm;
399
+ vars.SINAIN_LOCAL_VISION = localResult.vision;
400
+ p.log.success(`Local mode: LLM=${localResult.llm}, Vision=${localResult.vision}`);
299
401
  }
300
402
 
301
403
  // OpenClaw gateway is opt-in: most users run sinain in standalone mode
@@ -333,8 +435,12 @@ export async function runOnboard(args = {}) {
333
435
  p.log.info("Standalone mode (no gateway).");
334
436
  }
335
437
 
336
- const privacy = await stepPrivacy(base, "[4/6] Privacy mode");
438
+ const privacy = await stepPrivacy(base, "[4/6] Privacy mode", { localModeEnabled });
337
439
  vars.PRIVACY_MODE = privacy;
440
+ if (privacy === "paranoid" && localModeEnabled) {
441
+ vars.PRIVACY_OCR_AGENT_GATEWAY = "redacted";
442
+ vars.PRIVACY_AUDIO_AGENT_GATEWAY = "redacted";
443
+ }
338
444
  p.log.success(`Privacy: ${privacy}.`);
339
445
 
340
446
  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.25.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": {
@@ -39,7 +39,7 @@ class OllamaVision:
39
39
  self,
40
40
  model: str = "llava",
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
@@ -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", ""),