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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -485,6 +485,7 @@ function App() {
|
||||
minHeight: 0,
|
||||
}}>
|
||||
<Chatbot
|
||||
visible={currentPage === "chatbot"}
|
||||
conversationId={activeConversationId}
|
||||
setConversationId={setActiveConversationId}
|
||||
onConversationChanged={() => loadConversations(search)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
+158
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user