forked from enderofwings/NexusOS
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:
co-authored by
Claude Opus 5
parent
b5541d1c48
commit
8691a67803
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user