Port Windows installer/ncp fixes and a real nomic-embed-text default

- install-windows.ps1: warn about a leftover profile-based ncp() that
  would shadow ncp.cmd; the "press any key to close" footer now skips
  the wait when stdin is redirected (was hanging indefinitely) and
  exits cleanly instead of Stop-Process when it owns the window; pulls
  nomic-embed-text alongside the chat/memory models.
- management/ncp.py: ncp start / start -b bring Ollama up automatically;
  longer timeout + real error message on a slow model warm.
- synapse/nexus_config.py: DEFAULT_EMBED_MODEL, single source of truth
  alongside DEFAULT_CHAT_MODEL/DEFAULT_MEMORY_MODEL.
- synapse/ollama_manager.py: is_available() cached instead of spawning a
  process per /status poll; is_running() timeout dropped 2s -> 0.5s so a
  healthy backend stops reading as dead; embed() reads the new default
  instead of a hardcoded string; de-duplicated serve-env setup.

Ported via bin/publish.sh from NexusOS-jon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 22:05:37 -05:00
co-authored by Claude Sonnet 5
parent 4a13767ffc
commit ec8f2e08f6
5 changed files with 120 additions and 50 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ manually from the app (**Start AI** in the sidebar), not at boot.
```powershell
# 0. Allow scripts to run (PowerShell blocks unsigned scripts by default, which
# stops Nexus's CLI from working). Process scope covers only one single window;
# stops Nexus's CLI from working). Process scope covers only this window;
# LocalMachine makes it permanent so ncp works from every future shell (run
# PowerShell as Administrator for the LocalMachine line).
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
+58 -12
View File
@@ -178,6 +178,30 @@ try {
Write-Warn " $RepoRoot\management"
}
# -- Legacy profile shim check --------------------------------------------------
# An earlier version of this installer registered ncp as a `function ncp` in the
# PowerShell profile instead of management\ncp.cmd (see the "why" above). Profile
# functions resolve before PATH executables, so a leftover one silently shadows
# the real ncp.cmd - `ncp` still runs, just the wrong code, with no error. We do
# not rewrite the user's profile automatically: it is their file, and a blind
# strip risks mangling whatever else lives in it alongside it. Just flag it.
Write-Step "Checking for a stale ncp() in your PowerShell profile"
$DocsRoot = [Environment]::GetFolderPath("MyDocuments")
$ProfileCandidates = @(
(Join-Path $DocsRoot "WindowsPowerShell\Microsoft.PowerShell_profile.ps1"),
(Join-Path $DocsRoot "PowerShell\Microsoft.PowerShell_profile.ps1")
)
$Shadowed = $ProfileCandidates | Where-Object {
(Test-Path $_) -and (Select-String -Path $_ -Pattern '^\s*function\s+ncp\b' -Quiet)
}
if ($Shadowed) {
Write-Warn "Found an old 'function ncp' that will shadow the real ncp.cmd:"
foreach ($p in $Shadowed) { Write-Warn " $p" }
Write-Warn " Remove that function from the file(s) above so ncp resolves to ncp.cmd."
} else {
Write-OK "No stale ncp() in your PowerShell profile"
}
# -- Desktop shortcut ----------------------------------------------------------
Write-Step "Creating desktop shortcut"
try {
@@ -213,8 +237,9 @@ try {
Push-Location $RepoRoot
$ChatModel = (& $VenvPy -c "from synapse.nexus_config import DEFAULT_CHAT_MODEL as m; print(m)")
$MemModel = (& $VenvPy -c "from synapse.nexus_config import DEFAULT_MEMORY_MODEL as m; print(m)")
$EmbedModel = (& $VenvPy -c "from synapse.nexus_config import DEFAULT_EMBED_MODEL as m; print(m)")
Pop-Location
if ($LASTEXITCODE -ne 0 -or -not $ChatModel -or -not $MemModel) {
if ($LASTEXITCODE -ne 0 -or -not $ChatModel -or -not $MemModel -or -not $EmbedModel) {
Write-Fail "Could not read the default models from synapse\nexus_config.py - the venv install is broken"
}
@@ -255,7 +280,7 @@ Pop-Location
if ($seedOk) { Write-OK "Default model set to $ChatModel" }
else { Write-Warn "Could not persist default model - pick it at the top of the chat instead." }
Write-Step "Pulling models ($ChatModel for chat, $MemModel for memory)"
Write-Step "Pulling models ($ChatModel for chat, $MemModel for memory, $EmbedModel for recall)"
# A prompt, not "press Ctrl+C to skip": Ctrl+C in PowerShell 5.1 terminates the
# whole script, so the escape hatch the installer advertised was also the one
# thing that stopped it finishing - no Ollama cleanup, no summary, no window
@@ -277,6 +302,10 @@ if ($pullAnswer -match '^\s*(n|no)\s*$') {
ollama pull $MemModel
if ($LASTEXITCODE -eq 0) { Write-OK "$MemModel ready (memory curator)" }
else { Write-Warn "$MemModel pull skipped/failed - the memory service will fall back to the chat model." }
ollama pull $EmbedModel
if ($LASTEXITCODE -eq 0) { Write-OK "$EmbedModel ready (conversation recall)" }
else { Write-Warn "$EmbedModel pull skipped/failed - recall will fall back to lexical search." }
}
# -- Make Ollama manual-start (NexusOS owns the lifecycle) ----------------------
@@ -326,15 +355,32 @@ Write-Host " A process reads PATH once, when it starts. This shell started befo
Write-Host " the installer put management\ on PATH, so it will never see ncp no" -ForegroundColor DarkGray
Write-Host " matter what you run here. The next terminal you open will." -ForegroundColor DarkGray
Write-Host ""
Read-Host " Press Enter to close this window"
# Stop-Process on our own PID, not `exit`: `exit` only ends the window when the
# host was started with -File. Run interactively - `& .\install-windows.ps1` from
# a shell already open, which is the common case - it would just return to the
# prompt in a session whose PATH is permanently stale. Killing the host closes
# the window either way, which is the point: the user cannot accidentally keep
# using a shell where ncp will never resolve.
# Caveat: a Start-Transcript in this window never reaches Stop-Transcript. The
# transcript is flushed as it is written, so the content survives without the
# closing footer.
# Skip the wait entirely when stdin is redirected: nobody is at the keyboard to
# press anything, and both ReadKey and Read-Host were confirmed (empirically,
# not just in theory) to hang indefinitely in that case instead of failing
# fast - Read-Host only fails fast under an explicit -NonInteractive flag,
# which is not how the self-elevation Start-Process above launches this script.
if (-not [Console]::IsInputRedirected) {
Write-Host " Press any key to close this window..." -ForegroundColor Yellow
try { $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") } catch { Read-Host | Out-Null }
}
# $OwnsWindow means this is the elevated instance the self-elevation block above
# launched with `-File`, so a clean `exit` closes it - and does so with exit code
# 0, which is what lets Windows Terminal's closeOnExit actually close the tab
# instead of treating an abrupt kill as a crash and falling back to a fresh
# shell in the pane. Without $OwnsWindow we are in a shell the user already had
# open (they ran an admin PowerShell and invoked the script directly, so it was
# never re-launched with -File) - `exit` there would just return to the prompt
# in a session whose PATH is permanently stale, so kill the host instead; the
# point is the user cannot accidentally keep using a shell where ncp never
# resolves.
# Caveat: a Start-Transcript in this window never reaches Stop-Transcript in the
# Stop-Process path. The transcript is flushed as it is written, so the content
# survives without the closing footer.
if ($OwnsWindow) {
exit 0
} else {
Stop-Process -Id $PID
}
+12 -5
View File
@@ -304,14 +304,19 @@ def stop_service(svc: Service) -> bool:
def start_ollama() -> None:
"""Driven through the backend endpoint (the path the control panel uses)
rather than launching the binary, because OllamaManager owns model and GPU
selection. Requires the backend to be up."""
selection. Requires the backend to be up.
Long timeout: the endpoint blocks until the model is warmed - weights read
off disk into RAM/VRAM - which the web UI's own "Loading model..." button
state calls out as routinely taking about a minute, not just the Ollama
process launching."""
print("Starting OLLAMA...")
req = urllib.request.Request("http://localhost:8000/ollama/start", method="POST")
try:
urllib.request.urlopen(req, timeout=30).read(1)
urllib.request.urlopen(req, timeout=180).read(1)
print("NEXUS OLLAMA STARTED")
except Exception:
print(" OLLAMA start request failed (backend not reachable on :8000)")
except Exception as e:
print(f" OLLAMA start failed: {e}")
def stop_ollama() -> None:
@@ -339,6 +344,7 @@ def cmd_start(target) -> None:
launch(SERVICES["memory"]); wait_for_port(SERVICES["memory"])
elif target in ("--backend", "-b"):
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
start_ollama()
elif target in ("--frontend", "-f"):
launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"])
elif target in ("--ai", "-a"):
@@ -349,6 +355,7 @@ def cmd_start(target) -> None:
launch(SERVICES["backend"])
wait_for_port(SERVICES["memory"])
wait_for_port(SERVICES["backend"])
start_ollama()
launch(SERVICES["frontend"])
wait_for_port(SERVICES["frontend"])
else:
@@ -656,7 +663,7 @@ Commands:
--memory, -m Start only the memory service
--frontend,-f Start only the frontend
--backend, -b Start only the backend
--ai, -a Start the AI (Ollama) — manual; not started by default
--ai, -a Start only the AI (Ollama); `start`/`start -b` already include it
stop Stop ALL Nexus services
--memory, -m Stop only the memory service
+6 -2
View File
@@ -22,7 +22,7 @@ except Exception:
VERSION = "0.0.0"
# --- MODEL DEFAULTS ---
# Single source of truth for the two models NexusOS ships with. The installers
# Single source of truth for the three models NexusOS ships with. The installers
# pull and pin these, the runtime falls back to them; keeping them in one place
# is what stops installer and backend from drifting apart.
#
@@ -30,8 +30,12 @@ except Exception:
# 4 GB GPU into CPU/RAM). Chosen over Qwen3 because Qwen3 is a reasoning model:
# smart only with its slow <think> step, weak without it.
# Memory: mistral - the curator that extracts facts and titles conversations.
# Embed: nomic-embed-text - powers semantic recall of past conversations
# (OllamaManager.embed / store.semantic_search_conversations). Without it,
# recall silently degrades to lexical substring matching.
DEFAULT_CHAT_MODEL = "llama3.1:8b"
DEFAULT_MEMORY_MODEL = "mistral:latest"
DEFAULT_EMBED_MODEL = "nomic-embed-text"
# --- CORE DIRECTORIES ---
DATA_DIR = PROJECT_ROOT / "data"
@@ -144,7 +148,7 @@ settings = Settings()
# explicit exports for static checkers and IDEs
__all__ = ["Settings", "settings", "path", "VERSION",
"DEFAULT_CHAT_MODEL", "DEFAULT_MEMORY_MODEL",
"DEFAULT_CHAT_MODEL", "DEFAULT_MEMORY_MODEL", "DEFAULT_EMBED_MODEL",
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
"EXPORTS_DIR", "MEMORY_DB",
+37 -24
View File
@@ -9,7 +9,7 @@ import signal
from pathlib import Path
from typing import Any
from .nexus_config import settings, DEFAULT_CHAT_MODEL
from .nexus_config import settings, DEFAULT_CHAT_MODEL, DEFAULT_EMBED_MODEL
from .memory.store import store
OLLAMA_PORT = 11434
@@ -193,6 +193,7 @@ class OllamaManager:
def __init__(self, runtime_dir=None):
self.process = None
self.running = False
self._available = None # see is_available()
self.runtime_dir = Path(runtime_dir) if runtime_dir else Path(__file__).resolve().parent.parent / "runtime"
(self.runtime_dir / "logs").mkdir(parents=True, exist_ok=True)
@@ -241,17 +242,45 @@ class OllamaManager:
except Exception as e:
_log.warning("warm: preload failed: %s", e)
def _serve_env(self, gpu_env: dict) -> dict:
"""Environment for a `serve` we spawn. One definition - this was
duplicated verbatim in two methods, so the Windows carve-out below had
to be fixed in both places or the two paths would disagree."""
env = os.environ.copy()
env["OLLAMA_HOST"] = self._api_base
# Every platform uses the project's own model store. Windows used to be
# exempt, because the installer pulled with a bare `ollama pull` into
# %USERPROFILE%\.ollama and a NexusOS-spawned serve pointed elsewhere
# would not have seen those models. The installer sets OLLAMA_MODELS for
# its pulls now (and migrates an existing store), so the exemption just
# meant Windows kept models somewhere `ncp models` could not see.
env["OLLAMA_MODELS"] = str(settings.models_dir)
env.update(gpu_env)
return env
def is_available(self):
# ponytail: cached for the life of the process. This spawns a subprocess,
# and /status calls it on every poll - the frontend polls continuously,
# so it was a process spawn per tick to answer a question whose answer
# cannot change without someone installing Ollama. Restart to re-detect.
if self._available is None:
bin_path = _ollama_bin()
try:
subprocess.run([bin_path, "--version"], capture_output=True, check=True, timeout=5)
return True
subprocess.run([bin_path, "--version"], capture_output=True,
check=True, timeout=5)
self._available = True
except Exception:
return False
self._available = False
return self._available
def is_running(self):
# 0.5s, not 2s: this is a loopback request to a server that is either
# listening or is not. Ollama ships OFF (the user presses Start AI), so
# the "not running" path is the common one and every /status paid the
# full 2s for it - which pushed /status past the 2s client timeout in
# bin/nexus_window.py and made a healthy backend look dead.
try:
r = httpx.get(f"{self._api_base}/api/tags", timeout=2.0)
r = httpx.get(f"{self._api_base}/api/tags", timeout=0.5)
return r.status_code == 200
except httpx.RequestError:
return False
@@ -269,15 +298,7 @@ class OllamaManager:
try:
backend, gpu_env = _detect_gpu_backend()
_log.info("Starting Ollama service via %s (backend: %s)...", _ollama_bin(), backend)
env = os.environ.copy()
env["OLLAMA_HOST"] = self._api_base
# Linux ships a bundled model store under the project. On Windows,
# Ollama is installed system-wide and pulls into its default store,
# so don't override OLLAMA_MODELS or a NexusOS-spawned `serve` won't
# see the models the installer already pulled.
if os.name != "nt":
env["OLLAMA_MODELS"] = str(Path(__file__).resolve().parent.parent / "models")
env.update(gpu_env)
env = self._serve_env(gpu_env)
with open(self.log_file, "w") as log:
self.process = subprocess.Popen(
@@ -318,15 +339,7 @@ class OllamaManager:
try:
backend, gpu_env = _detect_gpu_backend()
_log.info("Starting Ollama service via %s (backend: %s)...", _ollama_bin(), backend)
env = os.environ.copy()
env["OLLAMA_HOST"] = self._api_base
# Linux ships a bundled model store under the project. On Windows,
# Ollama is installed system-wide and pulls into its default store,
# so don't override OLLAMA_MODELS or a NexusOS-spawned `serve` won't
# see the models the installer already pulled.
if os.name != "nt":
env["OLLAMA_MODELS"] = str(Path(__file__).resolve().parent.parent / "models")
env.update(gpu_env)
env = self._serve_env(gpu_env)
with open(self.log_file, "w") as log:
self.process = subprocess.Popen(
@@ -522,7 +535,7 @@ class OllamaManager:
_log.exception("chat error after %.3fs: %s", elapsed, e)
return None
async def embed(self, text: str, model: str = "nomic-embed-text") -> list[float] | None:
async def embed(self, text: str, model: str = DEFAULT_EMBED_MODEL) -> list[float] | None:
"""Return an embedding vector for `text` via /api/embeddings.
Returns None on any failure so callers can fall back to lexical search —