@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 +12 -1
- package/config-shared.js +176 -25
- package/config.js +21 -3
- package/launcher.js +19 -5
- package/onboard.js +150 -41
- package/package.json +1 -1
- package/sense_client/config.py +1 -1
- package/sense_client/ollama_vision.py +4 -4
- package/sense_client/vision.py +14 -3
- package/setup-overlay.js +1 -1
- package/sinain-agent/run.sh +67 -1
- package/sinain-core/src/agent/analyzer.ts +2 -1
- package/sinain-core/src/config.ts +20 -1
- package/sinain-core/src/distribution/download-manager.ts +199 -0
- package/sinain-core/src/index.ts +19 -11
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
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
|
|
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(
|
|
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 —
|
|
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
|
-
|
|
72
|
-
if (choice === "source")
|
|
73
|
-
|
|
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 (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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.
|
|
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": {
|
package/sense_client/config.py
CHANGED
|
@@ -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 (
|
|
4
|
-
moondream,
|
|
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 = "
|
|
40
|
+
model: str = "qwen2.5vl:7b",
|
|
41
41
|
base_url: str = "http://localhost:11434",
|
|
42
|
-
timeout: float =
|
|
42
|
+
timeout: float = 30.0,
|
|
43
43
|
max_tokens: int = 200,
|
|
44
44
|
):
|
|
45
45
|
self.model = model
|
package/sense_client/vision.py
CHANGED
|
@@ -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 = "
|
|
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.
|
|
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 =
|
|
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" });
|
package/sinain-agent/run.sh
CHANGED
|
@@ -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
|
-
|
|
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", "
|
|
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
|
+
}
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
619
|
+
const result = execFileSync(PYTHON_BIN, ["-c", script], {
|
|
612
620
|
input: JSON.stringify(graphOps),
|
|
613
621
|
timeout: 10_000,
|
|
614
622
|
encoding: "utf-8",
|