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
+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: