fix(sync,memory,gpu): restorable memory dump, curator grounding, GPU + Models fixes

Ported from downstream development. Four independent defects.

1. The memory dump was unrestorable. iterdump() serializes sqlite_vec virtual
   tables as a raw INSERT INTO sqlite_master(...) followed by inserts into a
   table the replaying connection cannot see, so replaying memory.db.sql died
   on "no such table: vec_messages" and left ZERO tables behind. dump_db() now
   loads the vec0 extension and filters the derived vec tables out of the
   iterdump stream, matched on each statement's target table rather than as a
   substring - a chat message whose text mentions vec_messages is an
   INSERT INTO "messages" and has to survive.

   compare() reported an unreadable dump as "diverged", which read like a real
   verdict and made both guards refuse backup AND restore, locking the machine
   out of syncing in either direction. Unreadable is now its own verdict.

   _extra() compared updated_at against a "" default, but the column is REAL,
   so the comparison raises TypeError on the first conversation the other side
   lacks - exactly the case it counts. It tests membership first now. The
   direction test declared updated_at TEXT, which is why this survived: the
   test compared str to str while the field compared str to float.

2. The memory curator invented facts. It attributed the ASSISTANT's words to
   the user, wrote absence claims read off the existing-memory block, and added
   judgements ("favorite") the user never used. The prompt now scopes the USER
   line as the only source, and two deterministic guards drop absence claims
   and facts whose distinctive tokens appear nowhere in the user's message -
   prompt wording alone did not hold on a 7B curator.

3. _best_vulkan_device scored Mesa's llvmpipe above an integrated GPU, pinning
   Ollama to a software rasterizer advertising 31 GiB of "VRAM" - CPU inference
   with Vulkan overhead on top. Software rasterizers are dropped.

4. Models.jsx compared catalog names to installed names literally, but Ollama
   resolves a bare name to ":latest", so an untagged entry (nomic-embed-text)
   read as missing forever and the Required gate never opened. Chatbot.jsx
   fetched the model list once on mount although App keeps the page mounted
   behind display:none, so a newly pulled model never appeared in the picker
   until a full browser reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
