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
+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));