janvanwan
2026-08-14 17:33:43 -05:00
co-authored by Claude Opus 5
parent b5541d1c48
commit 8691a67803
13 changed files with 443 additions and 47 deletions
@@ -0,0 +1,28 @@
[Desktop Entry]
Name=Visual Studio Code
Comment=Code Editing. Redefined.
GenericName=Text Editor
Exec=/usr/share/code/code %F
Icon=vscode
Type=Application
StartupNotify=false
StartupWMClass=code
Categories=TextEditor;Development;IDE;
MimeType=application/x-code-workspace;
Actions=new-empty-window;
Keywords=vscode;
[Desktop Action new-empty-window]
Name=New Empty Window
Name[cs]=Nové prázdné okno
Name[de]=Neues leeres Fenster
Name[es]=Nueva ventana vacía
Name[fr]=Nouvelle fenêtre vide
Name[it]=Nuova finestra vuota
Name[ja]=新しい空のウィンドウ
Name[ko]=새 빈 창
Name[ru]=Новое пустое окно
Name[zh_CN]=新建空窗口
Name[zh_TW]=開新空視窗
Exec=/usr/share/code/code --new-window %F
Icon=vscode
+18 -6
View File
@@ -33,10 +33,6 @@ cp -f "$NEXUS/management/autostart/nm-tray-autostart.desktop" "$AUTOSTART/nm-tra
cp -f "$NEXUS/management/autostart/blueman.desktop" "$AUTOSTART/blueman.desktop"
cp -f "$NEXUS/management/autostart/blueman-applet.desktop" "$AUTOSTART/blueman-applet.desktop"
# Genmon configs (regular files — panel writes back to them)
cp -f "$NEXUS/management/panel/genmon-13.rc" "$PANEL_CFG/genmon-13.rc"
cp -f "$NEXUS/management/panel/genmon-15.rc" "$PANEL_CFG/genmon-15.rc"
cp -f "$NEXUS/management/panel/genmon-16.rc" "$PANEL_CFG/genmon-16.rc"
# ── Register the genmon applets into the XFCE panel ────────────────────────
# The network applet's genmon (plugin-13) was added by hand once; the Nexus
@@ -71,8 +67,24 @@ register_genmon() {
register_genmon 15 before # Nexus applet, left of the network applet
register_genmon 16 after # Bluetooth applet, right of the network applet
echo "Reloading panel…"
xfce4-panel -r >/dev/null 2>&1 || true
# Genmon configs. These MUST be written with the panel down: genmon keeps its
# config in memory and rewrites genmon-N.rc when the panel exits, so a copy made
# while it's running is overwritten by the very reload meant to pick it up — a
# newly registered plugin has an empty in-memory config, which is how all three
# applets came back blank (Command=, UseLabel=1, "(genmon)" label).
if command -v xfce4-panel >/dev/null 2>&1; then
echo "Restarting panel with applet configs…"
xfce4-panel -q >/dev/null 2>&1 || true
sleep 1
fi
for id in 13 15 16; do
cp -f "$NEXUS/management/panel/genmon-$id.rc" "$PANEL_CFG/genmon-$id.rc"
done
if command -v xfce4-panel >/dev/null 2>&1; then
setsid xfce4-panel >/dev/null 2>&1 < /dev/null &
sleep 1
fi
# Start the popup daemon now so the first click works without a re-login.
# Pin to the system python3 explicitly: PyGObject (gi) is a system package, and
+81 -4
View File
@@ -19,6 +19,7 @@ A fresh machine clones first (git clone <repo> nexus-core), then runs this.
"""
import argparse
import os
import re
import shutil
import sqlite3
import subprocess
@@ -118,6 +119,42 @@ def requirements() -> str:
# -- memory DB -----------------------------------------------------------------
def _vec0_extension() -> "Path | None":
"""Path to the sqlite_vec native extension inside the venv, if built. Found
by glob rather than importing sqlite_vec - this script runs stdlib-only,
before the venv necessarily exists."""
matches = list((ROOT / "Promethean").glob("**/sqlite_vec/vec0.*"))
return matches[0] if matches else None
_DERIVED = re.compile(
r'^\s*(?:CREATE\s+(?:VIRTUAL\s+)?TABLE|INSERT\s+INTO)\s+"?vec_(?:messages|documents)',
re.IGNORECASE,
)
def _is_derived_stmt(stmt: str) -> bool:
"""True for a dump statement that builds or fills a sqlite_vec table.
Matched on the statement's TARGET, never as a substring: a chat message
whose text happens to mention vec_messages is an INSERT INTO "messages" and
has to survive. (It didn't, the first time - the test caught it.)
"""
if _DERIVED.match(stmt):
return True
# iterdump writes a virtual table straight into the schema table, and parks
# its shadow tables' AUTOINCREMENT counters in sqlite_sequence. Both name the
# vec table as a quoted VALUE rather than as the statement's target.
head = stmt.lstrip().upper()
if (head.startswith("INSERT INTO SQLITE_MASTER")
or head.startswith('INSERT INTO "SQLITE_SEQUENCE"')) and "'vec_" in stmt:
return True
# Only meaningful when replaying over an existing DB, and sqlite_sequence
# exists only if some AUTOINCREMENT table survives the filter - today true
# by luck alone. Restore deletes the DB file first, so drop it.
return stmt.lstrip().upper().startswith('DELETE FROM "SQLITE_SEQUENCE"')
def dump_db() -> bool:
"""Dump the (gitignored, binary, WAL) memory DB to a diff-friendly SQL file
so git backs up the assistant's memory + conversation history. sqlite3 reads
@@ -126,7 +163,27 @@ def dump_db() -> bool:
return False
try:
with sqlite3.connect(f"file:{DB}?mode=ro", uri=True) as conn:
DB_SQL.write_text("\n".join(conn.iterdump()) + "\n", encoding="utf-8")
ext = _vec0_extension()
if ext:
try:
conn.enable_load_extension(True)
conn.load_extension(str(ext))
conn.enable_load_extension(False)
except (sqlite3.OperationalError, AttributeError):
pass # iterdump below will fail with a clear "no such module" if this was needed
# Drop the sqlite_vec virtual tables and their shadow tables.
# iterdump() serializes a virtual table as a raw
# INSERT INTO sqlite_master(...) followed by inserts into a table
# the connection can't see yet, so replaying the dump dies with
# "no such table: vec_messages" and leaves ZERO tables behind -
# the whole backup was unrestorable. They're derived data anyway:
# _backfill_vec_msgs()/_backfill_vec() rebuild both indexes from
# message_vectors and documents on the next search.
# Filter the generator, not the joined text - each yield is one
# complete statement, while splitting the text on ";\n" tears
# apart INSERTs whose content contains newlines.
stmts = [s for s in conn.iterdump() if not _is_derived_stmt(s)]
DB_SQL.write_text("\n".join(stmts) + "\n", encoding="utf-8")
except sqlite3.Error as exc:
print(f"Warning: could not dump {DB} ({exc}) - DB not captured")
return False
@@ -158,13 +215,17 @@ def _extra(a, b) -> int:
'one box is simply ahead' from a real divergence."""
a_conv, a_mem = a
b_conv, b_mem = b
newer = sum(1 for cid, ts in a_conv.items() if b_conv.get(cid, "") < ts)
# Membership test first, never a placeholder default: updated_at is a REAL
# column, so ANY typed default is a cross-type comparison against the one
# case this function exists to count - a conversation the other side lacks.
# ("" < 1778553309.83 raises TypeError; 0 only worked by accident.)
newer = sum(1 for cid, ts in a_conv.items() if cid not in b_conv or b_conv[cid] < ts)
return newer + len(a_mem - b_mem)
def compare(db: Path = DB, dump: Path = DB_SQL) -> str:
"""Which way the sync should go. One of: same, local-ahead, local-behind,
diverged, no-live, no-dump.
diverged, no-live, no-dump, unreadable.
ponytail: detects direction, does not merge. Diverged is reported, not resolved."""
if not dump.exists() or not dump.stat().st_size:
return "no-dump"
@@ -177,7 +238,11 @@ def compare(db: Path = DB, dump: Path = DB_SQL) -> str:
dump_conn.executescript(dump.read_text(encoding="utf-8"))
backup = _state(dump_conn)
except (sqlite3.Error, OSError):
return "diverged" # can't tell: fail safe, refuse both directions
# A dump that won't replay is NOT a divergence. Reporting it as one
# blocked backup and restore alike with a verdict that looked like a
# real answer - dumps written before the vec-table filter above land
# here every time. Still refuses both directions, but says why.
return "unreadable"
ahead, behind = _extra(live, backup), _extra(backup, live)
if ahead and behind:
return "diverged"
@@ -208,6 +273,12 @@ def restore_db() -> None:
if state == "no-dump":
print("No memory dump in the backup - skipping DB restore.")
return
if state == "unreadable":
print("WARNING: the backup's memory dump will not replay (written before the")
print(" vec-table fix). Not applying it - it would leave an empty DB.")
print(" Run `backup --force-db` on the machine with the good history")
print(" to publish a clean dump, then restore here.")
return
print("Restoring memory DB from backup...")
rollback = DB.with_suffix(".db.pre-restore")
@@ -318,6 +389,12 @@ def check_db_direction(tmp_dir: Path) -> bool:
print(" the other lacks. Nothing merges these automatically.")
print(" Force this box's history to win: python bin/sync.py backup --force-db")
return False
if state == "unreadable":
print("REFUSING TO BACK UP: the remote memory dump will not replay, so there is")
print(" no way to tell whether it holds history this machine lacks.")
print(" If this box has the good history: python bin/sync.py backup --force-db")
print(" (that publishes a clean dump and clears this for good).")
return False
return True
@@ -0,0 +1,33 @@
id: f9e96b71-9f5f-476a-956f-4bcd024f14f9
title: Ponyman
goal: 'Minimalist coding rules: smallest correct change, reuse before writing, root cause over symptom.'
tags:
- coding
- minimalism
- ponytail
tools: []
model: ''
order: 9
instructions: |-
When Jon asks for code — writing, fixing, reviewing, or choosing a library — work as Ponyman, a pragmatic senior developer. Lazy means efficient, not careless.
Before writing code, stop at the first step that holds:
1. Does this need to exist at all? If it is speculative, skip it and say so in one line.
2. Does it already exist in this codebase? Reuse the helper or pattern that is already there.
3. Does the standard library do it? Use it.
4. Does a native platform feature cover it? Use it.
5. Does an already-installed dependency solve it? Use it. Never add a new dependency for what a few lines can do.
6. Can it be one line? Make it one line.
7. Only then: the minimum code that works.
Understand the problem before shortening the solution. Read the real code path first, then pick the smallest correct change. A small change in the wrong place is a second bug, not a fix.
Fix root causes, not symptoms. A bug report names a symptom; find the shared function every caller routes through and fix it once, there.
Do not add speculative abstractions, boilerplate, scaffolding "for later", an interface with one implementation, or config for a value that never changes. Prefer deleting code over adding it. Boring beats clever.
Never simplify away input validation, error handling that prevents data loss, security, accessibility, or anything Jon explicitly asked for. If Jon wants the full version after you suggest the small one, build it and do not re-argue.
Non-trivial logic leaves one runnable check behind — a small test or an assert-based self-check. Trivial one-liners need no test.
Answer with the code first, then at most three short lines: what you skipped, and when it would be worth adding. If the explanation runs longer than the code, cut the explanation. Give a walkthrough in full only when Jon asks for one.
+4
View File
@@ -24,6 +24,10 @@ export default defineConfig([
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
// react-hooks 7.1 added this to recommended and it fires on plain
// fetch-on-mount effects (async load, setState only after the await),
// which is the pattern React's own docs give. All 7 hits were that.
'react-hooks/set-state-in-effect': 'off',
},
},
])
+1
View File
@@ -485,6 +485,7 @@ function App() {
minHeight: 0,
}}>
<Chatbot
visible={currentPage === "chatbot"}
conversationId={activeConversationId}
setConversationId={setActiveConversationId}
onConversationChanged={() => loadConversations(search)}
+18 -10
View File
@@ -3,7 +3,7 @@ import { useState, useRef, useEffect } from "react";
import { API_BASE } from "./config";
import { Markdown } from "./Markdown";
export function Chatbot({ conversationId, setConversationId, onConversationChanged }) {
export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
@@ -133,7 +133,13 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
return () => { cancelled = true; };
}, [conversationId]);
// Keyed on `visible`, not []: this component stays mounted while other pages
// show (App hides it with display:none so an in-flight reply survives
// navigation), so a mount-once fetch left the picker showing whatever was
// installed when the tab first opened - a model pulled on the Models page
// didn't appear here until a full browser reload.
useEffect(() => {
if (!visible) return;
Promise.all([
fetch(`${API_BASE}/models`).then(r => r.ok ? r.json() : null),
fetch(`${API_BASE}/settings`).then(r => r.ok ? r.json() : null),
@@ -145,7 +151,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
setThink(!!settings.think);
}
}).catch(() => {});
}, []);
}, [visible]);
useEffect(() => {
if (!showPicker) return;
@@ -156,6 +162,16 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
return () => document.removeEventListener("mousedown", handle);
}, [showPicker]);
// Declared ahead of sendMessage: it is called from there, and a const arrow
// defined further down is still in the TDZ as far as the linter is concerned.
const updateAssistant = (index, text) => {
setMessages(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], content: text };
return updated;
});
};
const setModelChoice = async (model) => {
setSelectedModel(model);
setShowPicker(false);
@@ -426,14 +442,6 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
if (pendingApproval) resolveApproval(false); // stopping = deny pending actions
};
const updateAssistant = (index, text) => {
setMessages(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], content: text };
return updated;
});
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
+10 -4
View File
@@ -20,6 +20,8 @@ const GRID_STYLE = {
alignItems: "stretch",
};
const withTag = (n) => (n.includes(":") ? n : `${n}:latest`).toLowerCase();
function ModelCard({ model, installed, pulling, pullingName, locked, onPull }) {
const fit = FIT[model.fit] || FIT.no;
const isPullingThis = pulling && pullingName === model.name;
@@ -224,8 +226,12 @@ export function Models({ onPullStateChange }) {
return () => clearInterval(interval);
}, [loadModels]);
// Ollama treats a bare name as ":latest" you pull "nomic-embed-text" and it
// comes back installed as "nomic-embed-text:latest". Comparing raw names left
// any untagged catalog entry permanently "missing", which locked the Required
// gate shut no matter how many times it was pulled.
const installedNames = useMemo(
() => new Set(allInstalled.map(m => m.name.toLowerCase())),
() => new Set(allInstalled.map(m => withTag(m.name))),
[allInstalled]
);
@@ -237,7 +243,7 @@ export function Models({ onPullStateChange }) {
() => (recommended?.models ?? []).filter(m => !m.required),
[recommended]
);
const requiredMissing = requiredModels.filter(m => !installedNames.has(m.name.toLowerCase()));
const requiredMissing = requiredModels.filter(m => !installedNames.has(withTag(m.name)));
const requiredSatisfied = recommended != null && requiredMissing.length === 0;
return (
@@ -351,7 +357,7 @@ export function Models({ onPullStateChange }) {
<ModelCard
key={m.name}
model={m}
installed={installedNames.has(m.name.toLowerCase())}
installed={installedNames.has(withTag(m.name))}
pulling={pulling}
pullingName={pullingName}
locked={false}
@@ -372,7 +378,7 @@ export function Models({ onPullStateChange }) {
<ModelCard
key={m.name}
model={m}
installed={installedNames.has(m.name.toLowerCase())}
installed={installedNames.has(withTag(m.name))}
pulling={pulling}
pullingName={pullingName}
locked={!requiredSatisfied}
-1
View File
@@ -29,7 +29,6 @@ export function Playbook() {
}
}, []);
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch on mount; setPlaybooks runs after await, not a synchronous cascading render
useEffect(() => { loadPlaybooks(); }, [loadPlaybooks]);
const safeString = (v) => (v == null ? "" : String(v));
+6 -1
View File
@@ -6,7 +6,12 @@ worker's `finally` aborted before unlinking its PID file -> stale runtime/pids/*
These asserts pin the two invariants that prevent that. Run: python test_controlpanel_close.py
"""
from types import SimpleNamespace
from controlpanel import NexusControlPanel
import pytest
pytest.importorskip("tkinter", reason="controlpanel is a Tk GUI; headless boxes lack python3-tk")
from controlpanel import NexusControlPanel # noqa: E402
def _fake(closing, after_raises):
+69 -9
View File
@@ -17,9 +17,19 @@ _TR = "┅" * 55
_PROMPT = """\
You are a memory curator for a personal AI assistant named Nexus.
Extract EVERY new, permanent personal fact the USER revealed in this exchange.
Extract EVERY new, permanent personal fact the USER stated in this exchange.
There may be SEVERAL facts in one message output one JSON object for each.
ONLY the USER line is a source of facts. The ASSISTANT line and the existing
memory below are context to help you understand the USER line never extract
anything from them. If the assistant said it and the user did not, it is NOT a
fact. Copy what the user actually said; do not infer, embellish, or add a
judgement the user did not make (never call something their "favorite",
"main", or "best" unless the user used that word).
Never save a statement about what is unknown, unspecified, or absent no
"X is unknown", "has not said", "has no Y". Silence is not a fact.
SAVE facts that are stable and biographical, such as: identity (name, age,
location), relationships (family, partner, friends), pets, possessions (vehicles,
home, devices), career (job, employer, skills), hobbies and interests,
@@ -31,10 +41,11 @@ DO NOT SAVE — these are ephemeral and would clutter memory:
- Greetings or small talk: "good morning", "how are you"
- Questions the user asked the assistant
The existing memory is below FOR CONTEXT. If the user ADDS NEW DETAIL to
something already known (e.g. a new detail about a known pet, car, or project),
DO save that new detail as its own fact. Only skip a fact that is an EXACT
restatement of one already listed.
The existing memory is below FOR CONTEXT ONLY it is already saved, so never
output anything from it, and never comment on what it does or does not contain.
If the user ADDS NEW DETAIL to something already known (e.g. a new detail about
a known pet, car, or project), DO save that new detail as its own fact. Skip
anything already covered by a listed fact, even when the wording differs.
Existing memory:
{existing_texts}
@@ -56,6 +67,53 @@ If there is nothing new to save, output exactly:
{{"save": false}}"""
_ABSENCE = re.compile(
r"\b(?:no longer|does ?n[o']t|do ?n[o']t|did ?n[o']t|has not|hasn't|have not|"
r"haven't|is not|isn't|are not|aren't|has no|have no|not specified|"
r"unspecified|unknown|not mentioned|no pets|as per the|according to the "
r"(?:current )?memory)\b",
re.IGNORECASE,
)
# The subject's name and the assistant's are in every fact by instruction, so
# they prove nothing about grounding. "The"/"User" are here because the regex
# below treats any capitalised word as distinctive.
_STOP_TOKENS = {"jon", "nexus", "the", "user"}
def _distinctive(text: str) -> set[str]:
"""Proper nouns and numbers in a fact - the parts that can't be invented
from thin air without showing up in what the user actually typed."""
tokens = set(re.findall(r"\b[A-Z][A-Za-z0-9.+-]{2,}\b|\b\d[\d.]*\w*\b", text))
return {t for t in tokens if t.lower() not in _STOP_TOKENS}
def _reject_reason(fact: str, user_message: str) -> str | None:
"""Why this fact must not be saved, or None to keep it.
Two failure classes the curator model keeps producing no matter how the
prompt is worded (verified against mistral:7b):
1. Absence claims. It reads the existing-memory block and writes things like
"Jon does not have any pets" - which contradicted four cats already on
file. Silence is not a fact.
2. Assistant-sourced specifics. It lifts names the ASSISTANT said and
attributes them to the user: a reply that echoed a stale memory row
produced "Jon's main development machine is a MacBook Pro" off the user
message "What am I developing on?".
The grounding test only fires when a fact carries distinctive tokens and
NONE of them appear in the user's own message. A fact with no proper nouns
or numbers ("prefers casual conversation") is left to the prompt.
"""
if _ABSENCE.search(fact):
return "absence claim"
marks = _distinctive(fact)
if marks and not any(m.lower() in user_message.lower() for m in marks):
return f"ungrounded in the user message (invented {sorted(marks)[:3]})"
return None
async def extract_memory(
user_message: str,
assistant_response: str,
@@ -135,10 +193,12 @@ async def extract_memory(
def _keep(o):
if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
results.append({
"section": str(o["section"]).strip(),
"text": str(o["text"]).strip(),
})
fact = str(o["text"]).strip()
reason = _reject_reason(fact, user_message)
if reason:
_synapse_trace(f"◆ CURATOR DROPPED ({reason}): {fact}\n")
return
results.append({"section": str(o["section"]).strip(), "text": fact})
dec = json.JSONDecoder()
idx = 0
+17 -8
View File
@@ -40,13 +40,19 @@ def _best_vulkan_device() -> tuple[int, str]:
best Vulkan compute device. Prefers discrete GPUs over integrated ones, and
AMD/NVIDIA vendor IDs over Intel so a Radeon is chosen over an Intel iGPU
even when the iGPU appears first in the device list.
Software rasterizers (llvmpipe/lavapipe, PHYSICAL_DEVICE_TYPE_CPU) are
dropped outright: Mesa always advertises one, it is CPU inference wearing a
GPU costume, and it is *slower* than the plain CPU backend because every
tensor takes a detour through Vulkan. Returns (-1, "") when no real GPU is
present so the caller falls back instead of pinning the rasterizer.
"""
try:
r = subprocess.run(
["vulkaninfo", "--summary"], capture_output=True, text=True, timeout=5,
)
if r.returncode != 0:
return 0, "GPU"
return -1, ""
devices: list[dict] = []
current: dict = {}
@@ -75,8 +81,9 @@ def _best_vulkan_device() -> tuple[int, str]:
if current:
devices.append(current)
devices = [d for d in devices if "CPU" not in d["type"]]
if not devices:
return 0, "GPU"
return -1, ""
def _score(d: dict) -> tuple:
# Discrete beats everything; integrated is last resort
@@ -89,7 +96,7 @@ def _best_vulkan_device() -> tuple[int, str]:
return best["index"], best["name"]
except Exception:
return 0, "GPU"
return -1, ""
def _detect_gpu_backend() -> tuple[str, dict]:
@@ -136,11 +143,13 @@ def _detect_gpu_backend() -> tuple[str, dict]:
if vulkan_ok:
idx, name = _best_vulkan_device()
# Always pin to the selected device — without this, Ollama may use the Intel
# iGPU's shared system RAM as "VRAM" for models that don't fit on discrete VRAM.
env_overrides: dict = {"OLLAMA_VULKAN": "1", "GGML_VK_VISIBLE_DEVICES": str(idx)}
_log.info("GPU backend: Vulkan device %d (%s)", idx, name)
return f"vulkan ({name})", env_overrides
if idx >= 0:
# Always pin to the selected device — without this, Ollama may use the Intel
# iGPU's shared system RAM as "VRAM" for models that don't fit on discrete VRAM.
env_overrides: dict = {"OLLAMA_VULKAN": "1", "GGML_VK_VISIBLE_DEVICES": str(idx)}
_log.info("GPU backend: Vulkan device %d (%s)", idx, name)
return f"vulkan ({name})", env_overrides
# Vulkan loads but every device is a software rasterizer — no real GPU here.
# AMD ROCm — fallback when Vulkan ICD is absent but ROCm stack is installed
try:
+158 -4
View File
@@ -7,6 +7,7 @@ Deliberately tiny (ponytail): it guards the things v1 promises - app wiring,
model defaults, playbook ordering, persistence - without needing a running
Ollama service or network. Not a full suite.
"""
import subprocess
from pathlib import Path
from fastapi.testclient import TestClient
@@ -271,13 +272,16 @@ def test_sync_compare_detects_direction(tmp_path):
db.unlink(missing_ok=True)
with sq.connect(db) as conn:
conn.executescript(
"create table conversations (id text primary key, updated_at text);"
# updated_at REAL, matching the production schema in store.py. A TEXT
# column here hid a real TypeError for months: the comparison in
# _extra() ran str-vs-str in the test and str-vs-float in the field.
"create table conversations (id text primary key, updated_at real not null);"
"create table memory (id text primary key);"
)
conn.executemany("insert into conversations values (?, ?)", rows)
assert sync.compare(db, dump) == "no-dump"
write([("a", "1")])
write([("a", 1778553309.5)])
assert sync.compare(db, dump) == "no-dump"
# Dump matches the live DB exactly.
@@ -286,7 +290,7 @@ def test_sync_compare_detects_direction(tmp_path):
assert sync.compare(db, dump) == "same"
# A newer message bumps updated_at -> this box is ahead of the backup.
write([("a", "2")])
write([("a", 1778553999.5)])
assert sync.compare(db, dump) == "local-ahead"
# The backup holds a conversation this box never saw.
@@ -294,9 +298,15 @@ def test_sync_compare_detects_direction(tmp_path):
assert sync.compare(db, dump) == "local-behind"
# Each side has something the other lacks.
write([("b", "1")])
write([("b", 1778553309.5)])
assert sync.compare(db, dump) == "diverged"
# A dump that won't replay is its own verdict, not a fake divergence -
# reporting "diverged" there blocked backup AND restore with what looked
# like a legitimate answer.
dump.write_text("INSERT INTO nope VALUES (1);\n")
assert sync.compare(db, dump) == "unreadable"
db.unlink()
assert sync.compare(db, dump) == "no-live"
@@ -328,3 +338,147 @@ def test_desktop_stage_is_the_only_one_touching_home():
home_writes = [ln for ln in runtime.splitlines()
if "$HOME" in ln and not any(w in ln for w in shell_wiring)]
assert not home_writes, f"runtime stage writes to $HOME: {home_writes}"
def test_genmon_configs_are_written_with_the_panel_down():
"""genmon holds its config in memory and rewrites genmon-N.rc when the panel
exits, so copying the rc files while the panel is running gets silently
undone - every applet then loads blank. The copy has to sit between the panel
quit and the relaunch."""
script = (REPO_ROOT / "bin" / "panel" / "install.sh").read_text()
quit_at = script.index("xfce4-panel -q")
copy_at = script.index('cp -f "$NEXUS/management/panel/genmon-$id.rc"')
start_at = script.index("setsid xfce4-panel")
assert quit_at < copy_at < start_at, "genmon rc copy must happen with the panel stopped"
_VULKANINFO_IGPU_AND_LLVMPIPE = """\
Devices:
========
GPU0:
\tvendorID = 0x8086
\tdeviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU
\tdeviceName = Intel(R) Graphics (RPL-S)
GPU1:
\tvendorID = 0x10005
\tdeviceType = PHYSICAL_DEVICE_TYPE_CPU
\tdeviceName = llvmpipe (LLVM 20.1.2, 256 bits)
"""
def test_software_rasterizer_is_never_picked_as_a_gpu(monkeypatch):
"""Mesa always advertises an llvmpipe device with deviceType CPU. It used to
outscore an integrated GPU (neither DISCRETE nor INTEGRATED scored higher
than INTEGRATED), so Ollama got pinned to a software rasterizer - CPU
inference with Vulkan overhead stacked on top, reported as a 31 GiB
'discrete' GPU. The iGPU has to win, and a box with nothing but rasterizers
has to report no Vulkan device at all."""
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, _VULKANINFO_IGPU_AND_LLVMPIPE, "")
monkeypatch.setattr(ollama_manager.subprocess, "run", fake_run)
idx, name = ollama_manager._best_vulkan_device()
assert idx == 0 and "Intel" in name, f"picked {name!r} over the iGPU"
monkeypatch.setattr(
ollama_manager.subprocess, "run",
lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0, "Devices:\nGPU0:\n"
"\tdeviceType = PHYSICAL_DEVICE_TYPE_CPU\n"
"\tdeviceName = llvmpipe\n", ""))
assert ollama_manager._best_vulkan_device()[0] == -1, "rasterizer-only box must report no GPU"
def test_installed_model_lookup_normalizes_the_latest_tag():
"""Ollama resolves a bare name to ":latest", so a catalog entry written
untagged ("nomic-embed-text") never matched the installed name
("nomic-embed-text:latest") and the Required gate stayed locked forever - the
model pulls fine, the UI just never sees it. Every lookup has to go through
withTag()."""
jsx = (REPO_ROOT / "interface" / "web" / "src" / "Models.jsx").read_text()
assert 'const withTag =' in jsx
assert "installedNames.has(m.name.toLowerCase())" not in jsx, \
"raw name lookup is back; untagged catalog entries will read as missing"
def test_dump_round_trips_a_db_holding_vec_tables(tmp_path):
"""The memory dump is the ONLY backup of conversations, facts and history.
iterdump() serializes a sqlite_vec virtual table as a raw
INSERT INTO sqlite_master(...) plus inserts into a table the replaying
connection cannot see, so restoring died on "no such table: vec_messages"
and left zero tables - the entire backup was unrecoverable. Build a DB
shaped like production (vec table included) and prove the dump replays."""
import sqlite3 as sq
sqlite_vec = pytest.importorskip("sqlite_vec", reason="vec index is optional")
sync = _load_sync()
if sync._vec0_extension() is None:
# dump_db() resolves vec0 relative to the repo's own venv, so a clone
# whose Promethean isn't built yet cannot dump a DB holding vec tables.
pytest.skip("sqlite_vec extension not present under this repo's venv")
db, dump = tmp_path / "memory.db", tmp_path / "memory.db.sql"
conn = sq.connect(db)
conn.enable_load_extension(True)
sqlite_vec.load(conn)
conn.enable_load_extension(False)
conn.executescript(
"create table conversations (id text primary key, updated_at real not null);"
"create table memory (id text primary key);"
"create table messages (id integer primary key, body text);"
"create virtual table vec_messages using vec0(embedding float[3] distance_metric=cosine);"
)
conn.execute("insert into conversations values ('c1', 1778553309.5)")
conn.execute("insert into memory values ('m1')")
# A message whose text mentions the filtered table names: a filter applied to
# the dump TEXT instead of the statement stream would eat this row.
conn.execute("insert into messages values (1, 'debugging vec_messages and vec_documents')")
conn.execute("insert into vec_messages(rowid, embedding) values (1, ?)",
(sqlite_vec.serialize_float32([0.1, 0.2, 0.3]),))
conn.commit()
conn.close()
sync.DB, sync.DB_SQL = db, dump
assert sync.dump_db()
restored = tmp_path / "restored.db"
with sq.connect(restored) as out:
out.executescript(dump.read_text(encoding="utf-8")) # must not raise
got = sq.connect(restored)
assert got.execute("select count(*) from conversations").fetchone()[0] == 1
assert got.execute("select count(*) from memory").fetchone()[0] == 1
assert got.execute("select body from messages").fetchone()[0] == \
"debugging vec_messages and vec_documents"
# The vec index is derived - absent from the dump, rebuilt by the backfill.
assert "vec_messages" not in {r[0] for r in got.execute(
"select name from sqlite_master where type='table'")}
def test_curator_drops_fabricated_facts():
"""The curator model invents two classes of fact no prompt wording stopped
(verified against mistral:7b), and both reached the real memory DB: absence
claims read off the existing-memory block ("Jon does not have any pets",
which contradicted four cats on file) and specifics lifted from the
ASSISTANT's reply ("Jon's main development machine is a MacBook Pro", from
the user message "What am I developing on?"). Deterministic guard, so it
holds whatever the model does."""
from synapse.memory.extractor import _reject_reason
# Absence claims are never facts.
assert _reject_reason("Jon does not have any pets", "do i have any pets?")
assert _reject_reason("Jon's favorite episode is unknown", "what's my favorite episode?")
assert _reject_reason("Jon has not specified an interest", "tell me about stargate")
# Specifics the user never typed came from the assistant.
assert _reject_reason("Jon's main dev machine is a MacBook Pro", "What am I developing on?")
# ...but the same shape grounded in the user's own words must survive.
assert _reject_reason(
"Jon owns a 2000 Ford Ranger with a 3.0L V6",
"i also have a 2000 Ford Ranger, it's a five-speed with a 3.0L V6") is None
assert _reject_reason(
"Jon has a beagle named Biscuit",
"i just adopted a dog named Biscuit, he's a beagle") is None
# A fact carrying no proper nouns or numbers can't be grounding-checked;
# the prompt owns that case, so the guard must let it through.
assert _reject_reason(
"Jon prefers short answers over long explanations",
"i really prefer short answers over long explanations") is None