diff --git a/.github/workflows/publish-performance-pack.yml b/.github/workflows/publish-performance-pack.yml
index 19c82d126..ff82c0eb5 100644
--- a/.github/workflows/publish-performance-pack.yml
+++ b/.github/workflows/publish-performance-pack.yml
@@ -70,7 +70,12 @@ jobs:
run: ./scripts/fetch-win-binaries.ps1
- name: Build Kev server runtime (model weights excluded)
shell: pwsh
- run: ./scripts/build-kev-runtime.ps1
+ run: |
+ ./scripts/build-kev-runtime.ps1
+ . ./scripts/config/windows-vc-runtime.ps1
+ Get-ChildItem resources/bin/kev-runtime -Recurse -File -Filter '*.exe' |
+ Select-Object -ExpandProperty DirectoryName -Unique |
+ ForEach-Object { Copy-VCRuntime $_ }
- name: Check staged Windows runtimes
shell: bash
run: |
@@ -166,6 +171,13 @@ jobs:
rm -rf "$BIN_ROOT/llama-cuda" "$BIN_ROOT/cuda-runtime"
cp -a "$RUNNER_TEMP/engine/llama-cuda" "$RUNNER_TEMP/engine/cuda-runtime" "$BIN_ROOT/"
cat "$RUNNER_TEMP/engine/engine.json"
+ if [ "$PLATFORM" = win32 ]; then
+ # llama-cuda was staged on Linux; reuse the VC redist from the Windows job.
+ for dll in MSVCP140.dll MSVCP140_1.dll MSVCP140_2.dll MSVCP140_ATOMIC_WAIT.dll \
+ MSVCP140_CODECVT_IDS.dll VCRUNTIME140.dll VCRUNTIME140_1.dll CONCRT140.dll VCOMP140.dll; do
+ cp "$BIN_ROOT/whisper/$dll" "$BIN_ROOT/llama-cuda/$dll"
+ done
+ fi
- name: Check the Linux llama engines' libraries resolve
if: inputs.platform == 'linux'
run: |
@@ -189,7 +201,8 @@ jobs:
run: |
python3 -m pip install --no-cache-dir --no-deps --target "$RUNNER_TEMP/onnx-nvidia" \
'nvidia-cuda-runtime==13.2.86' 'nvidia-cublas==13.2.2.2' \
- 'nvidia-curand==10.4.2.66' 'nvidia-cudnn-cu13==9.23.1.3'
+ 'nvidia-curand==10.4.2.66' 'nvidia-cudnn-cu13==9.23.1.3' \
+ 'nvidia-cufft==12.2.0.57' 'nvidia-cuda-nvrtc==13.2.86'
mkdir -p "$BIN_ROOT/onnx-cuda"
for folder in cu13 cudnn; do
find "$RUNNER_TEMP/onnx-nvidia/nvidia/$folder/lib" -maxdepth 1 -type f -name '*.so.*' \
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 69c49ddab..2f7d0d2c5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -122,6 +122,8 @@ jobs:
build-mac:
needs: version
runs-on: macos-15 # Standard arm64 runner; matches the pinned native payload.
+ env:
+ MACOSX_DEPLOYMENT_TARGET: '13.0'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -139,13 +141,12 @@ jobs:
'https://github.com/off-grid-ai/OGAD/releases/download/native-deps-2026-09/ogad-mac-native-2026-10-v2.tar.gz'
echo '50f13dc0b6318eb0c036ccd9ebe0f21d931912992bf828f1cd56de4113e9e48c '"$RUNNER_TEMP/mac-native.tar.gz" | shasum -a 256 -c -
tar -xzf "$RUNNER_TEMP/mac-native.tar.gz" -C resources/bin
+ - name: Build macOS image engine for macOS 13
+ run: bash scripts/build-sd-mac.sh
- name: Verify cached macOS engines
run: |
for variant in llama llama-prism; do
test -x "resources/bin/$variant/llama-server"
- minos=$(otool -l "resources/bin/$variant/llama-server" | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print $2; exit}')
- test -n "$minos"
- case "$minos" in 1[0-3].*|[1-9].*) ;; *) echo "Unsupported $variant minos $minos" >&2; exit 1 ;; esac
DYLD_LIBRARY_PATH="resources/bin/$variant" "resources/bin/$variant/llama-server" --help >/dev/null
done
test -x resources/bin/whisper/whisper-cli
@@ -286,6 +287,7 @@ jobs:
ref: 45d7fd41a1cbf268fa3e712aa5558f08fa1a75d9 # ExecuTorch 1.5.1; the same commit on every platform
path: _executorch_speech
lfs: true # Mac releases bundle the default English voices for offline first use.
+ submodules: recursive
persist-credentials: false
- name: Put the speech runtime beside this checkout
run: |
@@ -295,6 +297,17 @@ jobs:
fi
rm -rf ../executorch-speech
mv _executorch_speech ../executorch-speech
+ - name: Build macOS speech runtime for macOS 13
+ run: |
+ npm --prefix ../executorch-speech ci
+ # Use the bundled Python and its existing torchgen, YAML, and Jinja dependencies.
+ speech_python="$GITHUB_WORKSPACE/resources/bin/kev-runtime/python/bin/python3"
+ "$speech_python" -c 'import torchgen, yaml, jinja2'
+ cmake -S ../executorch-speech/native -B ../executorch-speech/native/build \
+ -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_DEPLOYMENT_TARGET=13.0 \
+ -DFLATCC_ALLOW_WERROR=OFF -DPYTHON_EXECUTABLE="$speech_python"
+ cmake --build ../executorch-speech/native/build --config Release --parallel 4 \
+ --target executorch-speech
- name: Let npm read the private UI package (core and Pro)
# @offgrid/operator-ui is a private Wednesday package that core and Pro both render with
# (skin, primitives, motion). It is listed as optional, so npm would skip it silently
@@ -385,14 +398,15 @@ jobs:
printf '%s' "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
export APPLE_API_KEY="$RUNNER_TEMP/AuthKey.p8"
npm run prepare:speech-defaults
+ node scripts/check-macos-minos.mjs 13.0
# Build every artifact with publication disabled. The artifact hook verifies
# both the updater ZIP and DMG before this command can complete.
npx electron-builder --mac \
- -c.dmg.artifactName='OffGrid-${version}.${ext}' \
+ -c.dmg.artifactName='OffGrid-${version}-${arch}.${ext}' \
--publish never
# electron-builder notarizes the app before it creates the DMG. Submit the
# completed, signed DMG too, then staple its ticket for offline Gatekeeper.
- DMG="dist/OffGrid-$VERSION.dmg"
+ DMG="dist/OffGrid-$VERSION-arm64.dmg"
xcrun notarytool submit "$DMG" \
--key "$APPLE_API_KEY" \
--key-id "$APPLE_API_KEY_ID" \
@@ -488,7 +502,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: |
- cp "dist/OffGrid-$VERSION.dmg" dist/OffGrid-latest.dmg
+ cp "dist/OffGrid-$VERSION-arm64.dmg" dist/OffGrid-latest.dmg
gh release upload "v$VERSION" dist/OffGrid-latest.dmg --clobber
# Constant nightly download link. /releases/latest EXCLUDES pre-releases, so it
# can never point at a beta build (that is exactly what keeps the STABLE link
@@ -502,7 +516,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: |
- cp "dist/OffGrid-$VERSION.dmg" dist/OffGrid-nightly.dmg
+ cp "dist/OffGrid-$VERSION-arm64.dmg" dist/OffGrid-nightly.dmg
# Ensure the rolling 'nightly' pre-release exists (kept separate from the
# versioned -beta.N releases electron-builder makes for the updater feed),
# then clobber its dmg so the link always serves the freshest nightly.
@@ -684,6 +698,14 @@ jobs:
fi
- name: Install dependencies
run: npm ci
+ - name: Install Vulkan SDK for Whisper
+ uses: humbletim/install-vulkan-sdk@v1.2
+ with:
+ version: '1.4.321.1'
+ cache: true
+ - name: Build Windows Vulkan Whisper
+ shell: pwsh
+ run: ./scripts/build-whisper-vulkan-win.ps1
- name: Fetch Windows native binaries (llama/whisper/sd/ffmpeg)
shell: pwsh
env:
@@ -700,7 +722,7 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
MICROSOFT_CLIENT_ID: ${{ vars.MICROSOFT_CLIENT_ID }}
- run: node scripts/stage-native.mjs && npx electron-vite build && node scripts/check-windows-onnx-preload.mjs
+ run: node scripts/stage-native.mjs && npx electron-vite build
- name: Package Windows web installer, payload, and update metadata
shell: bash
env:
@@ -714,10 +736,10 @@ jobs:
shell: bash
run: |
test -s dist/win-unpacked/resources/performance-packs.json
- for binary in llama/llama-server.exe llama-cpu/llama-server.exe llama-prism/llama-server.exe llama-prism-cpu/llama-server.exe whisper-cpu/whisper-cli.exe sd/sd-cli.exe sd-cpu/sd-cli.exe ffmpeg.exe; do
+ for binary in llama/llama-server.exe llama-cpu/llama-server.exe llama-prism/llama-server.exe llama-prism-cpu/llama-server.exe whisper-cpu/whisper-cli.exe whisper-vulkan/whisper-cli.exe whisper-vulkan/whisper-server.exe sd/sd-cli.exe sd-cpu/sd-cli.exe ffmpeg.exe; do
test -s "dist/win-unpacked/resources/bin/$binary"
done
- for folder in llama-cuda llama-prism-cuda cuda-runtime sd-cuda whisper kev-runtime; do
+ for folder in llama-cuda llama-prism-cuda cuda-runtime sd-cuda whisper kev-runtime onnx-cuda; do
test ! -e "dist/win-unpacked/resources/bin/$folder"
done
- name: Report Windows native payload sizes
@@ -931,6 +953,14 @@ jobs:
run: npm ci
- name: Install Linux GPU runtime dependencies
run: sudo apt-get update -qq && sudo apt-get install -y libgomp1 libvulkan1 libvulkan-dev glslc spirv-headers unzip
+ - name: Stage Vulkan loaders beside cached Linux engines
+ run: |
+ source="$(ldconfig -p | awk '$1 == "libvulkan.so.1" && /x86-64/ && !found { found=$NF } END { print found }')"
+ test -n "$source"
+ for folder in llama llama-prism whisper; do
+ install -m 755 "$(readlink -f "$source")" "build/linux-bin/$folder/libvulkan.so.1"
+ test -s "build/linux-bin/$folder/libvulkan.so.1"
+ done
- name: Typecheck Linux main process
env:
OFFGRID_FORCE_CORE: '0'
@@ -959,8 +989,21 @@ jobs:
- name: Verify Linux package inputs
run: |
test -s dist/linux-unpacked/resources/performance-packs.json
+ provider="$(find dist/linux-unpacked/resources/app.asar.unpacked/node_modules \
+ -path '*/onnxruntime-node/*' -name libonnxruntime_providers_cuda.so -type f -print -quit)"
+ test -n "$provider"
+ test -s "$provider"
+ for folder in llama llama-prism whisper; do
+ test -s "dist/linux-unpacked/resources/bin/$folder/libvulkan.so.1"
+ done
+ while IFS= read -r -d '' binary; do
+ if file "$binary" | grep -q 'ELF.*executable'; then
+ glibc="$(objdump -T "$binary" | grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1 || true)"
+ printf '[ci] %s highest GLIBC: %s\n' "$binary" "${glibc:-none (static binary)}"
+ fi
+ done < <(find dist/linux-unpacked/resources/bin -type f -print0)
cmp resources/bin/kev-local-server.py dist/linux-unpacked/resources/bin/kev-local-server.py
- for folder in llama-cuda llama-prism-cuda cuda-runtime sd-cuda whisper-cuda kev-runtime; do
+ for folder in llama-cuda llama-prism-cuda cuda-runtime sd-cuda whisper-cuda kev-runtime onnx-cuda; do
test ! -e "dist/linux-unpacked/resources/bin/$folder"
done
for variant in llama llama-cpu llama-prism llama-prism-cpu; do
@@ -1013,9 +1056,6 @@ jobs:
const root = path.resolve("dist/linux-unpacked/resources/app.asar/node_modules")
const Database = require(path.join(root, "better-sqlite3-multiple-ciphers"))
new Database(":memory:").close()
- if (typeof require(path.join(root, "@lancedb/lancedb")).connect !== "function") {
- throw new Error("LanceDB native package did not load")
- }
if (typeof require(path.join(root, "sharp")) !== "function") {
throw new Error("Sharp native package did not load")
}
@@ -1038,7 +1078,7 @@ jobs:
test -n "$bundled"
cmp "dist/linux-unpacked/resources/bin/$variant/llama-server" "$bundled"
done
- for helper in whisper/whisper-cli whisper-cpu/whisper-cli whisper/LICENSE ffmpeg licenses/ffmpeg.txt sd/sd-cli sd/sd-server sd/libggml-vulkan.so sd/libgomp.so.1 sd/libvulkan.so.1 executorch-speech licenses/libgomp1.txt licenses/libvulkan1.txt; do
+ for helper in llama/libvulkan.so.1 llama-prism/libvulkan.so.1 whisper/libvulkan.so.1 whisper/whisper-cli whisper-cpu/whisper-cli whisper/LICENSE ffmpeg licenses/ffmpeg.txt sd/sd-cli sd/sd-server sd/libggml-vulkan.so sd/libgomp.so.1 sd/libvulkan.so.1 executorch-speech licenses/libgomp1.txt licenses/libvulkan1.txt; do
bundled="$(find "$extraction/squashfs-root" -path "*/resources/bin/$helper" -type f -print -quit)"
test -n "$bundled"
cmp "dist/linux-unpacked/resources/bin/$helper" "$bundled"
@@ -1055,7 +1095,7 @@ jobs:
test -n "$bundled"
cmp "dist/linux-unpacked/resources/bin/$variant/llama-server" "$bundled"
done
- for helper in whisper/whisper-cli whisper-cpu/whisper-cli whisper/LICENSE ffmpeg licenses/ffmpeg.txt sd/sd-cli sd/sd-server sd/libggml-vulkan.so sd/libgomp.so.1 sd/libvulkan.so.1 executorch-speech licenses/libgomp1.txt licenses/libvulkan1.txt; do
+ for helper in llama/libvulkan.so.1 llama-prism/libvulkan.so.1 whisper/libvulkan.so.1 whisper/whisper-cli whisper-cpu/whisper-cli whisper/LICENSE ffmpeg licenses/ffmpeg.txt sd/sd-cli sd/sd-server sd/libggml-vulkan.so sd/libgomp.so.1 sd/libvulkan.so.1 executorch-speech licenses/libgomp1.txt licenses/libvulkan1.txt; do
bundled="$(find "$extraction/deb" -path "*/resources/bin/$helper" -type f -print -quit)"
test -n "$bundled"
cmp "dist/linux-unpacked/resources/bin/$helper" "$bundled"
diff --git a/docs/RELEASE_DESKTOP.md b/docs/RELEASE_DESKTOP.md
index 6af1a1c6a..c59cd0855 100644
--- a/docs/RELEASE_DESKTOP.md
+++ b/docs/RELEASE_DESKTOP.md
@@ -240,3 +240,9 @@ quickly. No EV needed.
- [ ] Windows: bundled llama-server doesn't start — fix in progress separately.
- [ ] Azure Trusted Signing validated + `azureSignOptions` wired (§4) — resumes with Windows.
+
+### Linux AppImage
+
+The AppImage and deb packages are x64 only. On Ubuntu 24.04, install
+`libfuse2t64` before you start the AppImage. Headless servers and some sandboxes
+need the `--no-sandbox` option.
diff --git a/e2e/fixtures/gpu-fault-llama-server.mjs b/e2e/fixtures/gpu-fault-llama-server.mjs
new file mode 100644
index 000000000..6041bcc5f
--- /dev/null
+++ b/e2e/fixtures/gpu-fault-llama-server.mjs
@@ -0,0 +1,124 @@
+#!/usr/bin/env node
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type -- executable JavaScript fixture */
+
+// The GPU fault journey's only fake: the native llama-server boundary. Everything that decides what
+// happens after a fault (the HTTP client's classifier, the chat fallback policy, the same-model
+// restart, the backend ladder, the CPU-only launch flags, the runtime status and Settings) is
+// production code. Like a real engine, this one reports where it put the model in its load log,
+// and like a GPU that has lost its context it answers "500 Compute error" for as many questions as
+// faults.json beside it still lists. A fault is spent when it is answered, across restarts.
+
+import fs from 'node:fs'
+import http from 'node:http'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const args = process.argv.slice(2)
+const portFlag = Math.max(args.indexOf('--port'), args.indexOf('-p'))
+const port = portFlag >= 0 ? Number(args[portFlag + 1]) : 8439
+const deviceFlag = args.indexOf('--device')
+const cpuOnly = deviceFlag >= 0 && args[deviceFlag + 1] === 'none'
+const here = path.dirname(fileURLToPath(import.meta.url))
+const FAULTS = path.join(here, 'faults.json')
+const LOG = path.join(here, 'launches.jsonl')
+
+fs.appendFileSync(LOG, `${JSON.stringify({ args })}\n`)
+
+// The load log the app reads to name the backend and the layers on the GPU.
+if (cpuOnly) {
+ process.stderr.write('load_tensors: CPU model buffer size = 120.00 MiB\n')
+} else {
+ process.stderr.write('load_tensors: offloaded 28/28 layers to GPU\n')
+ process.stderr.write('load_tensors: Metal model buffer size = 120.00 MiB\n')
+}
+
+function takeFault() {
+ try {
+ const plan = JSON.parse(fs.readFileSync(FAULTS, 'utf8'))
+ if (!(plan.remaining > 0)) return false
+ fs.writeFileSync(FAULTS, JSON.stringify({ remaining: plan.remaining - 1 }))
+ return true
+ } catch {
+ return false
+ }
+}
+
+function lastUser(messages) {
+ const user = [...(messages ?? [])].reverse().find((message) => message.role === 'user')
+ const content = user?.content
+ return typeof content === 'string'
+ ? content
+ : Array.isArray(content)
+ ? content.map((part) => part?.text ?? '').join('')
+ : ''
+}
+
+const delta = (payload) => `data: ${JSON.stringify({ choices: [{ delta: payload }] })}\n\n`
+
+const server = http.createServer((request, response) => {
+ if (request.method === 'GET' && request.url === '/health') {
+ response.writeHead(200, { 'Content-Type': 'application/json' })
+ response.end(JSON.stringify({ status: 'ok' }))
+ return
+ }
+ if (request.method === 'GET' && request.url === '/v1/models') {
+ response.writeHead(200, { 'Content-Type': 'application/json' })
+ response.end(JSON.stringify({ data: [{ id: 'gpu-fault-model' }] }))
+ return
+ }
+ if (request.method !== 'POST' || !String(request.url).includes('/chat/completions')) {
+ response.writeHead(404)
+ response.end()
+ return
+ }
+ let body = ''
+ request.on('data', (chunk) => {
+ body += chunk
+ })
+ request.on('end', () => {
+ let payload = {}
+ try {
+ payload = JSON.parse(body)
+ } catch {
+ /* answer the default */
+ }
+ const question = lastUser(payload.messages)
+ // Only the journey's own questions fault; follow-up suggestions and titles answer normally.
+ const faulted = question.startsWith('GPU check') && takeFault()
+ fs.appendFileSync(
+ LOG,
+ `${JSON.stringify({ question: question.slice(0, 80), stream: payload.stream !== false, faulted, cpuOnly })}\n`
+ )
+ if (faulted) {
+ response.writeHead(500, { 'Content-Type': 'application/json' })
+ response.end(
+ JSON.stringify({ error: { code: 500, message: 'Compute error', type: 'server_error' } })
+ )
+ return
+ }
+ const text = question.startsWith('GPU check')
+ ? `Answered on the ${cpuOnly ? 'CPU' : 'GPU'}.`
+ : 'Okay.'
+ if (payload.stream === false) {
+ response.writeHead(200, { 'Content-Type': 'application/json' })
+ response.end(
+ JSON.stringify({
+ choices: [
+ { index: 0, finish_reason: 'stop', message: { role: 'assistant', content: text } }
+ ]
+ })
+ )
+ return
+ }
+ response.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' })
+ response.write(delta({ content: text }))
+ response.write(
+ `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\n`
+ )
+ response.write('data: [DONE]\n\n')
+ response.end()
+ })
+})
+
+server.listen(port, '127.0.0.1')
diff --git a/e2e/fixtures/unified-retrieval-llama-server.mjs b/e2e/fixtures/unified-retrieval-llama-server.mjs
new file mode 100644
index 000000000..3063ff3f9
--- /dev/null
+++ b/e2e/fixtures/unified-retrieval-llama-server.mjs
@@ -0,0 +1,144 @@
+#!/usr/bin/env node
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type -- executable JavaScript fixture */
+
+// The unified retrieval journey's only model fake: the native llama-server HTTP boundary. The
+// rendered app, chat storage, the search index (FTS + trigram), the record search service, the
+// search_memory tool, citation numbering, the date line in the system prompt, IPC and the chat UI
+// remain production code. Like a small local model, this boundary answers only from what reached
+// it: a fact it repeats must have come back through the real search_memory result, and the date it
+// states must have come from the real system prompt.
+
+import fs from 'node:fs'
+import http from 'node:http'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const args = process.argv.slice(2)
+const portFlag = Math.max(args.indexOf('--port'), args.indexOf('-p'))
+const port = portFlag >= 0 ? Number(args[portFlag + 1]) : 8439
+
+// What reached the model, one line per request, beside this executable: the spec prints it when a
+// journey fails, so a wrong answer shows what retrieval actually returned.
+const LOG = path.join(path.dirname(fileURLToPath(import.meta.url)), 'requests.jsonl')
+
+const delta = (payload) => `data: ${JSON.stringify({ choices: [{ delta: payload }] })}\n\n`
+
+function textOf(content) {
+ if (typeof content === 'string') return content
+ if (Array.isArray(content)) return content.map((part) => part?.text ?? '').join('')
+ return ''
+}
+
+function lastUser(messages) {
+ const user = [...messages].reverse().find((message) => message.role === 'user')
+ return textOf(user?.content)
+}
+
+function replyFor(payload) {
+ const messages = payload.messages ?? []
+ const question = lastUser(messages)
+ const asked = question.toLowerCase()
+
+ // "What is today's date?": read it from the system prompt's date line, never from a clock here.
+ // (Only the user's own question: the app's follow-up suggestions quote it after "The user asked:".)
+ if (asked.startsWith("what is today's date")) {
+ const system = messages
+ .filter((message) => message.role === 'system')
+ .map((message) => textOf(message.content))
+ .join('\n')
+ const date = /Current date: ([^(]+?) \(/.exec(system)?.[1]
+ return date ? `Today is ${date}.` : 'I do not know the date.'
+ }
+
+ // A memory question: search first, then answer only from the tool result.
+ if (asked.startsWith('search my memory')) {
+ const offered = (payload.tools ?? []).some((tool) => tool?.function?.name === 'search_memory')
+ const userIndex = messages.map((message) => message.role).lastIndexOf('user')
+ const results = messages
+ .slice(userIndex + 1)
+ .filter((message) => message.role === 'tool')
+ .map((message) => textOf(message.content))
+ .join('\n')
+ if (!offered) return 'search_memory was not offered.'
+ if (!results) {
+ return `${JSON.stringify({
+ name: 'search_memory',
+ arguments: { query: 'Kunal RBI filing promise' }
+ })}`
+ }
+ const source = /]*\bat="([^"]+)"[^>]*>([^<]*RBI filing[^<]*)<\/source>/.exec(
+ results
+ )
+ if (!source) return 'I found nothing about that in your memory.'
+ return `You promised Kunal the RBI filing by Friday [S${source[1]}]. Noted ${source[2].slice(0, 10)}.`
+ }
+
+ // Anything else: acknowledge this exact message, so the journey can wait for its own reply.
+ return `Noted (${[...question].length}).`
+}
+
+const server = http.createServer((request, response) => {
+ if (request.method === 'GET' && request.url === '/health') {
+ response.writeHead(200, { 'Content-Type': 'application/json' })
+ response.end(JSON.stringify({ status: 'ok' }))
+ return
+ }
+ if (request.method === 'GET' && request.url === '/v1/models') {
+ response.writeHead(200, { 'Content-Type': 'application/json' })
+ response.end(JSON.stringify({ data: [{ id: 'unified-retrieval-model' }] }))
+ return
+ }
+ if (request.method !== 'POST' || !String(request.url).includes('/chat/completions')) {
+ response.writeHead(404)
+ response.end()
+ return
+ }
+ let body = ''
+ request.on('data', (chunk) => {
+ body += chunk
+ })
+ request.on('end', () => {
+ let payload = {}
+ try {
+ payload = JSON.parse(body)
+ } catch {
+ /* answer the default */
+ }
+ const text = replyFor(payload)
+ const messages = payload.messages ?? []
+ fs.appendFileSync(
+ LOG,
+ `${JSON.stringify({
+ question: lastUser(messages),
+ tools: (payload.tools ?? []).map((tool) => tool?.function?.name),
+ toolResults: messages.filter((m) => m.role === 'tool').map((m) => textOf(m.content)),
+ reply: text
+ })}\n`
+ )
+ if (payload.stream === false) {
+ response.writeHead(200, { 'Content-Type': 'application/json' })
+ response.end(
+ JSON.stringify({
+ choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content: text } }]
+ })
+ )
+ return
+ }
+ response.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive'
+ })
+ for (const piece of text.match(/.{1,24}/gsu) ?? []) response.write(delta({ content: piece }))
+ response.write(
+ `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\n`
+ )
+ response.write('data: [DONE]\n\n')
+ response.end()
+ })
+})
+
+server.listen(port, '127.0.0.1', () => {
+ console.log(`unified-retrieval fake llama-server listening on ${port}`)
+})
diff --git a/e2e/gpu-fault-recovery.spec.ts b/e2e/gpu-fault-recovery.spec.ts
new file mode 100644
index 000000000..f35443a94
--- /dev/null
+++ b/e2e/gpu-fault-recovery.spec.ts
@@ -0,0 +1,135 @@
+/**
+ * GPU fault recovery: a GPU that stops responding never breaks a chat or changes the model.
+ *
+ * The rendered app, the HTTP client's fault classifier, the shared chat fallback policy, the
+ * same-model restart, the backend ladder, the CPU-only launch flags (`--device none`), the runtime
+ * status and the Settings row are production code. One fake stands at the native boundary: a
+ * llama-server that reports where it placed the model and answers "500 Compute error" for as many
+ * questions as its fault plan lists (e2e/fixtures/gpu-fault-llama-server.mjs).
+ *
+ * Proves, from what the user sees:
+ * - one fault: the same model restarts and the answer arrives, with no notice;
+ * - two faults: the same model answers on the CPU, the chat says so once, Settings shows CPU,
+ * and "Try the GPU again" puts it back on the GPU.
+ */
+import { expect, test, type ElectronApplication, type Page } from '@playwright/test'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { completeOnboarding } from './helpers/onboarding'
+import { launchOffGrid, targetIsPackaged } from './helpers/launch'
+
+const CPU_NOTICE = 'Chat moved to the CPU. The GPU stopped responding. Replies will be slower.'
+
+let app: ElectronApplication | null = null
+let page: Page
+let rootDir: string
+let llamaDir: string
+
+function installModelBoundary(profileDir: string): string {
+ const modelsDir = path.join(profileDir, 'models')
+ const binDir = path.join(profileDir, 'bin')
+ llamaDir = path.join(binDir, 'llama')
+ fs.mkdirSync(modelsDir, { recursive: true })
+ fs.mkdirSync(llamaDir, { recursive: true })
+ const model = Buffer.alloc(2_048)
+ model.write('GGUF')
+ fs.writeFileSync(path.join(modelsDir, 'gpu-fault.gguf'), model)
+ fs.writeFileSync(
+ path.join(modelsDir, 'active-model.json'),
+ JSON.stringify({ id: 'gpu-fault-model', primary: 'gpu-fault.gguf', mmproj: null })
+ )
+ const executable = path.join(llamaDir, 'llama-server')
+ fs.copyFileSync(
+ path.join(process.cwd(), 'e2e', 'fixtures', 'gpu-fault-llama-server.mjs'),
+ executable
+ )
+ fs.chmodSync(executable, 0o755)
+ return binDir
+}
+
+function planFaults(remaining: number): void {
+ fs.writeFileSync(path.join(llamaDir, 'faults.json'), JSON.stringify({ remaining }))
+}
+
+async function ask(text: string, reply: string): Promise {
+ const composer = page.getByRole('textbox', { name: 'Message', exact: true })
+ await composer.fill(text)
+ await composer.press('Enter')
+ await expect(page.getByText(text, { exact: true }).last()).toBeVisible()
+ await expect(page.getByText(reply, { exact: true }).last()).toBeVisible({ timeout: 90_000 })
+}
+
+/** The chat engine's row in Settings → Text: its "Now:" line. */
+async function openChatBackendRow(): Promise> {
+ await page.getByTitle('Active models').click()
+ await page.getByRole('button', { name: 'Settings', exact: true }).last().click()
+ const row = page.getByText(/^Now: /).first()
+ await expect(row).toBeVisible()
+ return row
+}
+
+test.describe('GPU fault recovery', () => {
+ test.skip(targetIsPackaged(), 'This native-boundary fixture is for the source-built E2E target.')
+ test.skip(process.platform !== 'darwin', 'The fixture stands in for the macOS Metal engine.')
+ test.describe.configure({ mode: 'serial', timeout: 240_000 })
+
+ test.beforeEach(async () => {
+ rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-gpu-fault-'))
+ const profileDir = path.join(rootDir, 'profile')
+ const binDir = installModelBoundary(profileDir)
+ planFaults(0)
+ app = await launchOffGrid({
+ env: {
+ ...process.env,
+ OFFGRID_USER_DATA: profileDir,
+ OFFGRID_BIN_DIR: binDir,
+ OFFGRID_EMBEDDINGS_OFFLINE: '1',
+ NODE_ENV: 'production',
+ NO_PROXY: '127.0.0.1,localhost'
+ }
+ })
+ page = await app.firstWindow()
+ await page.waitForLoadState('domcontentloaded')
+ await completeOnboarding(page)
+ await page.getByRole('button', { name: 'Chat', exact: true }).click()
+ await expect(page.getByRole('textbox', { name: 'Message', exact: true })).toBeVisible()
+ const captureDismiss = page.getByRole('button', { name: 'Dismiss', exact: true })
+ if (await captureDismiss.isVisible().catch(() => false)) await captureDismiss.click()
+ })
+
+ // eslint-disable-next-line no-empty-pattern
+ test.afterEach(async ({}, testInfo) => {
+ const launches = path.join(llamaDir, 'launches.jsonl')
+ if (testInfo.status !== testInfo.expectedStatus && fs.existsSync(launches)) {
+ console.log(fs.readFileSync(launches, 'utf8'))
+ }
+ const running = app
+ app = null
+ if (running) await running.close()
+ if (path.basename(rootDir).startsWith('offgrid-gpu-fault-')) {
+ fs.rmSync(rootDir, { recursive: true, force: true })
+ }
+ })
+
+ test('one GPU fault restarts the same model and the answer still arrives', async () => {
+ await ask('GPU check: hello', 'Answered on the GPU.')
+ planFaults(1)
+ await ask('GPU check: once more', 'Answered on the GPU.')
+ await expect(page.getByText(CPU_NOTICE)).toHaveCount(0)
+ })
+
+ test('a second fault moves the same model to the CPU, says so once, and the GPU comes back on request', async () => {
+ await ask('GPU check: hello', 'Answered on the GPU.')
+ planFaults(2)
+ await ask('GPU check: keep going', 'Answered on the CPU.')
+ // The notice sits above the answer (the sidebar preview may repeat its text).
+ await expect(page.getByText(CPU_NOTICE).first()).toBeVisible()
+ await expect(page.getByText('Answered on the CPU.', { exact: true }).last()).toBeVisible()
+
+ const row = await openChatBackendRow()
+ await expect(row).toContainText('Now: CPU')
+ await page.getByRole('button', { name: 'Try the GPU again' }).first().click()
+ await expect(row).toContainText('Metal · all layers on GPU', { timeout: 60_000 })
+ })
+})
diff --git a/e2e/unified-retrieval.spec.ts b/e2e/unified-retrieval.spec.ts
new file mode 100644
index 000000000..5f9ecc7d2
--- /dev/null
+++ b/e2e/unified-retrieval.spec.ts
@@ -0,0 +1,188 @@
+/**
+ * Unified retrieval: one search engine for memory, chats and the search palette.
+ *
+ * The rendered app, chat storage, the search index (unicode61 FTS with the trigram twin for
+ * scripts without spaces), the record search service, the search_memory tool, citation
+ * numbering, the date line at the end of the system prompt, IPC and the chat UI are production
+ * code. One fake stands at the true boundary: a scripted llama-server that repeats only what
+ * reached it (e2e/fixtures/unified-retrieval-llama-server.mjs). Embeddings stay offline, so this
+ * proves the keyword and trigram paths a fresh install has before its embedder is ready.
+ *
+ * Proves, on a fresh profile, from what the user sees:
+ * - a fact told in one chat is found from a new chat, cited as [S#], dated the day it was said,
+ * and the citation opens the chat it came from;
+ * - the chat knows today's date (it comes from the prompt's date line, not the model);
+ * - the search palette finds chat text by a typed prefix, a Hindi word, Japanese text (no
+ * spaces between words) and the time word "today".
+ */
+import { expect, test, type ElectronApplication, type Page } from '@playwright/test'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { completeOnboarding } from './helpers/onboarding'
+import { launchOffGrid, targetIsPackaged } from './helpers/launch'
+
+const PROMISE = 'Note for later: I promised Kunal the RBI filing by Friday.'
+const ENGLISH = 'Quarterly compliance review with the audit team.'
+const HINDI = 'कल की बैठक में बजट पर चर्चा हुई।'
+const JAPANESE = '来週の会議は東京で行います。'
+
+let app: ElectronApplication | null = null
+let page: Page
+let rootDir: string
+let modelLog: string
+
+function installModelBoundary(profileDir: string): string {
+ const modelsDir = path.join(profileDir, 'models')
+ const binDir = path.join(profileDir, 'bin')
+ const llamaDir = path.join(binDir, 'llama')
+ fs.mkdirSync(modelsDir, { recursive: true })
+ fs.mkdirSync(llamaDir, { recursive: true })
+ const model = Buffer.alloc(2_048)
+ model.write('GGUF')
+ fs.writeFileSync(path.join(modelsDir, 'unified-retrieval.gguf'), model)
+ fs.writeFileSync(
+ path.join(modelsDir, 'active-model.json'),
+ JSON.stringify({ id: 'unified-retrieval-model', primary: 'unified-retrieval.gguf', mmproj: null })
+ )
+ const executable = path.join(llamaDir, 'llama-server')
+ fs.copyFileSync(
+ path.join(process.cwd(), 'e2e', 'fixtures', 'unified-retrieval-llama-server.mjs'),
+ executable
+ )
+ fs.chmodSync(executable, 0o755)
+ return binDir
+}
+
+/** The local calendar day, as the app writes it on a source ("2026-10-15"). */
+function localDay(date = new Date()): string {
+ const pad = (n: number): string => String(n).padStart(2, '0')
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
+}
+
+async function openChat(): Promise {
+ await page.getByRole('button', { name: 'Chat', exact: true }).click()
+ await expect(page.getByRole('textbox', { name: 'Message', exact: true })).toBeVisible()
+ const captureDismiss = page.getByRole('button', { name: 'Dismiss', exact: true })
+ if (await captureDismiss.isVisible().catch(() => false)) await captureDismiss.click()
+}
+
+/** The scripted model's acknowledgement of exactly this message. */
+const noted = (text: string): string => `Noted (${[...text].length}).`
+
+async function say(text: string, reply: string | RegExp = noted(text)): Promise {
+ const composer = page.getByRole('textbox', { name: 'Message', exact: true })
+ await composer.fill(text)
+ await composer.press('Enter')
+ await expect(page.getByText(text, { exact: true }).last()).toBeVisible()
+ await expect(page.getByText(reply).last()).toBeVisible({ timeout: 60_000 })
+}
+
+async function newChat(): Promise {
+ await page.getByRole('button', { name: 'New chat', exact: true }).first().click()
+ await expect(page.getByRole('textbox', { name: 'Message', exact: true })).toHaveValue('')
+}
+
+test.describe('unified retrieval', () => {
+ test.skip(targetIsPackaged(), 'This native-boundary fixture is for the source-built E2E target.')
+ test.describe.configure({ mode: 'serial', timeout: 180_000 })
+
+ test.beforeEach(async () => {
+ rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-unified-retrieval-'))
+ const profileDir = path.join(rootDir, 'profile')
+ const binDir = installModelBoundary(profileDir)
+ modelLog = path.join(binDir, 'llama', 'requests.jsonl')
+ app = await launchOffGrid({
+ env: {
+ ...process.env,
+ OFFGRID_USER_DATA: profileDir,
+ OFFGRID_BIN_DIR: binDir,
+ OFFGRID_EMBEDDINGS_OFFLINE: '1',
+ // Memory search across chats is a Pro tool.
+ OFFGRID_PRO: '1',
+ NODE_ENV: 'production',
+ NO_PROXY: '127.0.0.1,localhost'
+ }
+ })
+ page = await app.firstWindow()
+ await page.waitForLoadState('domcontentloaded')
+ await completeOnboarding(page)
+ await openChat()
+ })
+
+ // Playwright requires the first callback argument to use object destructuring.
+ // eslint-disable-next-line no-empty-pattern
+ test.afterEach(async ({}, testInfo) => {
+ // On failure, show what reached the model: the question, the tools offered, the tool results.
+ if (testInfo.status !== testInfo.expectedStatus && fs.existsSync(modelLog)) {
+ await testInfo.attach('model-requests', { path: modelLog, contentType: 'application/json' })
+ console.log(fs.readFileSync(modelLog, 'utf8'))
+ }
+ const running = app
+ app = null
+ if (running) await running.close()
+ if (path.basename(rootDir).startsWith('offgrid-unified-retrieval-')) {
+ fs.rmSync(rootDir, { recursive: true, force: true })
+ }
+ })
+
+ test('a fact from another chat comes back cited, dated, and opens its source', async () => {
+ await say(PROMISE)
+
+ await newChat()
+ await say(
+ 'Search my memory: what did I promise Kunal?',
+ /You promised Kunal the RBI filing by Friday/
+ )
+
+ // The answer repeats only what search_memory returned, with the day it was said.
+ const answer = page.getByText(/You promised Kunal the RBI filing by Friday/).last()
+ await expect(answer).toContainText(`Noted ${localDay()}.`)
+
+ // The [S#] tag renders as a citation button; it opens the chat the fact came from.
+ const citation = answer.getByRole('button', { name: /^S\d+$/ })
+ await expect(citation).toBeVisible()
+ await citation.click()
+ await expect(page.getByText(PROMISE, { exact: true }).last()).toBeVisible()
+ })
+
+ test("the chat states today's date from the prompt's date line", async () => {
+ // Built from parts: Node and the app's Chromium differ on the comma after the weekday.
+ const now = new Date()
+ const part = (options: Intl.DateTimeFormatOptions): string =>
+ now.toLocaleDateString('en-GB', options)
+ const today = new RegExp(
+ `^Today is ${part({ weekday: 'long' })},? ${now.getDate()} ${part({ month: 'long' })} ${now.getFullYear()}\\.$`
+ )
+ await say("What is today's date?", today)
+ })
+
+ test('the search palette finds chat text by prefix, Hindi, Japanese and "today"', async () => {
+ await say(ENGLISH)
+ await say(HINDI)
+ await say(JAPANESE)
+
+ await page.keyboard.press('Meta+K')
+ const dialog = page.getByRole('dialog', { name: 'Search Off Grid AI' })
+ await expect(dialog).toBeVisible()
+ const search = dialog.getByPlaceholder(/^Search everything/)
+
+ // A word still being typed matches as a prefix.
+ await search.fill('quarterl')
+ await expect(dialog.getByText(/Quarterly compliance review/).first()).toBeVisible()
+
+ // Hindi: whole words, vowel signs kept (the default tokenizer splits them apart).
+ await search.fill('बजट')
+ await expect(dialog.getByText(/बजट पर चर्चा/).first()).toBeVisible()
+
+ // Japanese has no spaces between words: the trigram index finds text inside a run.
+ await search.fill('東京で行')
+ await expect(dialog.getByText(/会議は東京で行います/).first()).toBeVisible()
+
+ // A time word alone lists what happened in that window.
+ await search.fill('today')
+ await expect(
+ dialog.getByText(/Quarterly compliance review|बजट पर चर्चा|会議は東京で行います/).first()
+ ).toBeVisible()
+ })
+})
diff --git a/electron-builder.yml b/electron-builder.yml
index 5dea618b3..a889dbc52 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -60,6 +60,7 @@ win:
- '!llama-cuda/**'
- '!llama-prism-cuda/**'
- '!cuda-runtime/**'
+ - '!onnx-cuda/**'
- '!sd-cuda/**'
- '!whisper/**'
- '!kev-runtime/**'
@@ -109,6 +110,9 @@ mac:
extraResources:
- from: resources/bin
to: bin
+ filter:
+ - '**/*'
+ - '!onnx-cuda/**'
- from: ../executorch-speech/native/bin/executorch-speech
to: bin/executorch-speech
- from: ../executorch-speech/generated/default-assets
@@ -120,7 +124,7 @@ mac:
# Let electron-builder's signing stand.
dmg:
sign: true
- artifactName: ${name}-${version}.${ext}
+ artifactName: ${name}-${version}-${arch}.${ext}
# The app is currently ~3.3 GB before compression. macOS 26 adds an EFI/GPT
# partition to large HFS images, so dmgbuild's computed size can leave less usable
# space than the bundle needs. Allocate deterministic headroom, then shrink after
@@ -142,6 +146,7 @@ linux:
- '!llama-cuda/**'
- '!llama-prism-cuda/**'
- '!cuda-runtime/**'
+ - '!onnx-cuda/**'
- '!sd-cuda/**'
- '!whisper-cuda/**'
- '!kev-runtime/**'
@@ -168,6 +173,7 @@ deb:
- libsecret-1-0
- libasound2t64
- libgbm1
+ - libvulkan1
- xdotool
- x11-utils
- wtype
@@ -186,6 +192,8 @@ deb:
- xdg-desktop-portal
- grim
- kde-spectacle
+ recommends:
+ - mesa-vulkan-drivers
# Electron fuses (flipped at package time). ASAR integrity makes a tampered app.asar
# refuse to load — the local-first anti-tamper lever (we don't phone home, so this is
# the bar against cracking the offline license check). Only the integrity fuses are
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index ab9a1a8c5..de349e99b 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -126,12 +126,6 @@ export default defineConfig({
// embeddings.ts can spawn it by path. It must not be folded into the main chunk: the point
// is that native ONNX inference runs off the thread that owns the window.
rollupOptions: {
- // Rollup hoists external require() calls above source imports. The CUDA
- // pack must be selected before its first ONNX Runtime import, including
- // in the embedding and speech workers.
- output: {
- banner: readFileSync(resolve('scripts/windows-onnx-cuda-preload.cjs'), 'utf8')
- },
input: {
index: resolve('src/main/index.ts'),
'embeddings-worker': resolve('src/main/embeddings-worker.ts'),
diff --git a/electron/accessibility/main.swift b/electron/accessibility/main.swift
deleted file mode 100644
index e7f4dc59a..000000000
--- a/electron/accessibility/main.swift
+++ /dev/null
@@ -1,111 +0,0 @@
-import Cocoa
-import ApplicationServices
-
-// Model for JSON Output
-struct WindowContext: Codable {
- let appName: String
- let title: String
- let selectedText: String
- let content: String
- let isTrusted: Bool
-}
-
-func getAttribute(element: AXUIElement, attribute: String) -> CFTypeRef? {
- var value: CFTypeRef?
- let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
- if result == .success {
- return value
- }
- return nil
-}
-
-func getFocusedElement() -> WindowContext? {
- let systemWide = AXUIElementCreateSystemWide()
-
- // Check trust
- if !AXIsProcessTrusted() {
- return WindowContext(appName: "Unknown", title: "Permission Denied", selectedText: "", content: "", isTrusted: false)
- }
- // print("DEBUG: Process trusted")
-
- // Get focused app
- var appElement: AXUIElement!
- var appName = "Unknown"
-
- // Try NSWorkspace first (more robust)
- if let frontApp = NSWorkspace.shared.frontmostApplication {
- appName = frontApp.localizedName ?? "Unknown"
- appElement = AXUIElementCreateApplication(frontApp.processIdentifier)
- } else {
- // Fallback to AXSystemWide
- guard let focusedApp = getAttribute(element: systemWide, attribute: kAXFocusedApplicationAttribute) else {
- return nil
- }
- appElement = (focusedApp as! AXUIElement)
- }
-
- // Get focused UI Element directly from the app element (more reliable than system-wide)
- guard let focusedElementRef = getAttribute(element: appElement, attribute: kAXFocusedUIElementAttribute) else {
-
- // Fallback: Try to get the focused window if specific element fails
- if let windowRef = getAttribute(element: appElement, attribute: kAXFocusedWindowAttribute) {
- // We can at least get the title from the window
- let window = windowRef as! AXUIElement
- var windowTitle = ""
- if let title = getAttribute(element: window, attribute: kAXTitleAttribute) as? String {
- windowTitle = title
- }
- // Return just the window info if we can't get the element
- return WindowContext(appName: appName, title: windowTitle, selectedText: "", content: "", isTrusted: true)
- }
-
- return nil
- }
- let focusedElement = focusedElementRef as! AXUIElement
-
- // Try to get Value (content) or Selected Text
- var selectedText = ""
- var content = ""
-
- if let selected = getAttribute(element: focusedElement, attribute: kAXSelectedTextAttribute) as? String {
- selectedText = selected
- }
-
- if let val = getAttribute(element: focusedElement, attribute: kAXValueAttribute) as? String {
- content = val
- }
-
- // Get Window Title
- var windowTitle = ""
- if let window = getAttribute(element: focusedElement, attribute: kAXWindowAttribute) {
- let windowElem = window as! AXUIElement
- if let title = getAttribute(element: windowElem, attribute: kAXTitleAttribute) as? String {
- windowTitle = title
- }
- }
- // print("DEBUG: Found context for \(appName)")
-
- return WindowContext(appName: appName, title: windowTitle, selectedText: selectedText, content: content, isTrusted: true)
-}
-
-// Main Loop
-setbuf(stdout, nil) // Unbuffer stdout
-
-let timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in
- if let context = getFocusedElement() {
- // Relaxed condition: Print if we have at least an app name
- if !context.appName.isEmpty {
- let encoder = JSONEncoder()
- if let data = try? encoder.encode(context), let json = String(data: data, encoding: .utf8) {
- print(json)
- }
- }
- } else {
- // Check permissions silently?
- if !AXIsProcessTrusted() {
- print("{\"isTrusted\": false}")
- }
- }
-}
-
-RunLoop.main.run()
diff --git a/package-lock.json b/package-lock.json
index 39d5dcb96..97003da4e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,12 +12,10 @@
"dependencies": {
"@base-ui/react": "1.8.0",
"@dnd-kit/core": "^6.3.1",
- "@dnd-kit/sortable": "^10.0.0",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@gutenye/ocr-node": "^1.4.9",
"@huggingface/transformers": "4.3.1",
- "@lancedb/lancedb": "^0.40.0",
"@langchain/core": "^1.2.9",
"@langchain/langgraph": "^1.4.12",
"@modelcontextprotocol/sdk": "^1.29.0",
@@ -37,14 +35,11 @@
"@tabler/icons-react": "^3.36.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-virtual": "3.14.13",
- "apache-arrow": "^18.1.0",
"async-mutex": "^0.5.0",
"better-sqlite3": "^12.6.2",
"better-sqlite3-multiple-ciphers": "^12.11.1",
"bonjour-service": "^1.4.3",
- "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "cmdk": "^1.1.1",
"electron-updater": "^6.8.9",
"get-windows": "^9.3.0",
"hash-wasm": "^4.12.0",
@@ -3686,172 +3681,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@lancedb/lancedb": {
- "version": "0.40.0",
- "resolved": "https://registry.npmjs.org/@lancedb/lancedb/-/lancedb-0.40.0.tgz",
- "integrity": "sha512-hCvk6y/gHARQ++NarvguEmX02Hm2zJAUJQgf1Sq8KznBKjI21xsK5YYzw2L0eUArDWxPjtLl4kD329ymLMqLew==",
- "cpu": [
- "x64",
- "arm64"
- ],
- "license": "Apache-2.0",
- "os": [
- "darwin",
- "linux",
- "win32"
- ],
- "dependencies": {
- "@opentelemetry/api": "^1.9.0",
- "reflect-metadata": "^0.2.2"
- },
- "engines": {
- "node": ">= 22"
- },
- "optionalDependencies": {
- "@huggingface/transformers": "3.0.2",
- "@lancedb/lancedb-darwin-arm64": "0.40.0",
- "@lancedb/lancedb-linux-arm64-gnu": "0.40.0",
- "@lancedb/lancedb-linux-arm64-musl": "0.40.0",
- "@lancedb/lancedb-linux-x64-gnu": "0.40.0",
- "@lancedb/lancedb-linux-x64-musl": "0.40.0",
- "@lancedb/lancedb-win32-arm64-msvc": "0.40.0",
- "@lancedb/lancedb-win32-x64-msvc": "0.40.0",
- "openai": "4.29.2"
- },
- "peerDependencies": {
- "@types/node": ">=22",
- "apache-arrow": ">=15.0.0 <=18.1.0"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- }
- }
- },
- "node_modules/@lancedb/lancedb-darwin-arm64": {
- "version": "0.40.0",
- "resolved": "https://registry.npmjs.org/@lancedb/lancedb-darwin-arm64/-/lancedb-darwin-arm64-0.40.0.tgz",
- "integrity": "sha512-m/DqH8BUHPXTHQFpvhBfeAYZnrI9aJrz5lVVaQNefhcJO9ULIDk36isKjh6sM6owr29SemXjprbJFfcEskxylA==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/@lancedb/lancedb-linux-arm64-gnu": {
- "version": "0.40.0",
- "resolved": "https://registry.npmjs.org/@lancedb/lancedb-linux-arm64-gnu/-/lancedb-linux-arm64-gnu-0.40.0.tgz",
- "integrity": "sha512-O9CIe1RNqbHZujn4c8DMTsXJQ+eHTWn5ilTilRbm9ENsnAk02SgMYr2AH+hargTAjsXOwrWKbUogRbRi0VridA==",
- "cpu": [
- "arm64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/@lancedb/lancedb-linux-arm64-musl": {
- "version": "0.40.0",
- "resolved": "https://registry.npmjs.org/@lancedb/lancedb-linux-arm64-musl/-/lancedb-linux-arm64-musl-0.40.0.tgz",
- "integrity": "sha512-ttaeDklDfIkpK0fzaz1+SwH01jtaGU15jgPaPn9Dzv9AfpXoYlT7F3FDsa95BH23OSOath2kx6LougodU76lew==",
- "cpu": [
- "arm64"
- ],
- "libc": [
- "musl"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/@lancedb/lancedb-linux-x64-gnu": {
- "version": "0.40.0",
- "resolved": "https://registry.npmjs.org/@lancedb/lancedb-linux-x64-gnu/-/lancedb-linux-x64-gnu-0.40.0.tgz",
- "integrity": "sha512-Y7lQkz7uuQvKsZla85YNdhz6oPaY3fpzNLeNuNmHb8cEnLH8ArlbpOL/Bbdlp5sBmv6rE4lDYArn6IeBX8OAJQ==",
- "cpu": [
- "x64"
- ],
- "libc": [
- "glibc"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/@lancedb/lancedb-linux-x64-musl": {
- "version": "0.40.0",
- "resolved": "https://registry.npmjs.org/@lancedb/lancedb-linux-x64-musl/-/lancedb-linux-x64-musl-0.40.0.tgz",
- "integrity": "sha512-Ohj8J+iZ9QHXn5DbVacHlGR7JBNyqXdREXJDuKK7rB8zHXFMi3/uRGQQxy6XPBRFeuE0/wnIcgOLkiN1as1AwA==",
- "cpu": [
- "x64"
- ],
- "libc": [
- "musl"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/@lancedb/lancedb-win32-arm64-msvc": {
- "version": "0.40.0",
- "resolved": "https://registry.npmjs.org/@lancedb/lancedb-win32-arm64-msvc/-/lancedb-win32-arm64-msvc-0.40.0.tgz",
- "integrity": "sha512-8gzFTWB4KoTkuEReqiYy4TmT8uQEOMzMoKJlxQhayjB5dsAGk99VucbgL4gw5a2buazmD6QoW1LkMYGngXpoNQ==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/@lancedb/lancedb-win32-x64-msvc": {
- "version": "0.40.0",
- "resolved": "https://registry.npmjs.org/@lancedb/lancedb-win32-x64-msvc/-/lancedb-win32-x64-msvc-0.40.0.tgz",
- "integrity": "sha512-DZxho/MhnGJCQc98CN3yY8qWlDCATLTjGUNqD58Fx1sNMctmkbdAf4F1fTRahz0aojfnM9aoUNaSXdY0qaPhbQ==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 18"
- }
- },
"node_modules/@langchain/core": {
"version": "1.2.17",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.17.tgz",
@@ -8054,15 +7883,6 @@
"react": "^18.0.0 || ^19.0.0"
}
},
- "node_modules/@swc/helpers": {
- "version": "0.5.23",
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
- "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.8.0"
- }
- },
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
@@ -8637,18 +8457,6 @@
"assertion-error": "^2.0.1"
}
},
- "node_modules/@types/command-line-args": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz",
- "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==",
- "license": "MIT"
- },
- "node_modules/@types/command-line-usage": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz",
- "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==",
- "license": "MIT"
- },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -9016,17 +8824,6 @@
"undici-types": "~6.21.0"
}
},
- "node_modules/@types/node-fetch": {
- "version": "2.6.13",
- "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
- "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@types/node": "*",
- "form-data": "^4.0.4"
- }
- },
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
@@ -9769,19 +9566,6 @@
"node": ">= 14"
}
},
- "node_modules/agentkeepalive": {
- "version": "4.6.0",
- "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
- "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "humanize-ms": "^1.2.1"
- },
- "engines": {
- "node": ">= 8.0.0"
- }
- },
"node_modules/aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -9861,6 +9645,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -9879,35 +9664,6 @@
"license": "MIT",
"optional": true
},
- "node_modules/apache-arrow": {
- "version": "18.1.0",
- "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-18.1.0.tgz",
- "integrity": "sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==",
- "license": "Apache-2.0",
- "dependencies": {
- "@swc/helpers": "^0.5.11",
- "@types/command-line-args": "^5.2.3",
- "@types/command-line-usage": "^5.0.4",
- "@types/node": "^20.13.0",
- "command-line-args": "^5.2.1",
- "command-line-usage": "^7.0.1",
- "flatbuffers": "^24.3.25",
- "json-bignum": "^0.0.3",
- "tslib": "^2.6.2"
- },
- "bin": {
- "arrow2csv": "bin/arrow2csv.js"
- }
- },
- "node_modules/apache-arrow/node_modules/@types/node": {
- "version": "20.19.43",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
- "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
- "license": "MIT",
- "dependencies": {
- "undici-types": "~6.21.0"
- }
- },
"node_modules/app-builder-lib": {
"version": "26.16.1",
"resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.16.1.tgz",
@@ -10244,15 +10000,6 @@
"dequal": "^2.0.3"
}
},
- "node_modules/array-back": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
- "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -10565,12 +10312,6 @@
"devOptional": true,
"license": "MIT"
},
- "node_modules/base-64": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz",
- "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==",
- "optional": true
- },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -11224,6 +10965,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
@@ -11236,21 +10978,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/chalk-template": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz",
- "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==",
- "license": "MIT",
- "dependencies": {
- "chalk": "^4.1.2"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk-template?sponsor=1"
- }
- },
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -11291,16 +11018,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/charenc": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
- "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
- "license": "BSD-3-Clause",
- "optional": true,
- "engines": {
- "node": "*"
- }
- },
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -11442,6 +11159,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -11454,6 +11172,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "devOptional": true,
"license": "MIT"
},
"node_modules/color-support": {
@@ -11489,54 +11208,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/command-line-args": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz",
- "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==",
- "license": "MIT",
- "dependencies": {
- "array-back": "^3.1.0",
- "find-replace": "^3.0.0",
- "lodash.camelcase": "^4.3.0",
- "typical": "^4.0.0"
- },
- "engines": {
- "node": ">=4.0.0"
- }
- },
- "node_modules/command-line-usage": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz",
- "integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==",
- "license": "MIT",
- "dependencies": {
- "array-back": "^6.2.2",
- "chalk-template": "^0.4.0",
- "table-layout": "^4.1.1",
- "typical": "^7.3.0"
- },
- "engines": {
- "node": ">=12.20.0"
- }
- },
- "node_modules/command-line-usage/node_modules/array-back": {
- "version": "6.2.3",
- "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz",
- "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==",
- "license": "MIT",
- "engines": {
- "node": ">=12.17"
- }
- },
- "node_modules/command-line-usage/node_modules/typical": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz",
- "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==",
- "license": "MIT",
- "engines": {
- "node": ">=12.17"
- }
- },
"node_modules/commander": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
@@ -11679,16 +11350,6 @@
"node": ">= 8"
}
},
- "node_modules/crypt": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
- "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
- "license": "BSD-3-Clause",
- "optional": true,
- "engines": {
- "node": "*"
- }
- },
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -12591,17 +12252,6 @@
"node": ">=0.3.1"
}
},
- "node_modules/digest-fetch": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz",
- "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==",
- "license": "ISC",
- "optional": true,
- "dependencies": {
- "base-64": "^0.1.0",
- "md5": "^2.3.0"
- }
- },
"node_modules/dingbat-to-unicode": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.2.tgz",
@@ -14372,18 +14022,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/find-replace": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz",
- "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==",
- "license": "MIT",
- "dependencies": {
- "array-back": "^3.0.1"
- },
- "engines": {
- "node": ">=4.0.0"
- }
- },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -14415,12 +14053,6 @@
"node": ">=16"
}
},
- "node_modules/flatbuffers": {
- "version": "24.12.23",
- "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-24.12.23.tgz",
- "integrity": "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==",
- "license": "Apache-2.0"
- },
"node_modules/flatted": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
@@ -14512,13 +14144,6 @@
"node": ">= 6"
}
},
- "node_modules/form-data-encoder": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
- "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
- "license": "MIT",
- "optional": true
- },
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -14559,30 +14184,6 @@
"node": ">=18.14.0"
}
},
- "node_modules/formdata-node": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
- "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "node-domexception": "1.0.0",
- "web-streams-polyfill": "4.0.0-beta.3"
- },
- "engines": {
- "node": ">= 12.20"
- }
- },
- "node_modules/formdata-node/node_modules/web-streams-polyfill": {
- "version": "4.0.0-beta.3",
- "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
- "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">= 14"
- }
- },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -15295,6 +14896,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -15845,16 +15447,6 @@
"node": ">= 14"
}
},
- "node_modules/humanize-ms": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
- "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "ms": "^2.0.0"
- }
- },
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -16162,13 +15754,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-buffer": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
- "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
- "license": "MIT",
- "optional": true
- },
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -16963,14 +16548,6 @@
"node": ">=6"
}
},
- "node_modules/json-bignum": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz",
- "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==",
- "engines": {
- "node": ">=0.8"
- }
- },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -17587,12 +17164,6 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
- "node_modules/lodash.camelcase": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
- "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
- "license": "MIT"
- },
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
@@ -17880,18 +17451,6 @@
"node": ">= 0.4"
}
},
- "node_modules/md5": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
- "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
- "license": "BSD-3-Clause",
- "optional": true,
- "dependencies": {
- "charenc": "0.0.2",
- "crypt": "0.0.2",
- "is-buffer": "~1.1.6"
- }
- },
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz",
@@ -19498,27 +19057,6 @@
"node": ">=10"
}
},
- "node_modules/node-domexception": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
- "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
- "deprecated": "Use your platform's native DOMException instead",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/jimmywarting"
- },
- {
- "type": "github",
- "url": "https://paypal.me/jimmywarting"
- }
- ],
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=10.5.0"
- }
- },
"node_modules/node-exports-info": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
@@ -20053,44 +19591,6 @@
"integrity": "sha512-gBuF6U32YErKIAt+yD7DeGBpjRxhJ1uJwako6ygjokSZULDU6Pd+KWComX8Tumk1hYVHRxjPj7mdMnD0ryAPOw==",
"license": "MIT"
},
- "node_modules/openai": {
- "version": "4.29.2",
- "resolved": "https://registry.npmjs.org/openai/-/openai-4.29.2.tgz",
- "integrity": "sha512-cPkT6zjEcE4qU5OW/SoDDuXEsdOLrXlAORhzmaguj5xZSPlgKvLhi27sFWhLKj07Y6WKNWxcwIbzm512FzTBNQ==",
- "license": "Apache-2.0",
- "optional": true,
- "dependencies": {
- "@types/node": "^18.11.18",
- "@types/node-fetch": "^2.6.4",
- "abort-controller": "^3.0.0",
- "agentkeepalive": "^4.2.1",
- "digest-fetch": "^1.3.0",
- "form-data-encoder": "1.7.2",
- "formdata-node": "^4.3.2",
- "node-fetch": "^2.6.7",
- "web-streams-polyfill": "^3.2.1"
- },
- "bin": {
- "openai": "bin/cli"
- }
- },
- "node_modules/openai/node_modules/@types/node": {
- "version": "18.19.130",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
- "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "undici-types": "~5.26.4"
- }
- },
- "node_modules/openai/node_modules/undici-types": {
- "version": "5.26.5",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
- "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
- "license": "MIT",
- "optional": true
- },
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
@@ -21827,12 +21327,6 @@
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
- "node_modules/reflect-metadata": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
- "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
- "license": "Apache-2.0"
- },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -23419,6 +22913,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -23470,28 +22965,6 @@
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"license": "MIT"
},
- "node_modules/table-layout": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz",
- "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==",
- "license": "MIT",
- "dependencies": {
- "array-back": "^6.2.2",
- "wordwrapjs": "^5.1.0"
- },
- "engines": {
- "node": ">=12.17"
- }
- },
- "node_modules/table-layout/node_modules/array-back": {
- "version": "6.2.3",
- "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz",
- "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==",
- "license": "MIT",
- "engines": {
- "node": ">=12.17"
- }
- },
"node_modules/tailwind-merge": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz",
@@ -24151,15 +23624,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
- "node_modules/typical": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz",
- "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/unbash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/unbash/-/unbash-5.1.0.tgz",
@@ -25332,16 +24796,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/web-streams-polyfill": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
- "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/webcrypto-core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
@@ -25545,15 +24999,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/wordwrapjs": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz",
- "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==",
- "license": "MIT",
- "engines": {
- "node": ">=12.17"
- }
- },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
diff --git a/package.json b/package.json
index 57033730e..8e9a073db 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,7 @@
"dev:linked": "OPERATOR_UI_SRC=../ui-library node scripts/dev.mjs",
"dev:inner": "node scripts/stage-native.mjs && electron-vite dev",
"gateway": "node scripts/stage-native.mjs && OFFGRID_SERVER_ONLY=1 electron-vite dev",
- "build": "node scripts/stage-native.mjs && npm run typecheck && electron-vite build && node scripts/check-windows-onnx-preload.mjs",
+ "build": "node scripts/stage-native.mjs && npm run typecheck && electron-vite build",
"postinstall": "node scripts/patch-windows-onnx-cuda.mjs && electron-builder install-app-deps",
"prepare:speech-defaults": "node scripts/prepare-default-speech.mjs",
"build:unpack": "npm run build && npm run prepare:speech-defaults && electron-builder --dir",
@@ -57,11 +57,8 @@
"knip": "knip --no-gitignore",
"coverage:all": "bash scripts/coverage-all.sh"
},
- "//overrides": "ONE sharp for the whole tree. sharp ships libvips as `libvips-42.dll`, and Windows resolves a DLL by NAME across the process: whichever copy loads first wins for every later binding. The embedding runtime can load sharp before our own sharp 0.35; two libvips versions in one Windows process then fail with ERR_DLOPEN_FAILED. Every file attachment failed on Windows and only there because macOS binds by path. Proven by load order: sharp alone loads; transformers-then-sharp does not. Deduping to one version is the fix, not load-order juggling. The same holds for ONNX Runtime: LanceDB 0.40 lists Transformers.js 3.0.2 (ONNX Runtime 1.19, about 320 MB) as an optional dependency for an embedding helper the app never calls, so it shares the app's one Transformers.js.",
+ "//overrides": "ONE sharp for the whole tree. sharp ships libvips as `libvips-42.dll`, and Windows resolves a DLL by NAME across the process: whichever copy loads first wins for every later binding. The embedding runtime can load sharp before our own sharp 0.35; two libvips versions in one Windows process then fail with ERR_DLOPEN_FAILED. Every file attachment failed on Windows and only there because macOS binds by path. Proven by load order: sharp alone loads; transformers-then-sharp does not. Deduping to one version is the fix, not load-order juggling.",
"overrides": {
- "@lancedb/lancedb": {
- "@huggingface/transformers": "$@huggingface/transformers"
- },
"kokoro-js": {
"@huggingface/transformers": "$@huggingface/transformers"
},
@@ -71,12 +68,10 @@
"dependencies": {
"@base-ui/react": "1.8.0",
"@dnd-kit/core": "^6.3.1",
- "@dnd-kit/sortable": "^10.0.0",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@gutenye/ocr-node": "^1.4.9",
"@huggingface/transformers": "4.3.1",
- "@lancedb/lancedb": "^0.40.0",
"@langchain/core": "^1.2.9",
"@langchain/langgraph": "^1.4.12",
"@modelcontextprotocol/sdk": "^1.29.0",
@@ -96,14 +91,11 @@
"@tabler/icons-react": "^3.36.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-virtual": "3.14.13",
- "apache-arrow": "^18.1.0",
"async-mutex": "^0.5.0",
"better-sqlite3": "^12.6.2",
"better-sqlite3-multiple-ciphers": "^12.11.1",
"bonjour-service": "^1.4.3",
- "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "cmdk": "^1.1.1",
"electron-updater": "^6.8.9",
"get-windows": "^9.3.0",
"hash-wasm": "^4.12.0",
diff --git a/pro b/pro
index fab65c48a..71203914e 160000
--- a/pro
+++ b/pro
@@ -1 +1 @@
-Subproject commit fab65c48a997c8b04d9dd1a5ba67ac5d275ad539
+Subproject commit 71203914e145c4e91a292760d907a1309b0192bf
diff --git a/scripts/build-llama-linux.sh b/scripts/build-llama-linux.sh
index 951a6af1f..3e105bf20 100644
--- a/scripts/build-llama-linux.sh
+++ b/scripts/build-llama-linux.sh
@@ -113,6 +113,11 @@ cp "$SOURCE/LICENSE" "$DEST/"
GOMP="$(ldconfig -p | awk '/libgomp\.so\.1 / && !found { found=$NF } END { print found }')"
test -n "$GOMP"
cp -L "$GOMP" "$DEST/libgomp.so.1"
+if [ "$ACCELERATOR" = vulkan ]; then
+ VULKAN="$(ldconfig -p | awk '$1 == "libvulkan.so.1" && /x86-64/ && !found { found=$NF } END { print found }')"
+ test -n "$VULKAN"
+ install -m 755 "$(readlink -f "$VULKAN")" "$DEST/libvulkan.so.1"
+fi
chmod +x "$DEST/llama-server"
file "$DEST/llama-server" | grep -q 'ELF 64-bit.*x86-64'
diff --git a/scripts/build-sd-mac.sh b/scripts/build-sd-mac.sh
new file mode 100644
index 000000000..da2e427a6
--- /dev/null
+++ b/scripts/build-sd-mac.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Preserve master-889-c678dfe and the existing universal three-file payload.
+REF=c678dfe704a2230342376b46add9c8ca736a653d
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT="${OFFGRID_BUILD_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
+DEST="$ROOT/resources/bin/sd"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+[ "$(uname -s)" = Darwin ] || { echo '[build-sd-mac] macOS is required' >&2; exit 1; }
+
+git clone --no-checkout https://github.com/leejet/stable-diffusion.cpp "$WORK/src"
+git -C "$WORK/src" checkout --detach "$REF"
+git -C "$WORK/src" submodule update --init --recursive
+test "$(git -C "$WORK/src" rev-parse HEAD)" = "$REF"
+# SD_BUILD_SHARED_GGML_LIB=OFF keeps GGML inside the one existing SD dylib.
+cmake -S "$WORK/src" -B "$WORK/build" -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_OSX_DEPLOYMENT_TARGET=13.0 -DCMAKE_OSX_ARCHITECTURES='arm64;x86_64' \
+ -DSD_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_NATIVE=OFF \
+ -DGGML_OPENMP=OFF -DSD_BUILD_SHARED_LIBS=ON -DSD_BUILD_SHARED_GGML_LIB=OFF \
+ -DSD_BUILD_EXAMPLES=ON -DCMAKE_DISABLE_FIND_PACKAGE_OpenSSL=ON \
+ -DCMAKE_DISABLE_FIND_PACKAGE_CURL=ON
+cmake --build "$WORK/build" --config Release -j"$(sysctl -n hw.ncpu)" \
+ --target sd-cli sd-server stable-diffusion
+
+mkdir -p "$WORK/stage"
+for name in sd-cli sd-server libstable-diffusion.dylib; do
+ source="$(find "$WORK/build" -name "$name" -type f -print -quit)"
+ test -n "$source"
+ cp "$source" "$WORK/stage/$name"
+ lipo "$WORK/stage/$name" -verify_arch arm64 x86_64
+done
+for file in "$WORK/stage/"*; do
+ while IFS= read -r rpath; do
+ install_name_tool -delete_rpath "$rpath" "$file"
+ done < <(otool -l "$file" | awk '/cmd LC_RPATH/{found=1; next} found && $1 == "path" {print $2; found=0}' | sort -u)
+ install_name_tool -add_rpath @loader_path "$file"
+ while IFS= read -r dep; do
+ case "$dep" in
+ /System/Library/*|/usr/lib/*) ;;
+ *)
+ name="$(basename "$dep")"
+ if [ ! -f "$WORK/stage/$name" ]; then
+ echo "[build-sd-mac] unbundled dependency: $file -> $dep" >&2
+ exit 1
+ fi
+ install_name_tool -change "$dep" "@loader_path/$name" "$file"
+ ;;
+ esac
+ done < <(otool -L "$file" | awk '/compatibility version/ {print $1}' | sort -u)
+done
+install_name_tool -id @loader_path/libstable-diffusion.dylib "$WORK/stage/libstable-diffusion.dylib"
+node "$SCRIPT_DIR/check-macos-minos.mjs" 13.0 "$WORK/stage"
+rm -rf "$DEST"
+mkdir -p "$DEST"
+cp "$WORK/stage/"* "$DEST/"
+node "$SCRIPT_DIR/check-macos-minos.mjs" 13.0 "$DEST"
+echo '[build-sd-mac] staged universal SD engine for macOS 13+'
diff --git a/scripts/build-voice-linux.sh b/scripts/build-voice-linux.sh
index d4d483921..c79f142d4 100644
--- a/scripts/build-voice-linux.sh
+++ b/scripts/build-voice-linux.sh
@@ -39,7 +39,14 @@ if [ "${OFFGRID_BUILD_VOICE_FROM_SOURCE:-0}" != 1 ] &&
tar -xzf "$VOICE_WORK/$VOICE_ARCHIVE" -C "$ROOT/build/linux-bin" --no-same-owner
OFFGRID_REUSE_STAGED_VOICE=1
fi
+stage_vulkan_loader() {
+ local source
+ source="$(ldconfig -p | awk '$1 == "libvulkan.so.1" && /x86-64/ && !found { found=$NF } END { print found }')"
+ test -n "$source"
+ install -m 755 "$(readlink -f "$source")" "$ROOT/build/linux-bin/whisper/libvulkan.so.1"
+}
if [ "${OFFGRID_REUSE_STAGED_VOICE:-0}" = 1 ]; then
+ stage_vulkan_loader
for binary in "$ROOT/build/linux-bin/whisper-cuda/whisper-cli" \
"$ROOT/build/linux-bin/whisper/whisper-cli" \
"$ROOT/build/linux-bin/whisper-cpu/whisper-cli" \
@@ -151,6 +158,7 @@ mkdir -p "$BIN/whisper-cuda" "$BIN/whisper" "$BIN/whisper-cpu" "$BIN/licenses"
install -m 755 "$WHISPER_CUDA_BIN" "$BIN/whisper-cuda/whisper-cli"
install -m 755 "$WHISPER_VULKAN_BIN" "$BIN/whisper/whisper-cli"
install -m 755 "$WHISPER_CPU_BIN" "$BIN/whisper-cpu/whisper-cli"
+stage_vulkan_loader
install -m 644 "$WORK/whisper/LICENSE" "$BIN/whisper/LICENSE"
install -m 755 "$WORK/$FFMPEG_DIR/bin/ffmpeg" "$BIN/ffmpeg"
install -m 644 "$WORK/$FFMPEG_DIR/LICENSE.txt" "$BIN/licenses/ffmpeg.txt"
diff --git a/scripts/build-whisper-vulkan-win.ps1 b/scripts/build-whisper-vulkan-win.ps1
new file mode 100644
index 000000000..be48b40d0
--- /dev/null
+++ b/scripts/build-whisper-vulkan-win.ps1
@@ -0,0 +1,35 @@
+# Match the pinned Windows whisper download in fetch-win-binaries.ps1.
+$ErrorActionPreference = 'Stop'
+$WhisperRef = 'b5130'
+$WhisperCommit = '927cfce34f31707e17f2bff35c349632fb9e2c3a'
+if (-not $env:VULKAN_SDK -or -not (Test-Path "$env:VULKAN_SDK\Bin\glslc.exe")) {
+ throw 'The Vulkan SDK with glslc is required to build Windows Whisper'
+}
+. "$PSScriptRoot\config\windows-vc-runtime.ps1"
+$dest = Join-Path $PSScriptRoot '..\resources\bin\whisper-vulkan'
+$tmpBase = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
+$work = Join-Path $tmpBase ("whisper-vulkan-" + [guid]::NewGuid().ToString('N'))
+try {
+ git clone --depth 1 --branch $WhisperRef https://github.com/ggml-org/whisper.cpp "$work\src"
+ if ($LASTEXITCODE -ne 0) { throw 'Whisper clone failed' }
+ $commit = git -C "$work\src" rev-parse HEAD
+ if ($LASTEXITCODE -ne 0 -or $commit -ne $WhisperCommit) { throw 'Whisper source commit mismatch' }
+ cmake -S "$work\src" -B "$work\build" -A x64 `
+ -DGGML_VULKAN=ON -DWHISPER_BUILD_SERVER=ON -DCMAKE_BUILD_TYPE=Release `
+ -DBUILD_SHARED_LIBS=ON -DGGML_NATIVE=OFF -DGGML_CUDA=OFF `
+ -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON
+ if ($LASTEXITCODE -ne 0) { throw 'Whisper CMake configuration failed' }
+ cmake --build "$work\build" --config Release --parallel 4 --target whisper-cli whisper-server
+ if ($LASTEXITCODE -ne 0) { throw 'Whisper Vulkan build failed' }
+ if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
+ New-Item -ItemType Directory -Force -Path $dest | Out-Null
+ Get-ChildItem "$work\build" -Recurse -File |
+ Where-Object { $_.Name -in @('whisper-cli.exe', 'whisper-server.exe') -or $_.Extension -eq '.dll' } |
+ Copy-Item -Destination $dest -Force
+ Copy-VCRuntime $dest
+ foreach ($name in @('whisper-cli.exe', 'whisper-server.exe')) {
+ if (-not (Test-Path "$dest\$name")) { throw "Whisper Vulkan output missing: $name" }
+ }
+} finally {
+ if (Test-Path $work) { Remove-Item -Recurse -Force $work }
+}
diff --git a/scripts/check-macos-minos.mjs b/scripts/check-macos-minos.mjs
new file mode 100644
index 000000000..89c947c09
--- /dev/null
+++ b/scripts/check-macos-minos.mjs
@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { execFileSync } from 'node:child_process'
+
+const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const [maximum = '13.0', ...requestedRoots] = process.argv.slice(2)
+if (!/^\d+(?:\.\d+){0,2}$/.test(maximum)) throw new Error('Expected a macOS version, e.g. 13.0')
+const folderOverrides = new Map([
+ // Core ML image generation uses macOS 14 Core ML APIs.
+ ['coreml-sd', '14.0'],
+ // The Kev MLX runtime needs macOS 14; the app hides Kev on older macOS.
+ ['kev-runtime', '14.0']
+])
+const roots = requestedRoots.length
+ ? requestedRoots.map((root) => path.resolve(root))
+ : [
+ path.join(repo, 'resources/bin'),
+ ...[path.resolve(repo, '../executorch-speech/native/bin')].filter((root) =>
+ fs.existsSync(root)
+ )
+ ]
+const magicNumbers = new Set([
+ 'feedface',
+ 'cefaedfe',
+ 'feedfacf',
+ 'cffaedfe',
+ 'cafebabe',
+ 'bebafeca',
+ 'cafebabf',
+ 'bfbafeca'
+])
+const visited = new Set()
+const failures = []
+let checked = 0
+function exceeds(version, target) {
+ const actual = version.split('.').map(Number)
+ const allowed = target.split('.').map(Number)
+ for (let i = 0; i < Math.max(actual.length, allowed.length); i++) {
+ if ((actual[i] ?? 0) !== (allowed[i] ?? 0)) return (actual[i] ?? 0) > (allowed[i] ?? 0)
+ }
+ return false
+}
+function walk(file, root) {
+ const real = fs.realpathSync(file)
+ if (visited.has(real)) return
+ visited.add(real)
+ const stat = fs.statSync(file)
+ if (stat.isDirectory()) {
+ for (const name of fs.readdirSync(file)) walk(path.join(file, name), root)
+ return
+ }
+ if (!stat.isFile()) return
+ const header = Buffer.alloc(4)
+ const fd = fs.openSync(file, 'r')
+ try {
+ fs.readSync(fd, header, 0, 4, 0)
+ } finally {
+ fs.closeSync(fd)
+ }
+ if (!magicNumbers.has(header.toString('hex'))) return
+ checked++
+ const folder = path.relative(root, file).split(path.sep)[0]
+ const limit = folderOverrides.get(folder) ?? maximum
+ try {
+ // Inspect all universal slices, including legacy LC_VERSION_MIN_MACOSX commands.
+ const output = execFileSync('otool', ['-arch', 'all', '-l', file], {
+ encoding: 'utf8',
+ maxBuffer: 16 * 1024 * 1024
+ })
+ const versions = [
+ ...output.matchAll(
+ /cmd LC_BUILD_VERSION\s[\s\S]*?\bminos\s+(\d+(?:\.\d+)*)|cmd LC_VERSION_MIN_MACOSX\s[\s\S]*?\bversion\s+(\d+(?:\.\d+)*)/g
+ )
+ ].map((match) => match[1] ?? match[2])
+ if (!versions.length) failures.push(`${file}: no macOS minimum version found`)
+ for (const version of new Set(versions)) {
+ if (exceeds(version, limit)) failures.push(`${file}: minos ${version} exceeds ${limit}`)
+ }
+ } catch (error) {
+ failures.push(`${file}: cannot inspect Mach-O (${error.message})`)
+ }
+}
+for (const root of roots) walk(root, root)
+if (!checked) failures.push('No Mach-O files found')
+if (failures.length) {
+ console.error(failures.join('\n'))
+ process.exitCode = 1
+} else {
+ console.log(`[macos-minos] checked ${checked} Mach-O files (default maximum ${maximum})`)
+}
diff --git a/scripts/check-windows-onnx-preload.mjs b/scripts/check-windows-onnx-preload.mjs
deleted file mode 100644
index 7fcec5eff..000000000
--- a/scripts/check-windows-onnx-preload.mjs
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/usr/bin/env node
-// Verify the Windows pack bootstrap executes before Rollup's hoisted ONNX import.
-import fs from 'node:fs'
-import path from 'node:path'
-
-if (process.platform !== 'win32') process.exit(0)
-
-for (const name of ['index.js', 'embeddings-worker.js', 'tts-onnx-worker.js']) {
- const file = path.join(import.meta.dirname, '..', 'out', 'main', name)
- const source = fs.readFileSync(file, 'utf8')
- const bootstrap = source.indexOf('process.arch === "x64"')
- const manifest = source.indexOf('performance-packs.json')
- const onnx = source.indexOf('require("@huggingface/transformers")')
- if (bootstrap < 0 || manifest < 0 || onnx < 0 || bootstrap >= onnx || manifest >= onnx) {
- throw new Error(`Windows ONNX bootstrap does not precede external import in ${name}`)
- }
-}
-console.log('Windows ONNX bootstrap precedes external imports in all three main entries')
diff --git a/scripts/config/windows-vc-runtime.ps1 b/scripts/config/windows-vc-runtime.ps1
new file mode 100644
index 000000000..4a69969dc
--- /dev/null
+++ b/scripts/config/windows-vc-runtime.ps1
@@ -0,0 +1,25 @@
+# App-local VC++ runtime for each native executable, including optional engines.
+function Copy-VCRuntime($Destination) {
+ $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
+ if (-not (Test-Path $vswhere)) { throw "VC++ redist not found: vswhere is missing at $vswhere" }
+ $installation = & $vswhere -latest -products '*' -property installationPath
+ if ($LASTEXITCODE -ne 0 -or -not $installation) { throw 'VC++ redist not found: install Visual Studio C++ build tools' }
+ $redistRoot = Join-Path $installation 'VC\Redist\MSVC'
+ if (-not (Test-Path $redistRoot)) { throw "VC++ redist folder not found: $redistRoot" }
+ $version = Get-ChildItem $redistRoot -Directory |
+ Where-Object { $_.Name -match '^\d+\.\d+\.\d+$' } |
+ Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1
+ if (-not $version) { throw "VC++ redist version folder not found: $redistRoot" }
+ $x64 = Join-Path $version.FullName 'x64'
+ if (-not (Test-Path $x64)) { throw "VC++ x64 redist folder not found: $x64" }
+ $folders = @(Get-ChildItem $x64 -Directory | Where-Object { $_.Name -match '^Microsoft\.VC14.*\.(CRT|OpenMP)$' })
+ if ($folders.Count -lt 2) { throw "VC++ CRT/OpenMP redist folders not found: $x64" }
+ foreach ($name in @('MSVCP140.dll', 'MSVCP140_1.dll', 'MSVCP140_2.dll',
+ 'MSVCP140_ATOMIC_WAIT.dll', 'MSVCP140_CODECVT_IDS.dll', 'VCRUNTIME140.dll',
+ 'VCRUNTIME140_1.dll', 'CONCRT140.dll', 'VCOMP140.dll')) {
+ $source = $folders | ForEach-Object { Join-Path $_.FullName $name } |
+ Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
+ if (-not $source) { throw "VC++ redist DLL missing: $name under $x64" }
+ Copy-Item -LiteralPath $source -Destination $Destination -Force
+ }
+}
diff --git a/scripts/fetch-win-binaries.ps1 b/scripts/fetch-win-binaries.ps1
index ce633f2f3..dd13edaf1 100644
--- a/scripts/fetch-win-binaries.ps1
+++ b/scripts/fetch-win-binaries.ps1
@@ -29,6 +29,7 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' # makes Invoke-WebRequest downloads fast
+. "$PSScriptRoot\config\windows-vc-runtime.ps1"
$IncludeCuda = $env:OFFGRID_INCLUDE_CUDA -ne '0'
$bin = Join-Path $PSScriptRoot '..\resources\bin'
@@ -172,9 +173,8 @@ try {
} catch { Write-Warning "whisper.cpp CPU fetch failed: $_" }
# --- stable-diffusion.cpp (image gen): CUDA, Vulkan, then CPU -------------------
-# The CUDA engine reuses bin/cuda-runtime from llama.cpp. Do not fetch the
-# separate 563 MB stable-diffusion CUDA runtime archive: it contains the same
-# three CUDA DLLs and previously made the installer too large to build.
+# The image engine needs CUDA 12.8, while llama uses 12.4. Keep the image
+# runtime beside sd-cuda so the optional pack preserves both versions.
# Keep this release pinned: Qwen-Image 2.1 needs the current runtime and a moving
# latest release can change the packaged DLL contract without review.
$SdRef = 'master-968-8cd731e'
@@ -183,6 +183,12 @@ if ($IncludeCuda) {
try {
$x = Expand-Asset 'leejet/stable-diffusion.cpp' 'bin-win-cuda12-x64\.zip$' $SdRef '8b6f311f22b56db02f545a674f7549a0da283f7134d5fd01c42014b81ab78af0'
$dest = Copy-Runtime $x 'sd-cuda'
+ $runtime = Expand-Asset 'leejet/stable-diffusion.cpp' '^cudart-sd-bin-win-cu12-x64\.zip$' $SdRef 'fe20366827d357c00797eebb58244dddab7fd9a348d70090c3871004c320f38d'
+ foreach ($name in @('cudart64_12.dll', 'cublas64_12.dll', 'cublasLt64_12.dll')) {
+ $source = Get-ChildItem $runtime -Recurse -File -Filter $name | Select-Object -First 1
+ if (-not $source) { throw "CUDA 12.8 DLL missing from image runtime: $name" }
+ Copy-Item $source.FullName -Destination $dest -Force
+ }
$cli = Join-Path $dest 'sd-cli.exe'
$sd = Join-Path $dest 'sd.exe'
if (-not (Test-Path $cli) -and (Test-Path $sd)) { Copy-Item $sd $cli -Force }
@@ -225,6 +231,11 @@ try {
} catch { Write-Warning "ffmpeg fetch failed: $_" }
Write-Host ''
+# Include the root FFmpeg executable and every nested engine executable.
+Get-ChildItem -Path $bin -Recurse -Filter '*.exe' -File |
+ Select-Object -ExpandProperty DirectoryName -Unique |
+ ForEach-Object { Copy-VCRuntime $_ }
+
Write-Host 'resources/bin now contains (win64):'
Get-ChildItem -Path $bin -Recurse -Include *.exe |
ForEach-Object { Write-Host " $($_.FullName.Replace($bin, '').TrimStart('\'))" }
@@ -267,6 +278,9 @@ if ($IncludeCuda) {
(Join-Path $bin 'whisper\cublasLt64_12.dll'),
(Join-Path $bin 'whisper\nvrtc64_120_0.dll'),
(Join-Path $bin 'sd-cuda\sd-cli.exe'),
+ (Join-Path $bin 'sd-cuda\cudart64_12.dll'),
+ (Join-Path $bin 'sd-cuda\cublas64_12.dll'),
+ (Join-Path $bin 'sd-cuda\cublasLt64_12.dll'),
(Join-Path $bin 'sd-cuda\ggml-cuda.dll'))) {
if (-not (Test-Path -LiteralPath $p)) { throw "REQUIRED Windows CUDA runtime missing: $p" }
}
diff --git a/scripts/package-performance-pack.mjs b/scripts/package-performance-pack.mjs
index f42bd461e..5b5f931db 100644
--- a/scripts/package-performance-pack.mjs
+++ b/scripts/package-performance-pack.mjs
@@ -7,27 +7,88 @@ import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
const [, , platform, binRoot, archive, version] = process.argv
-if (!['win32', 'linux'].includes(platform) || !binRoot || !archive || !/^[a-zA-Z0-9._-]+$/.test(version ?? '')) {
- throw new Error('Usage: node scripts/package-performance-pack.mjs ')
+if (
+ !['win32', 'linux'].includes(platform) ||
+ !binRoot ||
+ !archive ||
+ !/^[a-zA-Z0-9._-]+$/.test(version ?? '')
+) {
+ throw new Error(
+ 'Usage: node scripts/package-performance-pack.mjs '
+ )
}
-const folders = platform === 'win32'
- ? ['llama-cuda', 'llama-prism-cuda', 'cuda-runtime', 'sd-cuda', 'whisper', 'kev-runtime', 'onnx-cuda']
- : ['llama-cuda', 'llama-prism-cuda', 'cuda-runtime', 'sd-cuda', 'whisper-cuda', 'kev-runtime', 'onnx-cuda']
-const required = platform === 'win32'
- ? ['llama-cuda/llama-server.exe', 'llama-prism-cuda/llama-server.exe', 'cuda-runtime/cudart64_12.dll', 'sd-cuda/sd-cli.exe', 'whisper/whisper-cli.exe', 'kev-runtime/python/python.exe', 'onnx-cuda/onnxruntime_binding.node', 'onnx-cuda/onnxruntime.dll', 'onnx-cuda/onnxruntime_providers_cuda.dll', 'onnx-cuda/cublasLt64_12.dll', 'onnx-cuda/cudnn64_9.dll']
- : ['llama-cuda/llama-server', 'llama-prism-cuda/llama-server', 'cuda-runtime/libcudart.so.12', 'sd-cuda/sd-cli', 'whisper-cuda/whisper-cli', 'kev-runtime/python/bin/python3', 'onnx-cuda/libcublasLt.so.13', 'onnx-cuda/libcudnn.so.9']
+const folders =
+ platform === 'win32'
+ ? [
+ 'llama-cuda',
+ 'llama-prism-cuda',
+ 'cuda-runtime',
+ 'sd-cuda',
+ 'whisper',
+ 'kev-runtime',
+ 'onnx-cuda'
+ ]
+ : [
+ 'llama-cuda',
+ 'llama-prism-cuda',
+ 'cuda-runtime',
+ 'sd-cuda',
+ 'whisper-cuda',
+ 'kev-runtime',
+ 'onnx-cuda'
+ ]
+const required =
+ platform === 'win32'
+ ? [
+ 'llama-cuda/llama-server.exe',
+ 'llama-prism-cuda/llama-server.exe',
+ 'cuda-runtime/cudart64_12.dll',
+ 'sd-cuda/sd-cli.exe',
+ 'whisper/whisper-cli.exe',
+ 'kev-runtime/python/python.exe',
+ 'onnx-cuda/onnxruntime_binding.node',
+ 'onnx-cuda/onnxruntime.dll',
+ 'onnx-cuda/onnxruntime_providers_cuda.dll',
+ 'onnx-cuda/cublasLt64_12.dll',
+ 'onnx-cuda/cudnn64_9.dll'
+ ]
+ : [
+ 'llama-cuda/llama-server',
+ 'llama-prism-cuda/llama-server',
+ 'cuda-runtime/libcudart.so.12',
+ 'sd-cuda/sd-cli',
+ 'whisper-cuda/whisper-cli',
+ 'kev-runtime/python/bin/python3',
+ 'onnx-cuda/libcublasLt.so.13',
+ 'onnx-cuda/libcudnn.so.9',
+ 'onnx-cuda/libcufft.so.12',
+ 'onnx-cuda/libcufftw.so.12',
+ 'onnx-cuda/libnvrtc.so.13',
+ 'onnx-cuda/libnvrtc-builtins.so.13.2'
+ ]
for (const relative of required) {
- if (!fs.statSync(path.join(binRoot, relative)).isFile()) throw new Error(`Missing CUDA input: ${relative}`)
+ if (!fs.statSync(path.join(binRoot, relative)).isFile())
+ throw new Error(`Missing CUDA input: ${relative}`)
}
fs.mkdirSync(path.dirname(path.resolve(archive)), { recursive: true })
// Stream straight from the staged folders. A second full copy can exhaust
// the runner disk before the archive is ready.
-const tar = spawnSync('tar', [
- '-czf', path.resolve(archive), '--dereference',
- '--transform', 's,^,bin/,', '-C', path.resolve(binRoot), ...folders
-], { stdio: 'inherit' })
+const tar = spawnSync(
+ 'tar',
+ [
+ '-czf',
+ path.resolve(archive),
+ '--dereference',
+ '--transform',
+ 's,^,bin/,',
+ '-C',
+ path.resolve(binRoot),
+ ...folders
+ ],
+ { stdio: 'inherit' }
+)
if (tar.status !== 0) throw new Error(`tar failed with exit code ${tar.status}`)
const hash = createHash('sha256')
for await (const chunk of fs.createReadStream(archive)) hash.update(chunk)
diff --git a/scripts/patch-windows-onnx-cuda.mjs b/scripts/patch-windows-onnx-cuda.mjs
index da40613a1..b0d3caaf6 100644
--- a/scripts/patch-windows-onnx-cuda.mjs
+++ b/scripts/patch-windows-onnx-cuda.mjs
@@ -1,7 +1,9 @@
#!/usr/bin/env node
// ONNX Runtime's published Windows Node addon has no CUDA provider. The optional
// performance pack supplies a CUDA-enabled addon; keep the stock addon for other
-// Windows GPUs and for machines without the pack.
+// Windows GPUs and for machines without the pack. The CUDA addon loads only in a
+// voice or search helper whose environment says CUDA ran on this machine
+// (src/main/onnx-host/onnx-runtime-env.ts), so DirectML and WebGPU stay available.
import fs from 'node:fs'
import path from 'node:path'
@@ -42,12 +44,27 @@ if (ortInfo.version !== '1.30.0') {
throw new Error(`Review the Windows CUDA binding for ONNX Runtime Node ${ortInfo.version}`)
}
const bindingFile = path.join(ortRoot, 'dist', 'binding.js')
+// Installs patched before the CUDA addon was gated on a verified run get the gate added in place.
+const bindingSource = fs.readFileSync(bindingFile, 'utf8')
+if (
+ bindingSource.includes('? process.env.OFFGRID_PERFORMANCE_PACK_BIN : undefined;') &&
+ !bindingSource.includes('OFFGRID_ONNX_CUDA_VERIFIED')
+) {
+ fs.writeFileSync(
+ bindingFile,
+ bindingSource.replace(
+ ' ? process.env.OFFGRID_PERFORMANCE_PACK_BIN : undefined;',
+ " && process.env.OFFGRID_ONNX_CUDA_VERIFIED === '1'\n ? process.env.OFFGRID_PERFORMANCE_PACK_BIN : undefined;"
+ )
+ )
+}
replaceOnce(
bindingFile,
'require(`../bin/napi-v6/${process.platform}/${process.arch}/onnxruntime_binding.node`);',
`(() => {
const stock = \`../bin/napi-v6/\${process.platform}/\${process.arch}/onnxruntime_binding.node\`;
const pack = process.platform === 'win32' && process.arch === 'x64'
+ && process.env.OFFGRID_ONNX_CUDA_VERIFIED === '1'
? process.env.OFFGRID_PERFORMANCE_PACK_BIN : undefined;
const cuda = pack ? require('node:path').join(pack, 'onnx-cuda', 'onnxruntime_binding.node') : undefined;
if (cuda && require('node:fs').existsSync(cuda)) {
diff --git a/scripts/verify-electron-builder-artifact.mjs b/scripts/verify-electron-builder-artifact.mjs
index 9b898e561..af69f0322 100644
--- a/scripts/verify-electron-builder-artifact.mjs
+++ b/scripts/verify-electron-builder-artifact.mjs
@@ -20,24 +20,28 @@ export default async function verifyElectronBuilderArtifact(event) {
// nsis-web stores its payload in dist/nsis-web, while the staged app stays in
// dist/win-unpacked. The target output directory is not the app directory.
- const packageOutDir = event.target.name === 'nsis-web'
- ? path.dirname(event.target.outDir)
- : event.target.outDir
+ const packageOutDir =
+ event.target.name === 'nsis-web' ? path.dirname(event.target.outDir) : event.target.outDir
const appOutDir = event.packager.computeAppOutDir(packageOutDir, event.arch)
+ const resourcesDir = asarOnlyArtifact
+ ? path.join(appOutDir, 'resources')
+ : path.join(appOutDir, `${event.packager.appInfo.productFilename}.app`, 'Contents', 'Resources')
+ if (fs.existsSync(path.join(resourcesDir, 'bin', 'onnx-cuda'))) {
+ throw new Error('installer input includes optional GPU runtime: bin/onnx-cuda')
+ }
if (asarOnlyArtifact) {
assertAsarArchiveInventory(path.join(appOutDir, 'resources', 'app.asar'))
const executable = artifact.endsWith('.exe') ? 'llama-server.exe' : 'llama-server'
const required = [
- ...[
- 'llama',
- 'llama-cpu',
- 'llama-prism',
- 'llama-prism-cpu'
- ].map((variant) => path.join('bin', variant, executable))
+ ...['llama', 'llama-cpu', 'llama-prism', 'llama-prism-cpu'].map((variant) =>
+ path.join('bin', variant, executable)
+ )
]
if (artifact.endsWith('.exe')) {
required.push(
path.join('bin', 'whisper-cpu', 'whisper-cli.exe'),
+ path.join('bin', 'whisper-vulkan', 'whisper-cli.exe'),
+ path.join('bin', 'whisper-vulkan', 'whisper-server.exe'),
path.join('bin', 'sd', 'sd-cli.exe'),
path.join('bin', 'sd-cpu', 'sd-cli.exe'),
path.join('bin', 'ffmpeg.exe'),
@@ -49,6 +53,9 @@ export default async function verifyElectronBuilderArtifact(event) {
path.join('bin', 'whisper', 'whisper-cli'),
path.join('bin', 'whisper-cpu', 'whisper-cli'),
path.join('bin', 'whisper', 'LICENSE'),
+ ...['llama', 'llama-prism', 'whisper'].map((folder) =>
+ path.join('bin', folder, 'libvulkan.so.1')
+ ),
path.join('bin', 'ffmpeg'),
path.join('bin', 'licenses', 'ffmpeg.txt'),
path.join('bin', 'sd', 'sd-cli'),
@@ -70,7 +77,11 @@ export default async function verifyElectronBuilderArtifact(event) {
}
}
const optionalGpuFolders = [
- 'llama-cuda', 'llama-prism-cuda', 'cuda-runtime', 'sd-cuda', 'kev-runtime',
+ 'llama-cuda',
+ 'llama-prism-cuda',
+ 'cuda-runtime',
+ 'sd-cuda',
+ 'kev-runtime',
...(artifact.endsWith('.exe') ? ['whisper'] : ['whisper-cuda'])
]
for (const folder of optionalGpuFolders) {
diff --git a/scripts/windows-onnx-cuda-preload.cjs b/scripts/windows-onnx-cuda-preload.cjs
deleted file mode 100644
index 5d4074aae..000000000
--- a/scripts/windows-onnx-cuda-preload.cjs
+++ /dev/null
@@ -1,33 +0,0 @@
-// This runs before Rollup's external requires in every main-process entry.
-// The optional pack is trusted only after its manifest and install marker match.
-if (process.platform === 'win32' && process.arch === 'x64') {
- try {
- const fs = require('node:fs')
- const path = require('node:path')
- const app = require('electron').app
- const manifestPath = app.isPackaged
- ? path.join(process.resourcesPath, 'performance-packs.json')
- : path.join(app.getAppPath(), 'resources', 'performance-packs.json')
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
- const asset = manifest.schemaVersion === 1 && manifest.cuda?.win32
- if (asset && /^[a-f0-9]{64}$/i.test(asset.sha256) && /^[a-zA-Z0-9._-]+$/.test(asset.version) &&
- new URL(asset.url).origin === 'https://runtime.getoffgridai.co' &&
- new URL(asset.url).pathname === `/desktop/cuda/win32/${asset.version}.tar.gz`) {
- const data = process.env.OFFGRID_DATA_DIR || process.env.OFFGRID_USER_DATA ||
- path.join(app.getPath('appData'), 'Off Grid AI Desktop')
- const root = path.join(data, 'performance-packs', `cuda-${asset.version}-${asset.sha256.slice(0, 12)}`)
- const marker = fs.readFileSync(path.join(root, 'verified.sha256'), 'utf8').trim()
- const bin = path.join(root, 'bin')
- const libraries = path.join(bin, 'onnx-cuda')
- if (marker === asset.sha256.toLowerCase() &&
- fs.existsSync(path.join(libraries, 'onnxruntime.dll')) &&
- fs.existsSync(path.join(libraries, 'onnxruntime_providers_shared.dll')) &&
- fs.existsSync(path.join(libraries, 'cudnn64_9.dll'))) {
- process.env.OFFGRID_PERFORMANCE_PACK_BIN = bin
- process.env.PATH = `${libraries};${process.env.PATH || ''}`
- }
- }
- } catch {
- // No verified pack yet. The stock ONNX Runtime remains available.
- }
-}
diff --git a/src/main/__tests__/ai-request-embeddings.test.ts b/src/main/__tests__/ai-request-embeddings.test.ts
index 328dbbf01..c644be3cc 100644
--- a/src/main/__tests__/ai-request-embeddings.test.ts
+++ b/src/main/__tests__/ai-request-embeddings.test.ts
@@ -1,6 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const host = vi.hoisted(() => ({ embed: vi.fn() }))
-vi.mock('../embeddings-core', () => ({ embedText: host.embed, embeddingDevice: () => 'coreml' }))
+// The inference boundary: the ONNX pipeline reports its device once, then returns vectors.
+vi.mock('../embeddings-core', () => ({
+ embedTexts: async (
+ texts: string[],
+ _model: string,
+ _modelsDir: string,
+ onReady?: (device: string) => void
+ ) => {
+ onReady?.('coreml')
+ return [await host.embed(texts[0])]
+ }
+}))
vi.mock('../backend-preferences', () => ({ getBackendPreference: () => 'auto' }))
vi.mock('../runtime-residency', () => ({ getResidencyMode: () => 'resident' }))
import { embeddings } from '../embeddings'
@@ -19,10 +30,10 @@ describe('embedding request records', () => {
await expect(embeddings.generateEmbedding('local text')).resolves.toEqual([0.1, 0.2])
expect(records.at(-1)).toMatchObject({
modality: 'embedding',
- backend: 'coreml',
+ backend: 'Core ML',
status: 'completed',
metrics: { dimensions: 2 },
- response: [0.1, 0.2],
+ response: [[0.1, 0.2]],
request: { text: 'local text' }
})
})
diff --git a/src/main/__tests__/database-integration.dbtest.ts b/src/main/__tests__/database-integration.dbtest.ts
index 2cedaf507..04e640569 100644
--- a/src/main/__tests__/database-integration.dbtest.ts
+++ b/src/main/__tests__/database-integration.dbtest.ts
@@ -237,7 +237,7 @@ describe('database.ts - RAG messages', () => {
})
})
-describe('database.ts - entities and facts', () => {
+describe('database.ts - entities', () => {
it('EntityDomain creates an entity and returns a stable id on repeat (case-insensitive name)', () => {
const id = resolveEntity('Ada Lovelace', 'Person')
expect(id).toBeGreaterThan(0)
@@ -246,60 +246,6 @@ describe('database.ts - entities and facts', () => {
expect(idAgain).toBe(id)
})
- it('EntityDomain defaults type to Unknown when blank', () => {
- const id = resolveEntity('Mystery', ' ')
- const { entity } = db.getEntityDetails(id) as { entity: { type: string } }
- expect(entity.type).toBe('Unknown')
- })
-
- it('addEntityFact inserts once and dedupes on repeat (INSERT OR IGNORE)', () => {
- const id = resolveEntity('Grace Hopper', 'Person')
- expect(db.addEntityFact(id, 'Coined the term debugging', 'sess-1')).toBe(true)
- // duplicate fact for the same entity is ignored -> no change.
- expect(db.addEntityFact(id, 'Coined the term debugging', 'sess-1')).toBe(false)
- // a distinct fact is inserted.
- expect(db.addEntityFact(id, 'Rear admiral in the US Navy')).toBe(true)
- })
-
- it('updateEntitySummary persists a summary readable via getEntityDetails', () => {
- const id = resolveEntity('Alan Turing', 'Person')
- db.updateEntitySummary(id, 'Founder of theoretical computer science')
- const { entity } = db.getEntityDetails(id) as { entity: { summary: string } }
- expect(entity.summary).toBe('Founder of theoretical computer science')
- })
-
- it('getEntities returns fact_count aggregated per entity', () => {
- const id = resolveEntity('Katherine Johnson', 'Person')
- db.addEntityFact(id, 'Calculated trajectories for Mercury and Apollo')
- db.addEntityFact(id, 'Received the Presidential Medal of Freedom')
- const list = db.getEntities() as { id: number; fact_count: number }[]
- const row = list.find((e) => e.id === id)
- expect(row?.fact_count).toBe(2)
- })
-
- it('getEntityDetails returns the entity plus its facts (newest first)', () => {
- const id = resolveEntity('Margaret Hamilton', 'Person')
- db.addEntityFact(id, 'Led Apollo flight software')
- const { entity, facts } = db.getEntityDetails(id) as {
- entity: { id: number; name: string }
- facts: { fact: string }[]
- }
- expect(entity.id).toBe(id)
- expect(entity.name).toBe('Margaret Hamilton')
- expect(facts.some((f) => f.fact === 'Led Apollo flight software')).toBe(true)
- })
-
- it('EntityDomain deletes the entity and its facts; false on a missing id', () => {
- const id = resolveEntity('Temporary Person', 'Person')
- db.addEntityFact(id, 'to be deleted')
- expect(entityDomain.deleteEntityById(id)).toBe(true)
- const { entity, facts } = db.getEntityDetails(id) as { entity: unknown; facts: unknown[] }
- expect(entity).toBeUndefined()
- expect(facts).toHaveLength(0)
- // deleting again affects no rows.
- expect(entityDomain.deleteEntityById(id)).toBe(false)
- })
-
it('upsertEntitySession links entities to a session and getEntitiesForSession returns them', () => {
// entity_sessions.session_id is a FK to conversations(id), so the session
// must be a real conversation row first.
@@ -317,30 +263,10 @@ describe('database.ts - entities and facts', () => {
const ids = forSession.map((e) => e.id)
expect(ids).toContain(a)
expect(ids).toContain(b)
- expect(
- forSession.every(
- (entity) => typeof (entity as { fact_count?: unknown }).fact_count === 'number'
- )
- ).toBe(true)
expect(forSession.every((entity) => !('session_count' in entity))).toBe(true)
})
})
-describe('database.ts - master memory', () => {
- it('getMasterMemory returns an object with content and updated_at keys', () => {
- const before = db.getMasterMemory()
- expect(before).toHaveProperty('content')
- expect(before).toHaveProperty('updated_at')
- })
-
- it('updateMasterMemory upserts the single row and getMasterMemory reads it back', () => {
- db.updateMasterMemory('cumulative profile v1')
- expect(db.getMasterMemory().content).toBe('cumulative profile v1')
- db.updateMasterMemory('cumulative profile v2')
- expect(db.getMasterMemory().content).toBe('cumulative profile v2')
- })
-})
-
describe('database.ts - chat summaries', () => {
it('upsertChatSummary inserts then updates for the same session, getAllChatSummaries lists them', () => {
db.upsertChatSummary('sum-sess', 'first summary')
@@ -410,40 +336,7 @@ describe('database.ts - app settings (typed round-trip + defaults)', () => {
})
})
-describe('database.ts - memories, dashboard stats and legacy purge', () => {
- it('deleteMemory returns false for a non-existent memory id', () => {
- expect(db.deleteMemory(999999)).toBe(false)
- })
-
- it('deleteMemory returns true after inserting a memory row directly', () => {
- const handle = db.getDB()
- const info = handle
- .prepare('INSERT INTO memories (content, source_app, session_id) VALUES (?, ?, ?)')
- .run('a captured memory', 'TestApp', 'sess-mem')
- const memId = Number(info.lastInsertRowid)
- expect(db.deleteMemory(memId)).toBe(true)
- })
-
- it('getMemoryRecordsForSession returns rows scoped to a session', () => {
- const handle = db.getDB()
- handle
- .prepare('INSERT INTO memories (content, source_app, session_id) VALUES (?, ?, ?)')
- .run('scoped memory', 'TestApp', 'sess-scope')
- const rows = db.getMemoryRecordsForSession('sess-scope') as { content: string }[]
- expect(rows.some((r) => r.content === 'scoped memory')).toBe(true)
- })
-
- it('getDashboardStats returns the full stat shape with numeric totals', () => {
- const stats = db.getDashboardStats()
- expect(typeof stats.totalMemories).toBe('number')
- expect(typeof stats.totalEntities).toBe('number')
- expect(Array.isArray(stats.recentMemories)).toBe(true)
- expect(Array.isArray(stats.topEntities)).toBe(true)
- expect(Array.isArray(stats.activityByDay)).toBe(true)
- // 14-day activity window.
- expect(stats.activityByDay.length).toBe(14)
- })
-
+describe('database.ts - legacy purge and chat sessions', () => {
it('purgeLegacyChatImports returns null when there is nothing legacy to purge', () => {
// No Claude/ChatGPT/Gemini conversations were seeded, so this is a no-op.
expect(db.purgeLegacyChatImports()).toBeNull()
diff --git a/src/main/__tests__/document-index-upgrade.dbtest.ts b/src/main/__tests__/document-index-upgrade.dbtest.ts
new file mode 100644
index 000000000..04eb10628
--- /dev/null
+++ b/src/main/__tests__/document-index-upgrade.dbtest.ts
@@ -0,0 +1,128 @@
+/**
+ * Document passages in scripts written without spaces, and the passage indexes on an upgraded
+ * profile, against the real profile database and document engine. A Japanese passage is found by
+ * a word of three or more characters (the trigram twin) and by a two-character one. When a profile
+ * from an older schema opens, its passage indexes are rebuilt in the background after start-up
+ * and every passage is found again. Electron and the embedding model are the only controlled
+ * boundaries; the model is unavailable, so this is the keyword path alone.
+ */
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+const PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-document-index-'))
+const originalDataDir = process.env.OFFGRID_DATA_DIR
+process.env.OFFGRID_DATA_DIR = PROFILE_DIR
+
+vi.mock('electron', () => ({
+ app: { getPath: () => PROFILE_DIR, isPackaged: false, getAppPath: () => process.cwd() },
+ BrowserWindow: { getAllWindows: () => [] },
+ safeStorage: {
+ isEncryptionAvailable: () => false,
+ encryptString: (value: string) => Buffer.from(value),
+ decryptString: (value: Buffer) => value.toString()
+ }
+}))
+
+vi.mock('@huggingface/transformers', () => ({
+ env: {},
+ pipeline: async () => {
+ throw new Error('no embedding model in this journey')
+ }
+}))
+
+const JAPANESE =
+ '東京オフィスの移転は来月の予定です。新しい会議室の予約は総務部が担当します。引っ越しの日程は後日連絡します。'
+const ENGLISH =
+ 'The harbourfront lease renews in March. Parking permits are issued by the building office.'
+
+let japaneseId = ''
+let englishId = ''
+
+type Documents = typeof import('../documents')
+
+async function found(documents: Documents, query: string): Promise {
+ const passages = await documents.documentTurns.searchPassages(
+ { documentIds: [japaneseId, englishId] },
+ query
+ )
+ return [...new Set(passages.map((passage) => passage.documentId))]
+}
+
+beforeAll(async () => {
+ const { ingestTextDocument } = await import('../files')
+ japaneseId = (await ingestTextDocument('移転計画', 'audio', JAPANESE)).documentId!
+ englishId = (await ingestTextDocument('Lease notes', 'audio', ENGLISH)).documentId!
+})
+
+afterAll(async () => {
+ const { stopIndexRebuilds, getDB } = await import('../database')
+ stopIndexRebuilds()
+ getDB().close()
+ if (originalDataDir === undefined) delete process.env.OFFGRID_DATA_DIR
+ else process.env.OFFGRID_DATA_DIR = originalDataDir
+ fs.rmSync(PROFILE_DIR, { recursive: true, force: true })
+})
+
+describe('document passage indexes', () => {
+ it('finds a Japanese passage by a long word and by a two-character one', async () => {
+ const documents = await import('../documents')
+ await vi.waitFor(
+ async () => expect(await found(documents, 'harbourfront lease')).toEqual([englishId]),
+ {
+ timeout: 15_000,
+ interval: 100
+ }
+ )
+ await vi.waitFor(async () => expect(await found(documents, 'オフィス')).toEqual([japaneseId]), {
+ timeout: 15_000,
+ interval: 100
+ })
+ expect(await found(documents, '会議')).toEqual([japaneseId])
+ // Both scripts in one question: each finds its own document.
+ expect((await found(documents, 'parking 総務部')).sort()).toEqual(
+ [englishId, japaneseId].sort()
+ )
+ })
+
+ it('rebuilds the passage indexes of an older profile in the background after start-up', async () => {
+ // The profile as an older schema left it: the indexes are not current, the twin is gone.
+ const database = await import('../database')
+ database
+ .getDB()
+ .prepare('DELETE FROM fts_schema WHERE name IN (?, ?)')
+ .run('attached_document_fts', 'attached_document_fts_tri')
+ database.getDB().exec('DROP TABLE IF EXISTS attached_document_fts_tri')
+ database.getDB().close()
+ vi.resetModules()
+
+ const reopened = await import('../database')
+ const documents = await import('../documents')
+ // Opening the store queues the rebuilds; start-up then runs them.
+ await documents.documentStore.getDocument(japaneseId)
+ reopened.startIndexRebuilds()
+
+ await vi.waitFor(
+ async () => {
+ expect(await found(documents, 'オフィス')).toEqual([japaneseId])
+ expect(await found(documents, 'harbourfront lease')).toEqual([englishId])
+ },
+ { timeout: 20_000, interval: 100 }
+ )
+ expect(await found(documents, '会議')).toEqual([japaneseId])
+ // Both indexes end current, so the next launch does not rebuild them again.
+ await vi.waitFor(
+ () =>
+ expect(
+ reopened
+ .getDB()
+ .prepare(
+ "SELECT name FROM fts_schema WHERE name LIKE 'attached_document_fts%' ORDER BY name"
+ )
+ .all()
+ ).toEqual([{ name: 'attached_document_fts' }, { name: 'attached_document_fts_tri' }]),
+ { timeout: 10_000, interval: 100 }
+ )
+ })
+})
diff --git a/src/main/__tests__/embeddings-core.test.ts b/src/main/__tests__/embeddings-core.test.ts
index 860744ff7..9edfa1327 100644
--- a/src/main/__tests__/embeddings-core.test.ts
+++ b/src/main/__tests__/embeddings-core.test.ts
@@ -18,7 +18,7 @@ vi.mock('../onnx-device', () => ({
})
}))
-import { disposeEmbeddingModel, embedText } from '../embeddings-core'
+import { disposeEmbeddingModel } from '../embeddings-core'
let profile: string
let selected: string
@@ -36,18 +36,6 @@ afterEach(async () => {
fs.rmSync(profile, { recursive: true, force: true })
})
-it('retries an embedding model load after a temporary provider failure', async () => {
- const runtime = Object.assign(
- vi.fn(async () => ({ data: new Float32Array([0.25, 0.5]) })),
- { dispose: vi.fn() }
- )
- host.pipeline.mockRejectedValueOnce(new Error('provider unavailable')).mockResolvedValue(runtime)
-
- await expect(embedText('first', selected)).rejects.toThrow('provider unavailable')
- await expect(embedText('second', selected)).resolves.toEqual([0.25, 0.5])
- expect(host.pipeline).toHaveBeenCalledTimes(2)
-})
-
it('loads embeddings from a scan folder while the chosen folder is offline', async () => {
const scan = path.join(profile, 'scan')
const packageRoot = path.join(scan, '.cache/Xenova/all-MiniLM-L6-v2')
@@ -65,7 +53,8 @@ it('loads embeddings from a scan folder while the chosen folder is offline', asy
fs.rmdirSync(selected)
host.pipeline.mockResolvedValue(
Object.assign(
- vi.fn(async () => ({ data: new Float32Array([0.25, 0.5]) })),
+ // A transformers.js Tensor: one row per text.
+ vi.fn(async () => ({ data: new Float32Array([0.25, 0.5]), tolist: () => [[0.25, 0.5]] })),
{ dispose: vi.fn() }
)
)
@@ -77,41 +66,3 @@ it('loads embeddings from a scan folder while the chosen folder is offline', asy
})
expect(fs.existsSync(selected)).toBe(false)
})
-
-it('refuses an embedding download when no complete package exists and storage is offline', async () => {
- fs.rmdirSync(selected)
- await expect(embedText('query', selected)).rejects.toThrow(
- 'Connect the drive or choose another folder.'
- )
- expect(host.pipeline).not.toHaveBeenCalled()
- expect(fs.existsSync(selected)).toBe(false)
-})
-
-it.each([undefined, '1'])(
- 'loads from an uncreated default models path (offline=%s)',
- async (offline) => {
- configureRuntime({ modelsDir: undefined })
- fs.rmdirSync(selected)
- vi.stubEnv('OFFGRID_EMBEDDINGS_OFFLINE', offline)
- host.pipeline.mockResolvedValue(
- Object.assign(
- vi.fn(async () => ({ data: new Float32Array([0.25, 0.5]) })),
- { dispose: vi.fn() }
- )
- )
- await expect(embedText('query', selected)).resolves.toEqual([0.25, 0.5])
- expect(host.pipeline).toHaveBeenCalledWith('feature-extraction', 'Xenova/all-MiniLM-L6-v2', {
- device: 'cpu'
- })
- expect(fs.existsSync(selected)).toBe(false)
- }
-)
-
-it('attempts local-only resolution without a writable chosen folder', async () => {
- fs.rmdirSync(selected)
- vi.stubEnv('OFFGRID_EMBEDDINGS_OFFLINE', '1')
- host.pipeline.mockRejectedValue(new Error('local model is missing'))
- await expect(embedText('query', selected)).rejects.toThrow('local model is missing')
- expect(host.pipeline).toHaveBeenCalledTimes(1)
- expect(fs.existsSync(selected)).toBe(false)
-})
diff --git a/src/main/__tests__/engine-resume-health.test.ts b/src/main/__tests__/engine-resume-health.test.ts
new file mode 100644
index 000000000..bc8a5252f
--- /dev/null
+++ b/src/main/__tests__/engine-resume-health.test.ts
@@ -0,0 +1,157 @@
+import { EventEmitter } from 'node:events'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', async () => {
+ const { EventEmitter: Emitter } = await import('node:events')
+ return { powerMonitor: new Emitter() }
+})
+
+import {
+ installEngineResumeHealth,
+ registerResumeHealthTarget,
+ waitForEngineResumeHealth
+} from '../engine-resume-health'
+
+/** An engine as the wake check sees it: whether it should run, whether it answers, its restarts. */
+interface Engine {
+ target: Parameters[0]
+ restarts: number[]
+ state: { running: boolean; healthy: boolean; restartFails?: boolean }
+}
+
+function engine(id: string, state: Engine['state']): Engine {
+ const restarts: number[] = []
+ const target = {
+ id,
+ shouldBeRunning: () => state.running,
+ healthCheck: async () => {
+ if (state.healthy === undefined) throw new Error('no answer')
+ return state.healthy
+ },
+ restart: async () => {
+ restarts.push(Date.now())
+ if (state.restartFails) throw new Error(`${id} would not start`)
+ state.healthy = true
+ }
+ }
+ return { target, restarts, state }
+}
+
+let monitor: EventEmitter
+let invalidations: number
+let dispose: () => void
+const unregister: Array<() => void> = []
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ monitor = new EventEmitter()
+ invalidations = 0
+ dispose = installEngineResumeHealth({
+ // The wake events, as Electron's powerMonitor emits them.
+ powerMonitor: monitor as unknown as NonNullable<
+ Parameters[0]
+ >['powerMonitor'],
+ invalidateBackendFacts: () => {
+ invalidations++
+ }
+ })
+})
+
+afterEach(() => {
+ dispose()
+ for (const off of unregister.splice(0)) off()
+ vi.useRealTimers()
+})
+
+describe('engine health after wake', () => {
+ it('restarts only the engines that should run and stopped answering', async () => {
+ const stalled = engine('chat', { running: true, healthy: false })
+ const fine = engine('image', { running: true, healthy: true })
+ const idle = engine('voice', { running: false, healthy: false })
+ for (const { target } of [stalled, fine, idle])
+ unregister.push(registerResumeHealthTarget(target))
+
+ monitor.emit('resume')
+ expect(invalidations).toBe(1)
+ // Requests that arrive during the wake debounce wait for the check.
+ let waited = false
+ const waiting = waitForEngineResumeHealth().then(() => {
+ waited = true
+ })
+ await vi.advanceTimersByTimeAsync(1_999)
+ expect(stalled.restarts).toHaveLength(0)
+ expect(waited).toBe(false)
+
+ await vi.advanceTimersByTimeAsync(1)
+ await waiting
+ expect(waited).toBe(true)
+ expect(stalled.restarts).toHaveLength(1)
+ expect(fine.restarts).toHaveLength(0)
+ expect(idle.restarts).toHaveLength(0)
+ })
+
+ it('checks once for a burst of wake and unlock events', async () => {
+ const stalled = engine('chat', { running: true, healthy: false })
+ unregister.push(registerResumeHealthTarget(stalled.target))
+ monitor.emit('resume')
+ await vi.advanceTimersByTimeAsync(1_000)
+ monitor.emit('unlock-screen')
+ await vi.advanceTimersByTimeAsync(2_000)
+ await waitForEngineResumeHealth()
+ expect(stalled.restarts).toHaveLength(1)
+ // Every event refreshes what the GPU can run (a driver may have changed while asleep).
+ expect(invalidations).toBe(2)
+ })
+
+ it('keeps checking the other engines when one cannot be recovered', async () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const broken = engine('grounding', { running: true, healthy: false, restartFails: true })
+ const stalled = engine('chat', { running: true, healthy: false })
+ const silent = engine('decision', { running: true, healthy: undefined as unknown as boolean })
+ for (const { target } of [broken, stalled, silent])
+ unregister.push(registerResumeHealthTarget(target))
+
+ monitor.emit('resume')
+ await vi.advanceTimersByTimeAsync(2_000)
+ await waitForEngineResumeHealth()
+ expect(broken.restarts).toHaveLength(1)
+ expect(stalled.restarts).toHaveLength(1)
+ // A health check that throws counts as not answering.
+ expect(silent.restarts).toHaveLength(1)
+ expect(error).toHaveBeenCalledWith(
+ '[engine-resume-health] grounding recovery failed:',
+ expect.objectContaining({ message: 'grounding would not start' })
+ )
+ error.mockRestore()
+ })
+
+ it('stops listening once disposed and releases anyone waiting', async () => {
+ const stalled = engine('chat', { running: true, healthy: false })
+ unregister.push(registerResumeHealthTarget(stalled.target))
+ monitor.emit('resume')
+ const waiting = waitForEngineResumeHealth()
+ dispose()
+ await waiting
+ await vi.advanceTimersByTimeAsync(5_000)
+ expect(stalled.restarts).toHaveLength(0)
+ monitor.emit('resume')
+ await vi.advanceTimersByTimeAsync(5_000)
+ expect(stalled.restarts).toHaveLength(0)
+ expect(monitor.listenerCount('resume')).toBe(0)
+ expect(monitor.listenerCount('unlock-screen')).toBe(0)
+ })
+
+ it('forgets an engine once it unregisters, but not a newer one under the same id', async () => {
+ const old = engine('chat', { running: true, healthy: false })
+ const offOld = registerResumeHealthTarget(old.target)
+ const replacement = engine('chat', { running: true, healthy: false })
+ unregister.push(registerResumeHealthTarget(replacement.target))
+ offOld()
+
+ monitor.emit('resume')
+ await vi.advanceTimersByTimeAsync(2_000)
+ await waitForEngineResumeHealth()
+ expect(old.restarts).toHaveLength(0)
+ expect(replacement.restarts).toHaveLength(1)
+ })
+})
diff --git a/src/main/__tests__/entity-domain.dbtest.ts b/src/main/__tests__/entity-domain.dbtest.ts
index 12bae122f..34034d0cc 100644
--- a/src/main/__tests__/entity-domain.dbtest.ts
+++ b/src/main/__tests__/entity-domain.dbtest.ts
@@ -14,7 +14,7 @@ vi.mock('electron', () => ({
}
}))
-import { addEntityFact, getDB, upsertEntitySession } from '../database'
+import { getDB, upsertEntitySession } from '../database'
import {
deleteEntityById,
registerEntityDomain,
@@ -114,7 +114,9 @@ describe('EntityDomain SQLite lifecycle', () => {
)
const doomed = resolve('Delete With Dependants', 'Person')
const survivor = resolve('Lifecycle Survivor', 'Person')
- addEntityFact(doomed, 'Fact that must not become orphaned', 'entity-delete-session')
+ db.prepare(
+ 'INSERT INTO entity_facts (entity_id, fact, source_session_id) VALUES (?, ?, ?)'
+ ).run(doomed, 'Fact that must not become orphaned', 'entity-delete-session')
upsertEntitySession(doomed, 'entity-delete-session')
db.prepare(
`INSERT INTO entity_edges
@@ -149,7 +151,9 @@ describe('EntityDomain SQLite lifecycle', () => {
'Notes'
)
const doomed = resolve('Rollback Entity', 'Person')
- addEntityFact(doomed, 'This fact must survive rollback', 'entity-rollback-session')
+ db.prepare(
+ 'INSERT INTO entity_facts (entity_id, fact, source_session_id) VALUES (?, ?, ?)'
+ ).run(doomed, 'This fact must survive rollback', 'entity-rollback-session')
upsertEntitySession(doomed, 'entity-rollback-session')
db.exec(`CREATE TRIGGER reject_entity_delete
BEFORE DELETE ON entities WHEN old.id = ${doomed}
diff --git a/src/main/__tests__/fetch-win-binaries.test.ts b/src/main/__tests__/fetch-win-binaries.test.ts
index 056fbd756..4727333bb 100644
--- a/src/main/__tests__/fetch-win-binaries.test.ts
+++ b/src/main/__tests__/fetch-win-binaries.test.ts
@@ -31,12 +31,15 @@ describe.skipIf(!SRC)('fetch-win-binaries.ps1 — stable-diffusion.cpp asset', (
expect(activeAvx2).toBe(false)
})
- it('stages CUDA, Vulkan, and CPU image runtimes with one shared CUDA runtime', () => {
+ it('stages CUDA, Vulkan, and CPU image runtimes, with the image engine’s own CUDA 12.8 runtime', () => {
expect(SRC).toMatch(/leejet\/stable-diffusion\.cpp'\s+'bin-win-cuda12-x64\\\.zip\$'/)
expect(SRC).toMatch(/leejet\/stable-diffusion\.cpp'\s+'bin-win-vulkan-x64\\\.zip\$'/)
expect(SRC).toMatch(/leejet\/stable-diffusion\.cpp'\s+'bin-win-cpu-x64\\\.zip\$'/)
expect(SRC).toMatch(/Copy-Runtime \$x 'sd-cuda'/)
- expect(SRC).not.toMatch(/cudart-sd-bin-win/)
+ // stable-diffusion.cpp builds against CUDA 12.8 and llama.cpp against 12.4: sd-cuda carries
+ // its own three runtime DLLs instead of borrowing llama's.
+ expect(SRC).toMatch(/cudart-sd-bin-win-cu12-x64/)
+ expect(SRC).toMatch(/'cudart64_12\.dll', 'cublas64_12\.dll', 'cublasLt64_12\.dll'/)
})
it('still renames upstream sd.exe to the sd-cli.exe the app resolves', () => {
@@ -54,7 +57,9 @@ describe.skipIf(!SRC)('fetch-win-binaries.ps1 — stable-diffusion.cpp asset', (
describe.skipIf(!SRC)('fetch-win-binaries.ps1 — Bonsai runtime', () => {
it('stages pinned Prism CUDA, Vulkan, and CPU runtimes in the directories the model server resolves', () => {
expect(SRC).toMatch(/offgrid\.prismLlamaRef/)
- expect(SRC).toMatch(/PrismML-Eng\/llama\.cpp'\s+'\^llama-\.\+-bin-win-cuda-12\\\.4-x64\\\.zip\$'/)
+ expect(SRC).toMatch(
+ /PrismML-Eng\/llama\.cpp'\s+'\^llama-\.\+-bin-win-cuda-12\\\.4-x64\\\.zip\$'/
+ )
expect(SRC).toMatch(/PrismML-Eng\/llama\.cpp'\s+'bin-win-vulkan-x64\\\.zip\$'/)
expect(SRC).toMatch(/PrismML-Eng\/llama\.cpp'\s+'bin-win-cpu-x64\\\.zip\$'/)
expect(SRC).toMatch(/Copy-Runtime \$x 'llama-prism-cuda'/)
@@ -64,7 +69,9 @@ describe.skipIf(!SRC)('fetch-win-binaries.ps1 — Bonsai runtime', () => {
it('stages the shared CUDA runtime and checks it before packaging', () => {
expect(SRC).toMatch(/ggml-org\/llama\.cpp'\s+'\^llama-\.\+-bin-win-cuda-12\\\.4-x64\\\.zip\$'/)
- expect(SRC).toMatch(/ggml-org\/llama\.cpp'\s+'\^cudart-llama-bin-win-cuda-12\\\.4-x64\\\.zip\$'/)
+ expect(SRC).toMatch(
+ /ggml-org\/llama\.cpp'\s+'\^cudart-llama-bin-win-cuda-12\\\.4-x64\\\.zip\$'/
+ )
expect(SRC).toMatch(/Copy-Runtime \$x 'cuda-runtime'/)
expect(SRC).toMatch(/cuda-runtime\\cudart64_12\.dll/)
expect(SRC).toMatch(/cuda-runtime\\cublas64_12\.dll/)
diff --git a/src/main/__tests__/fresh-profile.dbtest.ts b/src/main/__tests__/fresh-profile.dbtest.ts
index 2a63454b8..edda3e5b9 100644
--- a/src/main/__tests__/fresh-profile.dbtest.ts
+++ b/src/main/__tests__/fresh-profile.dbtest.ts
@@ -22,10 +22,7 @@ vi.mock('electron', () => ({
import {
getAllChatSummaries,
getChatSessions,
- getDashboardStats,
getDB,
- getEntities,
- getMasterMemory,
getRagConversations,
getUserProfile
} from '../database'
@@ -37,35 +34,13 @@ afterAll(() => {
})
describe('fresh profile integration', () => {
- it('starts with no chats, projects, memories, entities, summaries, or identity (#3)', () => {
+ it('starts with no chats, projects, summaries, or identity (#3)', () => {
expect(fs.readdirSync(profile)).toEqual([])
expect(getRagConversations()).toEqual([])
expect(listProjects()).toEqual([])
expect(getChatSessions()).toEqual([])
- expect(getEntities()).toEqual([])
expect(getAllChatSummaries()).toEqual([])
expect(getUserProfile()).toBeNull()
- expect(getMasterMemory()).toEqual({ content: null, updated_at: null })
- const stats = getDashboardStats()
- expect(stats).toEqual(
- expect.objectContaining({
- totalChats: 0,
- totalMemories: 0,
- totalEntities: 0,
- totalRelationships: 0,
- totalMessages: 0,
- totalFacts: 0,
- recentChats: [],
- recentMemories: [],
- topEntities: [],
- entityTypeCounts: [],
- appDistribution: []
- })
- )
- expect(stats.activityByDay).toHaveLength(14)
- expect(stats.activityByDay.every(({ chats, memories }) => chats === 0 && memories === 0)).toBe(
- true
- )
})
})
diff --git a/src/main/__tests__/fts-match.test.ts b/src/main/__tests__/fts-match.test.ts
deleted file mode 100644
index ab250a92f..000000000
--- a/src/main/__tests__/fts-match.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { DatabaseSync } from 'node:sqlite'
-import { describe, expect, it } from 'vitest'
-import { escapeFtsMatch } from '../fts-match'
-
-describe('escapeFtsMatch', () => {
- it.each([
- ['release?', '"release"'],
- ['say "release"', '"say" OR "release"'],
- ['title:release', '"title" OR "release"'],
- ['(release)', '"release"'],
- ['AND OR NOT NEAR', '"AND" OR "OR" OR "NOT" OR "NEAR"'],
- ['release* ^code', '"release" OR "code"'],
- ['', ''],
- [' \t\n ', ''],
- ['?:()*^"', ''],
- ['café 日本語 नमस्ते', '"café" OR "日本語" OR "नमस्ते"']
- ])('escapes %j', (query, expected) => {
- const match = escapeFtsMatch(query)
- expect(match).toBe(expected)
- if (!match) return
- const db = new DatabaseSync(':memory:')
- try {
- db.exec('CREATE VIRTUAL TABLE terms USING fts5(content)')
- db.prepare('INSERT INTO terms(content) VALUES (?)').run('release code café 日本語 नमस्ते AND')
- expect(() => db.prepare('SELECT * FROM terms WHERE terms MATCH ?').all(match)).not.toThrow()
- } finally {
- db.close()
- }
- })
-
- it('doubles internal quotes in phrase tokens', () => {
- expect(escapeFtsMatch(['a"b'])).toBe('"a""b"')
- })
-
- it('keeps caller-supplied prefix and all-term search behavior', () => {
- expect(escapeFtsMatch('release code', { prefix: true, join: 'AND', maxTokens: 1 })).toBe(
- '"release"*'
- )
- })
-})
diff --git a/src/main/__tests__/fts-rebuild-upgrade.dbtest.ts b/src/main/__tests__/fts-rebuild-upgrade.dbtest.ts
new file mode 100644
index 000000000..fa24db901
--- /dev/null
+++ b/src/main/__tests__/fts-rebuild-upgrade.dbtest.ts
@@ -0,0 +1,134 @@
+/**
+ * The upgrade path of the keyword indexes, against the real profile database. A profile from
+ * before the retrieval engine already holds moments and meetings; their indexes are created empty
+ * and filled in the background after start-up, a slice at a time. Once that finishes every old row
+ * is found, and a row added or edited while it ran is found as it is now. Electron and the
+ * embedding model are the only controlled boundaries.
+ */
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+const PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-fts-rebuild-'))
+const originalDataDir = process.env.OFFGRID_DATA_DIR
+process.env.OFFGRID_DATA_DIR = PROFILE_DIR
+
+vi.mock('electron', () => ({
+ app: { getPath: () => PROFILE_DIR, isPackaged: false, getAppPath: () => process.cwd() },
+ BrowserWindow: { getAllWindows: () => [] },
+ safeStorage: {
+ isEncryptionAvailable: () => false,
+ encryptString: (value: string) => Buffer.from(value),
+ decryptString: (value: Buffer) => value.toString()
+ }
+}))
+
+vi.mock('@huggingface/transformers', () => ({
+ env: {},
+ pipeline: async () => {
+ throw new Error('no embedding model in this journey')
+ }
+}))
+
+import { getDB, startIndexRebuilds, stopIndexRebuilds } from '../database'
+import { ensureRecordSchema, recordSearch } from '../records'
+
+/** Enough rows that the background rebuild takes several slices. */
+const OLD_MEETINGS = 4_000
+
+const keysFor = async (question: string, kinds: string[]): Promise =>
+ (await recordSearch(question, { semantic: false, window: null, kinds, limit: 50 })).hits.map(
+ (hit) => hit.key
+ )
+
+beforeAll(() => {
+ // A profile written before the retrieval engine: Pro's tables, already full.
+ const db = getDB()
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS observations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, summary TEXT NOT NULL, surface TEXT, surface_app TEXT,
+ url TEXT, source_session_id TEXT, source_key TEXT, memory_id INTEGER,
+ category TEXT NOT NULL DEFAULT 'work', engagement REAL NOT NULL DEFAULT 0,
+ salience REAL NOT NULL DEFAULT 0, ts DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS frames (id INTEGER PRIMARY KEY AUTOINCREMENT, image_path TEXT, text TEXT, surface TEXT, url TEXT, ts DATETIME, source TEXT);
+ CREATE TABLE IF NOT EXISTS observation_frames (observation_id INTEGER, frame_id INTEGER);
+ CREATE TABLE IF NOT EXISTS meetings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, transcript TEXT, summary TEXT,
+ started_at INTEGER, ended_at INTEGER, duration_sec INTEGER, created_at INTEGER NOT NULL DEFAULT 0
+ );
+ `)
+ const meeting = db.prepare('INSERT INTO meetings (title, summary, started_at) VALUES (?, ?, ?)')
+ db.transaction(() => {
+ for (let i = 1; i <= OLD_MEETINGS; i++)
+ meeting.run(`Standup ${i}`, `Daily standup notes number ${i}`, 1_700_000_000_000 + i)
+ meeting.run(
+ 'Harbourfront lease review',
+ 'Lease renewal for the harbourfront office',
+ 1_700_100_000_000
+ )
+ db.prepare("INSERT INTO observations (summary, surface) VALUES (?, 'Notes')").run(
+ 'Read the harbourfront parking rules'
+ )
+ db.prepare("INSERT INTO observations (summary, surface) VALUES (?, 'Notes')").run(
+ '東京オフィスの移転計画'
+ )
+ })()
+})
+
+afterAll(() => {
+ stopIndexRebuilds()
+ getDB().close()
+ if (originalDataDir === undefined) delete process.env.OFFGRID_DATA_DIR
+ else process.env.OFFGRID_DATA_DIR = originalDataDir
+ fs.rmSync(PROFILE_DIR, { recursive: true, force: true })
+})
+
+describe('keyword indexes on an upgraded profile', () => {
+ it('fills in the background after start-up and keeps edits made while it runs', async () => {
+ // Start-up: the indexes exist but are not filled yet, so old rows are not found.
+ ensureRecordSchema()
+ expect(await keysFor('harbourfront lease', ['meeting'])).toEqual([])
+
+ startIndexRebuilds()
+ // While the rebuild runs: a new meeting, and an old one renamed.
+ const added = Number(
+ getDB()
+ .prepare('INSERT INTO meetings (title, summary, started_at) VALUES (?, ?, ?)')
+ .run('Quayside budget call', 'Budget for the quayside fitout', Date.now()).lastInsertRowid
+ )
+ getDB()
+ .prepare('UPDATE meetings SET title = ?, summary = ? WHERE id = ?')
+ .run('Lighthouse retrospective', 'What went well at the lighthouse event', 2)
+
+ await vi.waitFor(
+ async () => {
+ expect(await keysFor('harbourfront lease', ['meeting'])).toEqual(
+ expect.arrayContaining([expect.stringMatching(/^mtg:\d+$/)])
+ )
+ expect(await keysFor(`standup number ${OLD_MEETINGS}`, ['meeting'])).toContain(
+ `mtg:${OLD_MEETINGS}`
+ )
+ },
+ { timeout: 20_000, interval: 100 }
+ )
+ expect(await keysFor('quayside budget', ['meeting'])).toEqual([`mtg:${added}`])
+ expect(await keysFor('lighthouse retrospective', ['meeting'])).toEqual(['mtg:2'])
+ // The renamed meeting is found by what it says now, not by its old words.
+ expect(await keysFor('standup number 2', ['meeting'])).not.toContain('mtg:2')
+
+ // Moments too, including text in a script written without spaces (the trigram twin).
+ await vi.waitFor(
+ async () => {
+ expect(await keysFor('harbourfront parking', ['screen'])).toHaveLength(1)
+ expect(await keysFor('オフィス', ['screen'])).toHaveLength(1)
+ },
+ { timeout: 20_000, interval: 100 }
+ )
+
+ // Once swapped in, the filled index follows edits live.
+ getDB().prepare('UPDATE meetings SET summary = ? WHERE id = ?').run('Marina berth fees', 3)
+ expect(await keysFor('marina berth', ['meeting'])).toEqual(['mtg:3'])
+ })
+})
diff --git a/src/main/__tests__/harness/fake-llama-server.ts b/src/main/__tests__/harness/fake-llama-server.ts
index 861b2edf5..8128eded6 100644
--- a/src/main/__tests__/harness/fake-llama-server.ts
+++ b/src/main/__tests__/harness/fake-llama-server.ts
@@ -36,7 +36,7 @@ interface FakeTurn {
requirePriorReasoningDetails?: boolean
/** Tool calls emitted on this turn (the agentic loop then runs them and calls back). */
toolCalls?: FakeToolCall[]
- /** Force a non-200 to exercise the error path (body is surfaced by describeServerError). */
+ /** Force a non-200 to exercise the error path (body is surfaced by serverResponseError). */
errorStatus?: number
errorBody?: string
/** Delay the native-engine response after the request is admitted. Lifecycle tests use
diff --git a/src/main/__tests__/ipc-fts-fallback.dbtest.ts b/src/main/__tests__/ipc-fts-fallback.dbtest.ts
index 7a2295ccb..11b5a3f14 100644
--- a/src/main/__tests__/ipc-fts-fallback.dbtest.ts
+++ b/src/main/__tests__/ipc-fts-fallback.dbtest.ts
@@ -1,15 +1,13 @@
-// Real SQLite and IPC retrieval, with model transport controlled without a local server.
+// User text that is FTS5 syntax (quotes, operators, column filters, wildcards) or no words at all
+// never breaks a search: the real retrieval engine over the real profile database finds the record
+// by its words, and an empty query matches nothing. Electron and the embedding model are the only
+// controlled boundaries; the model is unavailable, so this is the keyword path alone.
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
-type IpcHandler = (event: IpcEvent, ...args: unknown[]) => unknown
-interface IpcEvent {
- sender: { send: (channel: string, payload: unknown) => void }
-}
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-fts-fallback-'))
-const handlers = new Map()
vi.mock('electron', () => ({
app: {
@@ -18,24 +16,12 @@ vi.mock('electron', () => ({
getAppPath: () => process.cwd(),
on: () => undefined
},
+ BrowserWindow: { getAllWindows: () => [] },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString()
- },
- ipcMain: {
- handle: (channel: string, handler: IpcHandler) => handlers.set(channel, handler),
- on: () => undefined
- },
- BrowserWindow: { fromWebContents: () => undefined },
- clipboard: { readText: () => '', writeText: () => undefined },
- systemPreferences: {
- isTrustedAccessibilityClient: () => true,
- getMediaAccessStatus: () => 'granted'
- },
- shell: { openExternal: async () => undefined, openPath: async () => '' },
- desktopCapturer: { getSources: async () => [] },
- dialog: {}
+ }
}))
vi.mock('@huggingface/transformers', () => ({
@@ -45,129 +31,47 @@ vi.mock('@huggingface/transformers', () => ({
}
}))
-vi.mock('@lancedb/lancedb', () => ({
- connect: async () => ({
- tableNames: async () => [],
- openTable: async () => {
- throw new Error('no vector table in a fresh profile')
- }
- })
-}))
-
import { getDB } from '../database'
import { configureRuntime } from '../runtime-env'
-import { setupIPC } from '../ipc'
-import { llm } from '../llm'
-import { listProjects } from '../rag/store'
+import { ensureRagStoreSchema } from '../rag/store'
+import { universalSearch } from '../search'
+
+const CHAT_KEY = 'chat:aurora-chat'
beforeAll(() => {
configureRuntime({ dataDir: TMP_DIR })
- setupIPC()
- listProjects()
- vi.spyOn(llm, 'chat').mockResolvedValue('{"intent":"chat","urls":[]}')
- vi.spyOn(llm, 'chatStream').mockResolvedValue({
- content: 'Grounded answer.',
- toolCalls: [],
- finishReason: 'stop',
- maxTokens: 200
- })
- getDB()
- .prepare('INSERT INTO memories(content, source_app) VALUES (?, ?)')
- .run('Aurora release code obsidian café 日本語', 'synthetic')
- getDB().exec(`
- CREATE TABLE IF NOT EXISTS observations (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- summary TEXT NOT NULL,
- surface TEXT,
- url TEXT,
- ts DATETIME DEFAULT CURRENT_TIMESTAMP
- );
- CREATE VIRTUAL TABLE IF NOT EXISTS observation_fts USING fts5(
- summary, content='observations', content_rowid='id'
- );
- CREATE TABLE IF NOT EXISTS frames (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- image_path TEXT,
- text TEXT,
- surface TEXT,
- url TEXT,
- ts DATETIME
- );
- CREATE TABLE IF NOT EXISTS observation_frames (observation_id INTEGER, frame_id INTEGER);
- CREATE TABLE IF NOT EXISTS meetings (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- title TEXT,
- summary TEXT,
- transcript TEXT,
- started_at INTEGER
- );
- `)
- try {
- getDB().exec('ALTER TABLE entities ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0')
- } catch {
- /* already present */
- }
+ ensureRagStoreSchema()
+ const db = getDB()
+ db.prepare('INSERT INTO rag_conversations (id, title) VALUES (?, ?)').run(
+ 'aurora-chat',
+ 'Release'
+ )
+ db.prepare('INSERT INTO rag_messages (conversation_id, role, content) VALUES (?, ?, ?)').run(
+ 'aurora-chat',
+ 'user',
+ 'Aurora release code obsidian café 日本語'
+ )
})
afterAll(() => {
- vi.restoreAllMocks()
getDB().close()
configureRuntime({ dataDir: undefined })
fs.rmSync(TMP_DIR, { recursive: true, force: true })
})
-const event: IpcEvent = { sender: { send: () => undefined } }
-const queries = [
- 'Aurora?',
- '"Aurora"',
- 'title:Aurora',
- '(Aurora)',
- 'Aurora AND OR NOT NEAR',
- 'Aurora* ^release',
- 'café 日本語'
-]
+/** FTS5 syntax around words the record holds: each still finds it. */
+const wordQueries = ['Aurora?', '"Aurora"', '(Aurora)', 'Aurora* ^release', 'café 日本語']
+/** Column filters, operators and no words at all: each is plain text, never a syntax error. Every
+ * typed word is required, and a query with no words opens on the newest records. */
+const syntaxQueries = ['title:Aurora', 'Aurora AND OR NOT NEAR', '', ' ', '?:()*^"', 'NEAR(']
-describe('user text in IPC FTS fallbacks', () => {
- it.each(queries)('retrieves memory for %j without FTS syntax errors', async (query) => {
- const search = handlers.get('db:search-memories')!
- await expect(search(event, query)).resolves.toEqual(
- expect.arrayContaining([
- expect.objectContaining({ content: expect.stringContaining('Aurora') })
- ])
- )
- const chat = handlers.get('rag:chat')!
- const reply = (await chat(event, query, 'All', [], null, 'fts-fallback-chat')) as {
- answer: string
- context: { memories: Array<{ content: string }> }
- }
- expect(reply.answer).toBe('Grounded answer.')
- expect(reply.context.memories).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ content: expect.stringContaining('Aurora') })
- ])
- )
+describe('user text in search', () => {
+ it.each(wordQueries)('finds the chat for %j', async (query) => {
+ const results = await universalSearch(query, { semantic: false })
+ expect(results.map((result) => result.key)).toContain(CHAT_KEY)
})
- it.each(['', ' ', '?:()*^"'])('skips MATCH for empty terms in %j', async (query) => {
- const prepared = vi.spyOn(getDB(), 'prepare')
- try {
- await expect(handlers.get('db:search-memories')!(event, query)).resolves.toEqual([])
- const reply = (await handlers.get('rag:chat')!(
- event,
- query,
- 'All',
- [],
- null,
- 'fts-empty-chat'
- )) as {
- answer: string
- context: { memories: unknown[]; messages: unknown[] }
- }
- expect(reply.answer).toBe('Grounded answer.')
- expect(reply.context).toMatchObject({ memories: [], messages: [] })
- expect(prepared.mock.calls.some(([sql]) => /\bMATCH\b/.test(sql))).toBe(false)
- } finally {
- prepared.mockRestore()
- }
+ it.each(syntaxQueries)('searches %j without an FTS syntax error', async (query) => {
+ await expect(universalSearch(query, { semantic: false })).resolves.toBeInstanceOf(Array)
})
})
diff --git a/src/main/__tests__/ipc-query-logic.test.ts b/src/main/__tests__/ipc-query-logic.test.ts
deleted file mode 100644
index 5062c7c60..000000000
--- a/src/main/__tests__/ipc-query-logic.test.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { readFileSync } from 'fs'
-import { join } from 'path'
-import { DatabaseSync } from 'node:sqlite'
-import {
- tokenizeQuery,
- ftsMatchExpression,
- isGenerativeRequest,
- clipText,
- safeParseJson,
- isTrivialMessage,
- appNameLikeClause,
- STOPWORDS
-} from '../ipc-query-logic'
-
-describe('tokenizeQuery', () => {
- it('lowercases and splits on punctuation and whitespace', () => {
- expect(tokenizeQuery('Hello, World! Foobar')).toEqual(['hello', 'world', 'foobar'])
- })
-
- it('separates words at FTS punctuation and retains Unicode words', () => {
- expect(tokenizeQuery('title:Aurora café 日本語 नमस्ते')).toEqual([
- 'title',
- 'aurora',
- 'café',
- '日本語',
- 'नमस्ते'
- ])
- expect(ftsMatchExpression('?:()*^"')).toBe('')
- expect(ftsMatchExpression(' ')).toBe('')
- })
-
- it('drops tokens shorter than 3 chars', () => {
- // 'to' (2) and 'go' (2) are dropped; 'now' (3) kept
- expect(tokenizeQuery('go to now')).toEqual(['now'])
- })
-
- it('drops stopwords', () => {
- // every token here is a stopword → empty
- expect(tokenizeQuery('what do you know about the project', 6)).toEqual(['project'])
- // sanity: the words we dropped really are in STOPWORDS
- expect(STOPWORDS.has('what')).toBe(true)
- expect(STOPWORDS.has('about')).toBe(true)
- })
-
- it('de-duplicates tokens preserving first-seen order', () => {
- expect(tokenizeQuery('alpha beta alpha gamma beta')).toEqual(['alpha', 'beta', 'gamma'])
- })
-
- it('caps the result at maxTokens (default 6)', () => {
- const q = 'one2 two3 four5 six7 nine0 tenx elevenq twelvz'
- expect(tokenizeQuery(q).length).toBe(6)
- expect(tokenizeQuery(q, 2).length).toBe(2)
- })
-
- it('keeps underscores and hyphens inside a token', () => {
- expect(tokenizeQuery('foo_bar baz-qux')).toEqual(['foo_bar', 'baz-qux'])
- })
-})
-
-describe('ftsMatchExpression', () => {
- it('quotes each token as a phrase and OR-joins them (any-term recall)', () => {
- expect(ftsMatchExpression('cafe food near')).toBe('"cafe" OR "food" OR "near"')
- })
-
- it('quotes a hyphenated token instead of leaving it bare in the MATCH', () => {
- // tokenizeQuery keeps the hyphen, so a bare join produced `best-reviewed`, which FTS5 rejects.
- const expr = ftsMatchExpression('best-reviewed places')
- expect(expr).toContain('"best-reviewed"')
- // never a bare hyphenated bareword adjacent to whitespace/operators
- expect(expr).not.toMatch(/(^|\s)best-reviewed(\s|$)/)
- })
-
- it('uses quoted words when the stopword filter removes all terms', () => {
- // all stopwords / too short → no tokens; the fallback must still be a quoted phrase, not raw text
- expect(ftsMatchExpression('to of a')).toBe('"to" OR "of" OR "a"')
- })
-
- it('escapes an embedded double-quote so the phrase literal stays well-formed', () => {
- expect(ftsMatchExpression('say "hey" now')).toBe('"say" OR "hey" OR "now"')
- // punctuation in the fallback is removed
- expect(ftsMatchExpression('a "b')).toBe('"a" OR "b"')
- })
-
- // The real regression: run the built expression against a REAL FTS5 table (node:sqlite ships FTS5)
- // and prove SQLite accepts it. Before the fix the bare `best-reviewed` threw `no such column:
- // reviewed`, which failed the entire rag:chat retrieval and surfaced as "something went wrong".
- describe('against a real FTS5 table', () => {
- const withFts = (fn: (db: InstanceType) => void): void => {
- const db = new DatabaseSync(':memory:')
- try {
- db.exec('CREATE VIRTUAL TABLE entity_fts USING fts5(name, summary)')
- db.prepare('INSERT INTO entity_fts(name, summary) VALUES (?, ?)').run(
- 'Cafe Roma',
- 'best reviewed place near good food'
- )
- fn(db)
- } finally {
- db.close()
- }
- }
-
- const failingPrompt =
- 'Find the three best-reviewed places near me for a specific kind of food, open right now.'
-
- it('reproduces the original failure with the old bare-join form', () => {
- withFts((db) => {
- const bare = tokenizeQuery(failingPrompt).join(' OR ') // the pre-fix expression
- expect(bare).toContain('best-reviewed')
- expect(() =>
- db.prepare('SELECT name FROM entity_fts WHERE entity_fts MATCH ?').all(bare)
- ).toThrow(/no such column/)
- })
- })
-
- it('accepts the fixed expression and still matches the row', () => {
- withFts((db) => {
- const expr = ftsMatchExpression(failingPrompt)
- const rows = db
- .prepare('SELECT name FROM entity_fts WHERE entity_fts MATCH ?')
- .all(expr) as { name: string }[]
- expect(rows.map((r) => r.name)).toContain('Cafe Roma')
- })
- })
-
- it('never throws on hyphenated or punctuation-heavy input', () => {
- withFts((db) => {
- for (const q of [
- 'e-commerce back-end co-founder',
- 'twenty-first',
- 'a---b',
- 'x: y',
- '"quoted"'
- ]) {
- expect(() =>
- db
- .prepare('SELECT name FROM entity_fts WHERE entity_fts MATCH ?')
- .all(ftsMatchExpression(q))
- ).not.toThrow()
- }
- })
- })
- })
-})
-
-describe('isGenerativeRequest', () => {
- it('is true when a build verb and a code/UI noun co-occur', () => {
- expect(isGenerativeRequest('build a react app')).toBe(true)
- expect(isGenerativeRequest('write an svg')).toBe(true)
- expect(isGenerativeRequest('make a landing page')).toBe(true)
- expect(isGenerativeRequest('create a dashboard component')).toBe(true)
- })
-
- it('is false without a build verb', () => {
- // has the noun "app" but no verb
- expect(isGenerativeRequest('what is a react app')).toBe(false)
- })
-
- it('is false without a code/UI noun', () => {
- expect(isGenerativeRequest('write a poem about the sea')).toBe(false)
- })
-
- it('is false for empty / whitespace input', () => {
- expect(isGenerativeRequest('')).toBe(false)
- expect(isGenerativeRequest(' ')).toBe(false)
- })
-})
-
-describe('clipText', () => {
- it('returns text unchanged when under the limit', () => {
- expect(clipText('hello', 10)).toBe('hello')
- })
-
- it('returns text unchanged when exactly at the limit', () => {
- expect(clipText('hello', 5)).toBe('hello')
- })
-
- it('clips and appends an ellipsis when over the limit', () => {
- // limit 5 → keep 4 chars + ellipsis, length stays 5
- expect(clipText('hello world', 5)).toBe('hell…')
- })
-
- it('returns empty string for empty/undefined input', () => {
- expect(clipText('', 10)).toBe('')
- expect(clipText(undefined as unknown as string, 10)).toBe('')
- })
-})
-
-describe('safeParseJson', () => {
- it('parses valid JSON', () => {
- expect(safeParseJson('{"a":1}', { a: 0 })).toEqual({ a: 1 })
- })
-
- it('strips ```json fences before parsing', () => {
- expect(safeParseJson('```json\n{"store":true}\n```', { store: false })).toEqual({ store: true })
- })
-
- it('returns the fallback on invalid JSON', () => {
- const fallback = { store: false }
- expect(safeParseJson('not json at all', fallback)).toBe(fallback)
- })
-})
-
-describe('isTrivialMessage', () => {
- it('treats empty / whitespace as trivial', () => {
- expect(isTrivialMessage('')).toBe(true)
- expect(isTrivialMessage(' ')).toBe(true)
- })
-
- it('treats short pleasantries as trivial (case-insensitive, optional punctuation)', () => {
- expect(isTrivialMessage('hi')).toBe(true)
- expect(isTrivialMessage('OK')).toBe(true)
- expect(isTrivialMessage('Thanks!')).toBe(true)
- expect(isTrivialMessage('thank you.')).toBe(true)
- })
-
- it('treats a real question as non-trivial', () => {
- expect(isTrivialMessage('what did I work on yesterday?')).toBe(false)
- })
-
- it('a long message is never trivial even if it starts like a pleasantry', () => {
- expect(isTrivialMessage('hello there, can you help me draft an email')).toBe(false)
- })
-})
-
-describe('appNameLikeClause — single source for the optional app-name filter', () => {
- it('returns null for "All" or an empty/absent app (no filter = every app)', () => {
- expect(appNameLikeClause('All', 'source_app')).toBeNull()
- expect(appNameLikeClause('', 'source_app')).toBeNull()
- expect(appNameLikeClause(undefined, 'source_app')).toBeNull()
- })
-
- it('builds a LIKE clause + wildcarded param for a specific app', () => {
- expect(appNameLikeClause('Slack', 'source_app')).toEqual({
- clause: 'source_app LIKE ?',
- param: '%Slack%'
- })
- })
-
- it('uses the column the caller passes (the 4 rag:chat sites differ)', () => {
- expect(appNameLikeClause('Gmail', 'memories.source_app')).toEqual({
- clause: 'memories.source_app LIKE ?',
- param: '%Gmail%'
- })
- expect(appNameLikeClause('Gmail', 'c.app_name')).toEqual({
- clause: 'c.app_name LIKE ?',
- param: '%Gmail%'
- })
- })
-
- it('ipc.ts builds every app-name filter from this helper, not an inline guard', () => {
- // ipc.ts is a coverage-excluded IPC shell; guard the contract by reading source.
- const src = readFileSync(join(__dirname, '../ipc.ts'), 'utf8')
- expect(src).not.toMatch(/appName !== 'All'\)\s*\{[^}]*LIKE \?/s) // no inline appName+LIKE guard
- expect(src).toContain('appNameLikeClause')
- })
-})
diff --git a/src/main/__tests__/macos-artifact-integrity.integration.test.ts b/src/main/__tests__/macos-artifact-integrity.integration.test.ts
index c82ad1e0b..6c636a954 100644
--- a/src/main/__tests__/macos-artifact-integrity.integration.test.ts
+++ b/src/main/__tests__/macos-artifact-integrity.integration.test.ts
@@ -306,12 +306,7 @@ describe('macOS artifact integrity', () => {
const appOutDir = path.join(root, 'linux-unpacked')
const resources = path.join(appOutDir, 'resources')
writeAsarArchive(path.join(resources, 'app.asar'), ['out/main/index.js'])
- for (const variant of [
- 'llama',
- 'llama-cpu',
- 'llama-prism',
- 'llama-prism-cpu'
- ]) {
+ for (const variant of ['llama', 'llama-cpu', 'llama-prism', 'llama-prism-cpu']) {
const server = path.join(resources, 'bin', variant, 'llama-server')
fs.mkdirSync(path.dirname(server), { recursive: true })
fs.writeFileSync(server, 'fixture')
@@ -339,6 +334,13 @@ describe('macOS artifact integrity', () => {
fs.mkdirSync(path.dirname(whisperCpu), { recursive: true })
fs.writeFileSync(whisperCpu, 'fixture')
fs.writeFileSync(path.join(resources, 'bin', 'whisper', 'LICENSE'), 'fixture')
+ // Each Vulkan engine carries its loader, so a desktop without libvulkan1 still starts it.
+ await expect(verifyElectronBuilderArtifact(event)).rejects.toThrow(
+ 'installer input is missing required runtime: bin/llama/libvulkan.so.1'
+ )
+ for (const folder of ['llama', 'llama-prism', 'whisper']) {
+ fs.writeFileSync(path.join(resources, 'bin', folder, 'libvulkan.so.1'), 'fixture')
+ }
await expect(verifyElectronBuilderArtifact(event)).rejects.toThrow(
'installer input is missing required runtime: bin/ffmpeg'
)
diff --git a/src/main/__tests__/mcp-server-action-gate.test.ts b/src/main/__tests__/mcp-server-action-gate.test.ts
index ddbd4b5d7..8986fad16 100644
--- a/src/main/__tests__/mcp-server-action-gate.test.ts
+++ b/src/main/__tests__/mcp-server-action-gate.test.ts
@@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'
// it can't be exercised in-process. This is a source-level regression guard for
// the SECURITY contract instead: the action tools must be exposed ONLY to an
// authorized request. If someone un-gates them (registers unconditionally, or
-// stops threading isActionAuthorized into the build), this test fails.
+// stops threading authorizeActionRequest into the build), this test fails.
const SRC = readFileSync(path.join(__dirname, '..', 'mcp-server.ts'), 'utf8')
describe('mcp-server action-tool gate', () => {
diff --git a/src/main/__tests__/memory-rag-chat-lifecycle.integration.dbtest.ts b/src/main/__tests__/memory-rag-chat-lifecycle.integration.dbtest.ts
index b125672df..7f4591c59 100644
--- a/src/main/__tests__/memory-rag-chat-lifecycle.integration.dbtest.ts
+++ b/src/main/__tests__/memory-rag-chat-lifecycle.integration.dbtest.ts
@@ -4,8 +4,7 @@
* This drives the same IPC handlers as the renderer through real file reading, the
* document engine, SQLite persistence, the project search tool, prompt assembly,
* deletion, and profile reopen. Only process boundaries are controlled: Electron
- * registration, the MiniLM/LanceDB runtimes, and llama-server tokens over a real
- * loopback socket.
+ * registration, the embedding model, and llama-server tokens over a real loopback socket.
*/
import fs from 'node:fs'
import os from 'node:os'
@@ -66,15 +65,6 @@ vi.mock('@huggingface/transformers', () => ({
}
}))
-vi.mock('@lancedb/lancedb', () => ({
- connect: async () => ({
- tableNames: async () => [],
- openTable: async () => {
- throw new Error('no external vector table in this profile')
- }
- })
-}))
-
const event: IpcEvent = { sender: { send: () => undefined } }
let fake: FakeLlamaServer
@@ -109,8 +99,10 @@ async function createCaptureTablesNow(): Promise {
const { getDB } = await import('../database')
getDB().exec(`
CREATE TABLE IF NOT EXISTS observations (
- id INTEGER PRIMARY KEY AUTOINCREMENT, summary TEXT NOT NULL, surface TEXT, url TEXT,
- category TEXT NOT NULL DEFAULT 'work', ts DATETIME DEFAULT CURRENT_TIMESTAMP, source_key TEXT
+ id INTEGER PRIMARY KEY AUTOINCREMENT, summary TEXT NOT NULL, surface TEXT, surface_app TEXT,
+ url TEXT, source_session_id TEXT, source_key TEXT, memory_id INTEGER,
+ category TEXT NOT NULL DEFAULT 'work', engagement REAL NOT NULL DEFAULT 0,
+ salience REAL NOT NULL DEFAULT 0, ts DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE VIRTUAL TABLE IF NOT EXISTS observation_fts USING fts5(summary, content='observations', content_rowid='id');
CREATE TABLE IF NOT EXISTS frames (id INTEGER PRIMARY KEY AUTOINCREMENT, image_path TEXT, text TEXT, surface TEXT, url TEXT, ts DATETIME, source TEXT);
@@ -125,6 +117,26 @@ async function createCaptureTablesNow(): Promise {
}
const createCaptureTables = createCaptureTablesNow
+/** A captured screen moment, as Pro's capture pipeline stores it. */
+async function captureMoment(summary: string): Promise {
+ const { getDB } = await import('../database')
+ const id = Number(
+ getDB().prepare("INSERT INTO observations (summary, surface) VALUES (?, 'Notes')").run(summary)
+ .lastInsertRowid
+ )
+ getDB().prepare('INSERT INTO observation_fts(rowid, summary) VALUES (?, ?)').run(id, summary)
+ return id
+}
+
+/** The user deletes a captured moment (Pro's capture deletion removes the row and its text). */
+async function deleteMoment(id: number, summary: string): Promise {
+ const { getDB } = await import('../database')
+ getDB()
+ .prepare("INSERT INTO observation_fts(observation_fts, rowid, summary) VALUES ('delete', ?, ?)")
+ .run(id, summary)
+ getDB().prepare('DELETE FROM observations WHERE id = ?').run(id)
+}
+
async function invoke(channel: string, ...args: unknown[]): Promise {
return (await handler(channel)(event, ...args)) as T
}
@@ -157,13 +169,18 @@ async function askProject(
}
const projectFiles = (answer: ProjectAnswer): string[] => [
- ...new Set(answer.unified.filter(({ kind }) => kind === 'attachment').map(({ surface }) => surface))
+ ...new Set(
+ answer.unified.filter(({ kind }) => kind === 'attachment').map(({ surface }) => surface)
+ )
]
+/** Whether the answer drew on captured memory (a screen moment). */
const usedMemory = (answer: ProjectAnswer): boolean =>
- answer.unified.some(({ kind }) => kind === 'memory')
+ answer.unified.some(({ kind }) => kind === 'screen')
/** The knowledge-base list once every file has been read and indexed. */
-async function settled(projectId: string): Promise> {
+async function settled(
+ projectId: string
+): Promise> {
let rows: Array<{ id: number; name: string; enabled: boolean; state?: string }> = []
await vi.waitFor(
async () => {
@@ -215,8 +232,8 @@ describe('memory -> RAG -> scoped chat lifecycle', () => {
boundary.selectedPaths = [otherDocument]
expect(await invoke('projects:add-documents', otherProjectId)).toEqual({ added: 1 })
- const memory = await invoke<{ id: number }>('db:add-memory', capturedMemory, 'integration-test')
- expect(Number(memory.id)).toBeGreaterThan(0)
+ const memoryId = await captureMoment(capturedMemory)
+ expect(memoryId).toBeGreaterThan(0)
const currentConversation = 'cedar-current'
const siblingConversation = 'cedar-sibling'
@@ -261,7 +278,7 @@ describe('memory -> RAG -> scoped chat lifecycle', () => {
expect(lastModelPrompt()).not.toContain(capturedMemory)
expect(lastModelPrompt()).toContain(siblingContext)
- expect(await invoke('db:delete-memory', Number(memory.id))).toBe(true)
+ await deleteMoment(memoryId, capturedMemory)
const [oldDocument] = await invoke>(
'projects:list-documents',
selectedProjectId
@@ -302,7 +319,7 @@ describe('memory -> RAG -> scoped chat lifecycle', () => {
const reopenedDb = (await import('../database')).getDB()
expect(
- reopenedDb.prepare('SELECT COUNT(*) AS count FROM memories WHERE id = ?').get(memory.id)
+ reopenedDb.prepare('SELECT COUNT(*) AS count FROM observations WHERE id = ?').get(memoryId)
).toEqual({ count: 0 })
// The replaced file's text left the document index: nothing holds it any more.
expect(
@@ -328,22 +345,24 @@ describe('memory -> RAG -> scoped chat lifecycle', () => {
expect(lastModelPrompt()).not.toContain(otherChatContext)
})
- it('includes saved memory in an All-memory answer through the public chat handler', async () => {
+ it('includes captured memory in an All-memory answer through the public chat handler', async () => {
const fact = 'The Aurora release owner is Priya and the release code is obsidian.'
- const saved = await invoke<{ id: number }>('db:add-memory', fact, 'integration-test')
- expect(saved.id).toBeGreaterThan(0)
+ const id = await captureMoment(fact)
fake.enqueue(
- { content: '{"intent":"chat","urls":[]}' },
+ { toolCalls: [{ name: 'search_memory', args: { query: 'Aurora release owner' } }] },
{ content: 'Priya owns the Aurora release, code obsidian.' }
)
- const reply = await invoke<{
- answer: string
- context: { memories: Array<{ content: string }> }
- }>('rag:chat', 'Who owns the Aurora release?', 'All', [], null, 'aurora-memory-chat', false, 'aurora-memory-turn', false, [])
+ const reply = await invoke('tools:chat', 'Who owns the Aurora release?', [], {
+ conversationId: 'aurora-memory-chat',
+ allMemory: true
+ })
expect(reply.answer).toBe('Priya owns the Aurora release, code obsidian.')
- expect(reply.context.memories).toEqual(expect.arrayContaining([expect.objectContaining({ content: fact })]))
+ expect(reply.unified).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ kind: 'screen', snippet: expect.stringContaining(fact) })
+ ])
+ )
expect(lastModelPrompt()).toContain(fact)
- await invoke('db:delete-memory', saved.id)
+ await deleteMoment(id, fact)
})
-
})
diff --git a/src/main/__tests__/native-library-env.test.ts b/src/main/__tests__/native-library-env.test.ts
index 25fcf9128..d73002e8c 100644
--- a/src/main/__tests__/native-library-env.test.ts
+++ b/src/main/__tests__/native-library-env.test.ts
@@ -15,6 +15,7 @@ describe('nativeLibraryEnv', () => {
it('uses the Windows executable path only on Windows', () => {
expect(nativeLibraryEnv('win32', 'C:\\app\\bin', { PATH: 'C:\\Windows' })).toEqual({
+ GGML_VK_DISABLE_BFLOAT16: '1',
PATH: 'C:\\app\\bin;C:\\Windows'
})
})
diff --git a/src/main/__tests__/nvidia-driver.test.ts b/src/main/__tests__/nvidia-driver.test.ts
new file mode 100644
index 000000000..cfb9be06a
--- /dev/null
+++ b/src/main/__tests__/nvidia-driver.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { parseLinuxDriverVersion } from '../nvidia-driver'
+
+describe('NVIDIA driver version', () => {
+ it('reads the kernel module version from /proc/driver/nvidia/version', () => {
+ expect(
+ parseLinuxDriverVersion(
+ 'NVRM version: NVIDIA UNIX x86_64 Kernel Module 550.54.14 Thu Feb 22 01:44:30 UTC 2024\nGCC version: gcc version 12.3.0\n'
+ )
+ ).toBe('550.54.14')
+ expect(
+ parseLinuxDriverVersion(
+ 'NVRM version: NVIDIA UNIX Open Kernel Module for x86_64 580.82.07 Release Build (dvs-builder@U16)\n'
+ )
+ ).toBe('580.82.07')
+ })
+
+ it('returns null for text without a version', () => {
+ expect(parseLinuxDriverVersion('no driver here')).toBeNull()
+ })
+})
diff --git a/src/main/__tests__/ocr.dbtest.ts b/src/main/__tests__/ocr.dbtest.ts
index 5987c0bc7..3f8e686c5 100644
--- a/src/main/__tests__/ocr.dbtest.ts
+++ b/src/main/__tests__/ocr.dbtest.ts
@@ -8,7 +8,7 @@ vi.mock('electron', () => ({
app: { isPackaged: false, getAppPath: () => '/app' }
}))
-import { runBoxedOCR, runOCR } from '../ocr'
+import { runBoxedOCR } from '../ocr'
beforeEach(() => {
vi.clearAllMocks()
@@ -22,7 +22,6 @@ describe('OCR helper boundary', () => {
available: false,
degradedReason: 'ocr_helper_missing'
})
- await expect(runOCR('/tmp/frame.png')).resolves.toBe('')
expect(mocks.exec).not.toHaveBeenCalled()
})
@@ -58,12 +57,7 @@ describe('OCR helper boundary', () => {
})
})
- it('returns trimmed text and degrades cleanly after process failures', async () => {
- mocks.exec.mockImplementationOnce((_bin, _args, _options, callback) =>
- callback(null, { stdout: ' visible text \n', stderr: '' })
- )
- await expect(runOCR('/tmp/frame.png')).resolves.toBe('visible text')
-
+ it('degrades cleanly after process failures', async () => {
mocks.exec.mockImplementationOnce((_bin, _args, _options, callback) =>
callback(new Error('helper stopped'))
)
diff --git a/src/main/__tests__/onnx-device.test.ts b/src/main/__tests__/onnx-device.test.ts
index d6f4e7c45..6231c1017 100644
--- a/src/main/__tests__/onnx-device.test.ts
+++ b/src/main/__tests__/onnx-device.test.ts
@@ -1,15 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
-import { loadWithOnnxFallback, onnxDeviceCandidates } from '../onnx-device'
+import { loadWithOnnxFallback } from '../onnx-device'
describe('ONNX accelerator selection', () => {
- it('uses the native GPU provider first and always keeps a CPU fallback', () => {
- expect(onnxDeviceCandidates('darwin')).toEqual(['coreml', 'webgpu', 'cpu'])
- expect(onnxDeviceCandidates('win32')).toEqual(['cuda', 'dml', 'webgpu', 'cpu'])
- expect(onnxDeviceCandidates('linux')).toEqual(['cuda', 'webgpu', 'cpu'])
- expect(onnxDeviceCandidates('linux', 'webgpu')).toEqual(['webgpu', 'cuda', 'cpu'])
- expect(onnxDeviceCandidates('darwin', 'cpu')).toEqual(['cpu'])
- })
-
it('starts a fresh load with the next provider after a provider fails', async () => {
const load = vi.fn(async (device: string) => {
if (device === 'coreml') throw new Error('provider unavailable')
@@ -26,9 +18,12 @@ describe('ONNX accelerator selection', () => {
it('reports every provider failure when no runtime can start', async () => {
await expect(
- loadWithOnnxFallback(async (device) => {
- throw new Error(`${device} unavailable`)
- }, ['dml', 'cpu'])
+ loadWithOnnxFallback(
+ async (device) => {
+ throw new Error(`${device} unavailable`)
+ },
+ ['dml', 'cpu']
+ )
).rejects.toThrow('dml: dml unavailable | cpu: cpu unavailable')
})
})
diff --git a/src/main/__tests__/parser.test.ts b/src/main/__tests__/parser.test.ts
deleted file mode 100644
index 7d2f7f9fd..000000000
--- a/src/main/__tests__/parser.test.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-/**
- * Tests for chat message parsers
- *
- * These tests verify that each platform's parser correctly extracts:
- * - User messages
- * - Assistant messages
- * - Chat titles
- * - Window titles
- * - Browser URLs
- */
-
-import { describe, it, expect } from 'vitest'
-import {
- parseClaudeDesktopOutput,
- parseClaudeWebOutput,
- parseChatGPTOutput,
- parseGeminiOutput
-} from '../parsers'
-
-// ============== TEST CASES ==============
-
-describe('Claude Desktop Parser', () => {
- const sampleOutput = `[WINDOW_TITLE] Claude
-[CHAT_TITLE] Debugging TypeScript Issues
-[USER] Can you help me debug this error?
-[ASSISTANT] Of course! Please share the error message.
-[USER] TypeError: Cannot read property 'foo' of undefined
-[ASSISTANT] This error typically occurs when you try to access a property on a variable that is undefined.
-[ASSISTANT] Here are some common causes:
-[ASSISTANT] 1. The object wasn't initialized
-[ASSISTANT] 2. An async operation hasn't completed`
-
- it('should extract window title', () => {
- const result = parseClaudeDesktopOutput(sampleOutput)
- expect(result.windowTitle).toBe('Claude')
- })
-
- it('should extract chat title', () => {
- const result = parseClaudeDesktopOutput(sampleOutput)
- expect(result.chatTitle).toBe('Debugging TypeScript Issues')
- })
-
- it('should extract user messages', () => {
- const result = parseClaudeDesktopOutput(sampleOutput)
- const userMessages = result.messages.filter((m) => m.role === 'user')
- expect(userMessages.length).toBe(2)
- expect(userMessages[0]!.content).toBe('Can you help me debug this error?')
- expect(userMessages[1]!.content).toBe("TypeError: Cannot read property 'foo' of undefined")
- })
-
- it('should extract assistant messages and combine consecutive ones', () => {
- const result = parseClaudeDesktopOutput(sampleOutput)
- const assistantMessages = result.messages.filter((m) => m.role === 'assistant')
- expect(assistantMessages.length).toBe(2)
- expect(assistantMessages[0]!.content).toBe('Of course! Please share the error message.')
- expect(assistantMessages[1]!.content).toContain('This error typically occurs')
- expect(assistantMessages[1]!.content).toContain("1. The object wasn't initialized")
- })
-
- it('should handle empty input', () => {
- const result = parseClaudeDesktopOutput('')
- expect(result.messages.length).toBe(0)
- })
-})
-
-describe('Claude Web Parser', () => {
- const sampleOutput = `[WINDOW_TITLE] Chat - Claude - Brave
-[BROWSER_URL] claude.ai/chat/abc123
-[CHAT_TITLE] API Integration Help
-[USER] How do I use the fetch API?
-[ASSISTANT] The fetch API is a modern way to make HTTP requests.
-[ASSISTANT] Here's a basic example:
-[USER] Thanks! What about error handling?
-[ASSISTANT] You can use try/catch or .catch() for error handling.`
-
- it('should extract browser URL', () => {
- const result = parseClaudeWebOutput(sampleOutput)
- expect(result.browserUrl).toBe('claude.ai/chat/abc123')
- })
-
- it('should extract chat title', () => {
- const result = parseClaudeWebOutput(sampleOutput)
- expect(result.chatTitle).toBe('API Integration Help')
- })
-
- it('should extract all messages in order', () => {
- const result = parseClaudeWebOutput(sampleOutput)
- expect(result.messages.length).toBe(4)
- expect(result.messages[0]!.role).toBe('user')
- expect(result.messages[1]!.role).toBe('assistant')
- expect(result.messages[2]!.role).toBe('user')
- expect(result.messages[3]!.role).toBe('assistant')
- })
-
- it('should combine consecutive assistant messages', () => {
- const result = parseClaudeWebOutput(sampleOutput)
- const firstAssistant = result.messages[1]!
- expect(firstAssistant.content).toContain('The fetch API')
- expect(firstAssistant.content).toContain('basic example')
- })
-})
-
-describe('ChatGPT Parser', () => {
- const sampleOutput = `[WINDOW_TITLE] ChatGPT - Brave
-[BROWSER_URL] chatgpt.com/c/abc123
-[CHAT_TITLE] Team Context Reminder
-[USER] You said: Rameez and me work together at Wednesday Solutions
-[ASSISTANT] ChatGPT said: Got it. I'll remember that.
-[ASSISTANT] If you want, I can remember this and use it for context.`
-
- it('should extract chat title', () => {
- const result = parseChatGPTOutput(sampleOutput)
- expect(result.chatTitle).toBe('Team Context Reminder')
- })
-
- it('should handle "You said:" role labels', () => {
- const result = parseChatGPTOutput(sampleOutput)
- const userMessages = result.messages.filter((m) => m.role === 'user')
- expect(userMessages.length).toBeGreaterThanOrEqual(1)
- })
-
- it('should handle "ChatGPT said:" role labels', () => {
- const result = parseChatGPTOutput(sampleOutput)
- const assistantMessages = result.messages.filter((m) => m.role === 'assistant')
- expect(assistantMessages.length).toBeGreaterThanOrEqual(1)
- })
-
- it('should filter out noise like "Chat history"', () => {
- const noiseOutput = `[USER] Chat history
-[USER] Search chats
-[USER] Actual question here`
- const result = parseChatGPTOutput(noiseOutput)
- const contents = result.messages.map((m) => m.content)
- expect(contents).not.toContain('Chat history')
- expect(contents).not.toContain('Search chats')
- })
-
- it('should extract browser URL', () => {
- const result = parseChatGPTOutput(sampleOutput)
- expect(result.browserUrl).toBe('chatgpt.com/c/abc123')
- })
-})
-
-describe('Gemini Parser', () => {
- const sampleOutput = `[WINDOW_TITLE] Google Gemini - Brave - Work
-[BROWSER_URL] gemini.google.com/u/3/app/880725c54adcc1b7
-[CHAT_TITLE] System Check and Assistance Offered
-[USER] Test
-[ASSISTANT] System Check: Operational
-[ASSISTANT] I am reading you loud and clear. Everything is working perfectly on my end.
-[ASSISTANT] How can I assist you today?
-[ASSISTANT] Would you like to start a new draft, brainstorm ideas, or search for information?`
-
- it('should extract chat title', () => {
- const result = parseGeminiOutput(sampleOutput)
- expect(result.chatTitle).toBe('System Check and Assistance Offered')
- })
-
- it('should extract browser URL', () => {
- const result = parseGeminiOutput(sampleOutput)
- expect(result.browserUrl).toBe('gemini.google.com/u/3/app/880725c54adcc1b7')
- })
-
- it('should extract user message', () => {
- const result = parseGeminiOutput(sampleOutput)
- const userMessages = result.messages.filter((m) => m.role === 'user')
- expect(userMessages.length).toBe(1)
- expect(userMessages[0]!.content).toBe('Test')
- })
-
- it('should combine consecutive assistant messages', () => {
- const result = parseGeminiOutput(sampleOutput)
- const assistantMessages = result.messages.filter((m) => m.role === 'assistant')
- expect(assistantMessages.length).toBe(1)
- expect(assistantMessages[0]!.content).toContain('System Check: Operational')
- expect(assistantMessages[0]!.content).toContain('How can I assist you today?')
- })
-
- it('should filter out Gemini UI noise', () => {
- const noiseOutput = `[USER] Gemini
-[USER] Chats
-[USER] New chat
-[USER] Actual user question`
- const result = parseGeminiOutput(noiseOutput)
- expect(result.messages.length).toBe(1)
- expect(result.messages[0]!.content).toBe('Actual user question')
- })
-
- it('should handle window title', () => {
- const result = parseGeminiOutput(sampleOutput)
- expect(result.windowTitle).toBe('Google Gemini - Brave - Work')
- })
-})
-
-describe('Cross-platform isolation', () => {
- it('Claude Desktop parser should not use ChatGPT noise filtering', () => {
- // "Chat history" (exact match) should be filtered by ChatGPT but not Claude
- const input = `[USER] Chat history`
- const claudeResult = parseClaudeDesktopOutput(input)
- const chatgptResult = parseChatGPTOutput(input)
-
- // Claude should keep it, ChatGPT should filter it
- expect(claudeResult.messages.length).toBe(1)
- expect(chatgptResult.messages.length).toBe(0)
- })
-
- it('Gemini parser should filter Gemini-specific noise that ChatGPT does not', () => {
- // "Show thinking" is Gemini-specific noise, not filtered by ChatGPT
- const input = `[USER] Show thinking`
- const geminiResult = parseGeminiOutput(input)
- const chatgptResult = parseChatGPTOutput(input)
-
- // Gemini should filter it, ChatGPT should keep it
- expect(geminiResult.messages.length).toBe(0)
- expect(chatgptResult.messages.length).toBe(1)
- })
-
- it('Each parser should have independent state', () => {
- // Run parsers in sequence and verify no state leakage
- const input1 = `[USER] Message 1`
- const input2 = `[ASSISTANT] Message 2`
-
- parseClaudeDesktopOutput(input1)
- parseClaudeWebOutput(input1)
- parseChatGPTOutput(input1)
- parseGeminiOutput(input1)
-
- // Each subsequent call should start fresh
- const result = parseClaudeDesktopOutput(input2)
- expect(result.messages.length).toBe(1)
- expect(result.messages[0]!.role).toBe('assistant')
- })
-})
diff --git a/src/main/__tests__/performance-pack.test.ts b/src/main/__tests__/performance-pack.test.ts
index 0a8f12997..8bd071f31 100644
--- a/src/main/__tests__/performance-pack.test.ts
+++ b/src/main/__tests__/performance-pack.test.ts
@@ -5,13 +5,14 @@ import path from 'node:path'
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
-const fixture = vi.hoisted(() => ({ root: '', driver: true }))
+const fixture = vi.hoisted(() => ({ root: '', driver: true, driverVersion: null as string | null }))
vi.mock('../runtime-env', () => ({
dataDir: () => fixture.root,
resourceFile: () => path.join(fixture.root, 'performance-packs.json')
}))
-vi.mock('../transcription/whisper-runtime', () => ({
+vi.mock('../nvidia-driver', () => ({
+ cachedNvidiaDriverVersion: () => fixture.driverVersion,
hasLinuxNvidiaDriver: () => fixture.driver,
hasWindowsNvidiaDriver: () => fixture.driver
}))
@@ -19,12 +20,19 @@ vi.mock('../transcription/whisper-runtime', () => ({
const version = 'test-v1'
const url = `https://runtime.getoffgridai.co/desktop/cuda/linux/${version}.tar.gz`
let originalPlatform: PropertyDescriptor | undefined
+let originalArch: PropertyDescriptor | undefined
const originalLibraryPath = process.env.LD_LIBRARY_PATH
-function archive(withSpeechLibraries = true): Buffer {
+/** The pack's chat engine, as a script: `--list-devices` reports a CUDA GPU unless told not to. */
+function archive(withSpeechLibraries = true, withGpu = true): Buffer {
const source = path.join(fixture.root, 'source', 'bin', 'llama-cuda')
fs.mkdirSync(source, { recursive: true })
- fs.writeFileSync(path.join(source, 'llama-server'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
+ const devices = withGpu ? ' CUDA0: Test GPU (8192 MiB, 8000 MiB free)\\n' : ''
+ fs.writeFileSync(
+ path.join(source, 'llama-server'),
+ `#!/bin/sh\nprintf 'Available devices:\\n${devices}'\nexit 0\n`,
+ { mode: 0o755 }
+ )
if (withSpeechLibraries) {
const onnx = path.join(fixture.root, 'source', 'bin', 'onnx-cuda')
fs.mkdirSync(onnx, { recursive: true })
@@ -38,13 +46,18 @@ function archive(withSpeechLibraries = true): Buffer {
}
function manifest(bytes: Buffer, digest = createHash('sha256').update(bytes).digest('hex')): void {
- fs.writeFileSync(path.join(fixture.root, 'performance-packs.json'), JSON.stringify({
- schemaVersion: 1,
- cuda: { linux: { url, sha256: digest, bytes: bytes.length, version } }
- }))
+ fs.writeFileSync(
+ path.join(fixture.root, 'performance-packs.json'),
+ JSON.stringify({
+ schemaVersion: 1,
+ cuda: { linux: { url, sha256: digest, bytes: bytes.length, version } }
+ })
+ )
}
-async function settled(phase: 'installed' | 'failed'): Promise>> {
+async function settled(
+ phase: 'installed' | 'failed'
+): Promise>> {
const { performancePackStatus } = await import('../performance-pack')
for (let attempt = 0; attempt < 100; attempt++) {
const status = performancePackStatus()
@@ -58,8 +71,12 @@ beforeEach(() => {
vi.resetModules()
fixture.root = fs.mkdtempSync(path.join(os.tmpdir(), 'ogad-pack-test-'))
fixture.driver = true
+ fixture.driverVersion = null
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+ originalArch = Object.getOwnPropertyDescriptor(process, 'arch')
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
+ // The pack carries x64 engines only.
+ Object.defineProperty(process, 'arch', { value: 'x64', configurable: true })
})
afterEach(() => {
@@ -68,10 +85,18 @@ afterEach(() => {
if (originalLibraryPath === undefined) delete process.env.LD_LIBRARY_PATH
else process.env.LD_LIBRARY_PATH = originalLibraryPath
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
+ if (originalArch) Object.defineProperty(process, 'arch', originalArch)
fs.rmSync(fixture.root, { recursive: true, force: true })
})
describe('optional NVIDIA performance pack', () => {
+ it('offers no download on an arm64 machine', async () => {
+ manifest(archive())
+ Object.defineProperty(process, 'arch', { value: 'arm64', configurable: true })
+ const pack = await import('../performance-pack')
+ expect(pack.performancePackStatus().phase).toBe('unavailable')
+ })
+
it('offers no download without a pinned manifest or an NVIDIA driver', async () => {
const pack = await import('../performance-pack')
expect(pack.performancePackStatus().phase).toBe('unavailable')
@@ -103,16 +128,103 @@ describe('optional NVIDIA performance pack', () => {
expect(pack.startPerformancePack().phase).toBe('downloading')
const status = await settled('installed')
expect(status.downloadedBytes).toBe(bytes.length)
- expect(status.restartRequired).toBe(true)
+ expect(status.device).toBe('Test GPU')
expect(fetcher).toHaveBeenCalledOnce()
- expect(fs.existsSync(path.join(process.env.OFFGRID_PERFORMANCE_PACK_BIN!, 'llama-cuda', 'llama-server'))).toBe(true)
- expect(pack.prepareInstalledOnnxCudaLibraries()).toBe(true)
- expect(process.env.LD_LIBRARY_PATH).toContain('/bin/onnx-cuda')
- expect(pack.prepareInstalledOnnxCudaLibraries()).toBe(false)
+ expect(
+ fs.existsSync(
+ path.join(process.env.OFFGRID_PERFORMANCE_PACK_BIN!, 'llama-cuda', 'llama-server')
+ )
+ ).toBe(true)
+ // The app never relaunches itself for the pack's libraries.
+ expect(process.env.LD_LIBRARY_PATH ?? '').not.toContain('onnx-cuda')
expect(pack.startPerformancePack().phase).toBe('installed')
- vi.resetModules()
- const afterRestart = await import('../performance-pack')
- expect(afterRestart.performancePackStatus().restartRequired).toBe(false)
+ })
+
+ it('does not offer the pack on a driver older than its floor', async () => {
+ const bytes = archive()
+ manifest(bytes)
+ fixture.driverVersion = '515.65.01'
+ const pack = await import('../performance-pack')
+ expect(pack.performancePackStatus()).toMatchObject({
+ phase: 'driver-too-old',
+ driver: '515.65.01',
+ minDriver: '525.60.13'
+ })
+ expect(pack.startPerformancePack().phase).toBe('driver-too-old')
+ })
+
+ it('installs a complete archive already on disk without downloading it again', async () => {
+ const bytes = archive()
+ const hash = createHash('sha256').update(bytes).digest('hex')
+ manifest(bytes, hash)
+ const packRoot = path.join(
+ fixture.root,
+ 'performance-packs',
+ `cuda-${version}-${hash.slice(0, 12)}`
+ )
+ fs.mkdirSync(path.dirname(packRoot), { recursive: true })
+ fs.writeFileSync(`${packRoot}.tar.gz.part`, bytes)
+ const fetcher = vi.fn()
+ vi.stubGlobal('fetch', fetcher)
+ const pack = await import('../performance-pack')
+ pack.startPerformancePack()
+ expect((await settled('installed')).device).toBe('Test GPU')
+ expect(fetcher).not.toHaveBeenCalled()
+ })
+
+ it('starts over after HTTP 416 instead of failing for good', async () => {
+ const bytes = archive()
+ const hash = createHash('sha256').update(bytes).digest('hex')
+ manifest(bytes, hash)
+ const packRoot = path.join(
+ fixture.root,
+ 'performance-packs',
+ `cuda-${version}-${hash.slice(0, 12)}`
+ )
+ fs.mkdirSync(path.dirname(packRoot), { recursive: true })
+ fs.writeFileSync(`${packRoot}.tar.gz.part`, bytes.subarray(0, 10))
+ const fetcher = vi.fn(async (_url: string, options: RequestInit) =>
+ options.headers
+ ? new Response(null, { status: 416 })
+ : new Response(new Uint8Array(bytes), { status: 200 })
+ )
+ vi.stubGlobal('fetch', fetcher)
+ const pack = await import('../performance-pack')
+ pack.startPerformancePack()
+ expect((await settled('installed')).bytes).toBe(bytes.length)
+ expect(fetcher).toHaveBeenCalledTimes(2)
+ })
+
+ it('replaces a partial file larger than the pack', async () => {
+ const bytes = archive()
+ const hash = createHash('sha256').update(bytes).digest('hex')
+ manifest(bytes, hash)
+ const packRoot = path.join(
+ fixture.root,
+ 'performance-packs',
+ `cuda-${version}-${hash.slice(0, 12)}`
+ )
+ fs.mkdirSync(path.dirname(packRoot), { recursive: true })
+ fs.writeFileSync(`${packRoot}.tar.gz.part`, Buffer.concat([bytes, Buffer.from('extra')]))
+ const fetcher = vi.fn(
+ async (_url: string, _options: RequestInit) =>
+ new Response(new Uint8Array(bytes), { status: 200 })
+ )
+ vi.stubGlobal('fetch', fetcher)
+ const pack = await import('../performance-pack')
+ pack.startPerformancePack()
+ expect((await settled('installed')).bytes).toBe(bytes.length)
+ expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ headers: undefined })
+ })
+
+ it('keeps the files out of use when the GPU does not start with them', async () => {
+ const bytes = archive(true, false)
+ manifest(bytes)
+ vi.stubGlobal('fetch', async () => new Response(new Uint8Array(bytes), { status: 200 }))
+ const pack = await import('../performance-pack')
+ pack.startPerformancePack()
+ expect((await settled('failed')).error).toMatch(/GPU did not start/)
+ expect(process.env.OFFGRID_PERFORMANCE_PACK_BIN).toBeUndefined()
})
it('rejects an archive with the wrong hash and removes the partial file', async () => {
@@ -122,7 +234,7 @@ describe('optional NVIDIA performance pack', () => {
const pack = await import('../performance-pack')
pack.startPerformancePack()
const status = await settled('failed')
- expect(status.error).toMatch(/hash check/)
+ expect(status.error).toMatch(/damaged/)
expect(process.env.OFFGRID_PERFORMANCE_PACK_BIN).toBeUndefined()
expect(fs.readdirSync(path.join(fixture.root, 'performance-packs'))).toEqual([])
})
@@ -140,7 +252,11 @@ describe('optional NVIDIA performance pack', () => {
const bytes = archive()
const hash = createHash('sha256').update(bytes).digest('hex')
manifest(bytes, hash)
- const packRoot = path.join(fixture.root, 'performance-packs', `cuda-${version}-${hash.slice(0, 12)}`)
+ const packRoot = path.join(
+ fixture.root,
+ 'performance-packs',
+ `cuda-${version}-${hash.slice(0, 12)}`
+ )
fs.mkdirSync(path.dirname(packRoot), { recursive: true })
const prefix = Math.floor(bytes.length / 2)
fs.writeFileSync(`${packRoot}.tar.gz.part`, bytes.subarray(0, prefix))
@@ -162,14 +278,22 @@ describe('optional NVIDIA performance pack', () => {
const bytes = archive()
const hash = createHash('sha256').update(bytes).digest('hex')
manifest(bytes, hash)
- const packRoot = path.join(fixture.root, 'performance-packs', `cuda-${version}-${hash.slice(0, 12)}`)
+ const packRoot = path.join(
+ fixture.root,
+ 'performance-packs',
+ `cuda-${version}-${hash.slice(0, 12)}`
+ )
fs.mkdirSync(path.dirname(packRoot), { recursive: true })
const prefix = Math.floor(bytes.length / 2)
fs.writeFileSync(`${packRoot}.tar.gz.part`, bytes.subarray(0, prefix))
- vi.stubGlobal('fetch', async () => new Response(new Uint8Array(bytes.subarray(prefix)), {
- status: 206,
- headers: { 'Content-Range': `bytes 0-${bytes.length - 1}/${bytes.length}` }
- }))
+ vi.stubGlobal(
+ 'fetch',
+ async () =>
+ new Response(new Uint8Array(bytes.subarray(prefix)), {
+ status: 206,
+ headers: { 'Content-Range': `bytes 0-${bytes.length - 1}/${bytes.length}` }
+ })
+ )
const pack = await import('../performance-pack')
pack.startPerformancePack()
expect((await settled('failed')).error).toMatch(/invalid resume range/)
diff --git a/src/main/__tests__/pure-branch-fills.test.ts b/src/main/__tests__/pure-branch-fills.test.ts
index 7384149bf..15e522326 100644
--- a/src/main/__tests__/pure-branch-fills.test.ts
+++ b/src/main/__tests__/pure-branch-fills.test.ts
@@ -1,12 +1,10 @@
-// Remaining single-branch fills for three already-well-covered pure modules. Each
+// Remaining single-branch fills for two already-well-covered pure modules. Each
// case targets one uncovered branch left by the module's main test file:
// - llama-error: an "unknown architecture" stderr with NO quoted arch name.
// - chat-health: "ready" when there is no active model (the ?? undefined branch).
-// - search-ranking: matchScore ignoring an empty-string term (the `t ?` false arm).
import { describe, it, expect } from 'vitest'
import { classifyLlamaError } from '../llama-error'
import { decideChatStatus } from '../chat-health'
-import { matchScore } from '../search-ranking'
describe('classifyLlamaError - architecture without a quoted name', () => {
it('reports engine_outdated with the generic reason when no arch is quoted', () => {
@@ -32,13 +30,3 @@ describe('decideChatStatus - ready with no active model', () => {
})
})
})
-
-describe('matchScore - empty term is ignored', () => {
- it('skips an empty-string term (the falsy-term branch) but still counts real ones', () => {
- expect(matchScore('grid grid grid', ['', 'grid'])).toBe(3)
- })
-
- it('an all-empty term list scores 0', () => {
- expect(matchScore('grid', ['', ''])).toBe(0)
- })
-})
diff --git a/src/main/__tests__/rag-empty-memory.dbtest.ts b/src/main/__tests__/rag-empty-memory.dbtest.ts
index 9daeffcaf..51bbb2bff 100644
--- a/src/main/__tests__/rag-empty-memory.dbtest.ts
+++ b/src/main/__tests__/rag-empty-memory.dbtest.ts
@@ -1,9 +1,9 @@
-// Fresh-profile integration coverage for RELEASE_TEST_CHECKLIST #35.
+// Fresh-profile integration coverage for RELEASE_TEST_CHECKLIST #34 and #35.
//
-// This invokes the REAL `rag:chat` handler registered by setupIPC, over the REAL
-// SQLite schema, retrieval queries, prompt assembly, modality queue, and LLM
-// transport. Only true process boundaries are faked: Electron registration,
-// MiniLM embeddings, LanceDB, and llama-server (a real loopback HTTP socket).
+// This invokes the REAL `rag:chat` handler registered by setupIPC and the REAL chat tools loop
+// (toolChat → search_memory → the retrieval engine), over the REAL SQLite schema, prompt assembly,
+// modality queue, and LLM transport. Only true process boundaries are faked: Electron
+// registration, the embedding model, and llama-server (a real loopback HTTP socket).
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import fs from 'fs'
import os from 'os'
@@ -50,19 +50,11 @@ vi.mock('@huggingface/transformers', () => ({
pipeline: async () => async () => ({ data: new Float32Array(384).fill(0.01) })
}))
-vi.mock('@lancedb/lancedb', () => ({
- connect: async () => ({
- tableNames: async () => [],
- openTable: async () => {
- throw new Error('no vector table in a fresh profile')
- }
- })
-}))
-
import { getDB, saveSetting } from '../database'
import { setupIPC } from '../ipc'
import { llm } from '../llm'
import { listProjects } from '../rag/store'
+import { toolChat } from '../tools'
let fake: FakeLlamaServer
@@ -80,11 +72,19 @@ beforeAll(async () => {
// empty here so universalSearch runs its complete production query set against
// a genuinely empty corpus instead of short-circuiting on missing optional DDL.
getDB().exec(`
+ -- As Pro's crm/schema.ts creates it.
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
summary TEXT NOT NULL,
surface TEXT,
+ surface_app TEXT,
url TEXT,
+ source_session_id TEXT,
+ source_key TEXT,
+ memory_id INTEGER,
+ category TEXT NOT NULL DEFAULT 'work',
+ engagement REAL NOT NULL DEFAULT 0,
+ salience REAL NOT NULL DEFAULT 0,
ts DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE VIRTUAL TABLE IF NOT EXISTS observation_fts USING fts5(
@@ -142,7 +142,7 @@ describe('rag:chat on an empty memory corpus', () => {
expect(getActiveTranscription().isAvailable()).toBeTypeOf('boolean')
})
- it('answers without context and releases the chat seam for the next turn', async () => {
+ it('answers a plain turn on an empty profile and releases the chat seam for the next', async () => {
const handler = handlers.get('rag:chat')
expect(handler).toBeTypeOf('function')
expect(
@@ -158,20 +158,13 @@ describe('rag:chat on an empty memory corpus', () => {
.get()
).toEqual({ count: 0 })
- const streamed: Array<{ channel: string; payload: unknown }> = []
- const event: IpcEvent = {
- sender: {
- send: (channel, payload) => streamed.push({ channel, payload })
- }
- }
-
+ const event: IpcEvent = { sender: { send: () => undefined } }
fake.enqueue(
- { content: '{"intent":"chat","urls":[]}' },
{ content: 'I do not have any saved memory yet, but I can still help.' },
- { content: '{"intent":"chat","urls":[]}' },
{ content: 'This second response is ready.' }
)
+ // rag:chat is the direct model call (no retrieval); memory is the chat tools loop's.
const first = (await handler!(
event,
'What do you remember about me?',
@@ -183,44 +176,19 @@ describe('rag:chat on an empty memory corpus', () => {
'empty-memory-turn-1',
false,
[]
- )) as {
- answer: string
- context: Record
- }
-
+ )) as { answer: string; context?: unknown }
expect(first.answer).toBe('I do not have any saved memory yet, but I can still help.')
expect(first.answer).not.toMatch(/something went wrong/i)
- expect(first.context).toMatchObject({
- memories: [],
- messages: [],
- summaries: [],
- entities: [],
- entityFacts: [],
- unified: []
- })
- const modelPrompt = JSON.stringify(fake.requests[1]?.messages ?? [])
- expect(modelPrompt).toContain('RELEVANT MEMORIES:\\n(none)')
- expect(modelPrompt).toContain('RELEVANT MESSAGES:\\n(none)')
- expect(modelPrompt).toContain('RELEVANT SUMMARIES:\\n(none)')
- expect(modelPrompt).toContain('RELEVANT ENTITIES:\\n(none)')
- expect(modelPrompt).toContain('RELEVANT ENTITY FACTS:\\n(none)')
- expect(streamed).toContainEqual({
- channel: 'rag:stream',
- payload: {
- streamId: 'empty-memory-turn-1',
- type: 'step',
- step: {
- kind: 'memory',
- counts: { memories: 0, messages: 0, summaries: 0, entities: 0, facts: 0, unified: 0 }
- }
- }
- })
+ expect(first.context).toBeUndefined()
const second = (await handler!(
event,
'Can I ask another question?',
'All',
- [],
+ [
+ { role: 'user', content: 'What do you remember about me?' },
+ { role: 'assistant', content: first.answer }
+ ],
null,
'fresh-chat',
false,
@@ -228,14 +196,13 @@ describe('rag:chat on an empty memory corpus', () => {
false,
[]
)) as { answer: string }
-
expect(second.answer).toBe('This second response is ready.')
- expect(fake.requests).toHaveLength(4)
+ expect(fake.requests).toHaveLength(2)
+ expect(JSON.stringify(fake.requests[1])).toContain('What do you remember about me?')
})
it('retrieves seeded local memory and returns its citation in All memory mode (#34)', async () => {
- const handler = handlers.get('rag:chat')
- expect(handler).toBeTypeOf('function')
+ fake.requests.length = 0
const db = getDB()
const inserted = db
.prepare(
@@ -251,55 +218,28 @@ describe('rag:chat on an empty memory corpus', () => {
inserted.lastInsertRowid,
'Project Aurora uses the release code obsidian.'
)
-
- const streamed: Array<{ channel: string; payload: unknown }> = []
- const event: IpcEvent = {
- sender: {
- send: (channel, payload) => streamed.push({ channel, payload })
- }
- }
fake.enqueue(
- { content: '{"intent":"chat","urls":[]}' },
+ { toolCalls: [{ name: 'search_memory', args: { query: 'Aurora release code' } }] },
{ content: 'The Aurora release code is obsidian [S1].' }
)
- const result = (await handler!(
- event,
- 'Aurora release code obsidian',
- 'All',
- [],
- null,
- 'seeded-memory-chat',
- false,
- 'seeded-memory-turn',
- false,
- []
- )) as {
- answer: string
- context: { unified: Array<{ refId: number; snippet: string; surface: string }> }
- }
+ const result = await toolChat('Aurora release code obsidian', [], {
+ conversationId: 'seeded-memory-chat',
+ allMemory: true
+ })
expect(result.answer).toBe('The Aurora release code is obsidian [S1].')
- expect(result.context.unified).toEqual([
+ expect(result.unified).toEqual([
expect.objectContaining({
+ key: `obs:${Number(inserted.lastInsertRowid)}`,
refId: Number(inserted.lastInsertRowid),
- snippet: 'Project Aurora uses the release code obsidian.',
surface: 'Synthetic release notes'
})
])
+ expect(result.unified[0]!.snippet).toContain('Project Aurora uses the release code obsidian.')
+ // The answer's model call saw the cited source with its tag.
const answerPrompt = JSON.stringify(fake.requests.at(-1)?.messages ?? [])
expect(answerPrompt).toContain('[S1]')
expect(answerPrompt).toContain('Project Aurora uses the release code obsidian.')
- expect(streamed).toContainEqual({
- channel: 'rag:stream',
- payload: {
- streamId: 'seeded-memory-turn',
- type: 'step',
- step: {
- kind: 'memory',
- counts: expect.objectContaining({ unified: 1 })
- }
- }
- })
})
})
diff --git a/src/main/__tests__/rag-store-integration.dbtest.ts b/src/main/__tests__/rag-store-integration.dbtest.ts
index 72d2ebe95..5caf3964b 100644
--- a/src/main/__tests__/rag-store-integration.dbtest.ts
+++ b/src/main/__tests__/rag-store-integration.dbtest.ts
@@ -96,8 +96,15 @@ describe('rag/store.ts - projects CRUD', () => {
addRagMessage('chat-sibling', 'user', 'Quartz budget is 300')
addRagMessage('chat-other', 'user', 'Quartz budget is 400')
+ // Each hit names its conversation and when it was said, so a citation can open it, dated.
expect(searchProjectConversations('p-chat-search', 'Quartz budget', 'chat-current')).toEqual([
- { role: 'user', content: 'Quartz budget is 300', title: 'Sibling' }
+ {
+ role: 'user',
+ content: 'Quartz budget is 300',
+ title: 'Sibling',
+ conversation_id: 'chat-sibling',
+ created_at: expect.any(String)
+ }
])
const { runTool } = await import('../tools')
const result = await runTool(
diff --git a/src/main/__tests__/record-sources.dbtest.ts b/src/main/__tests__/record-sources.dbtest.ts
new file mode 100644
index 000000000..83545ff5d
--- /dev/null
+++ b/src/main/__tests__/record-sources.dbtest.ts
@@ -0,0 +1,184 @@
+/**
+ * What the retrieval engine reads from each record source, against the real profile database:
+ * a question that is only a time ("what did I do yesterday") lists that day's records of every
+ * kind, a to-do reads as the user sees it, and a short run in a script written without spaces
+ * (two Chinese characters) still finds its record. Electron and the embedding model are the only
+ * controlled boundaries; the model is unavailable, so this is the keyword and time paths alone.
+ */
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+const PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-record-sources-'))
+const originalDataDir = process.env.OFFGRID_DATA_DIR
+process.env.OFFGRID_DATA_DIR = PROFILE_DIR
+
+vi.mock('electron', () => ({
+ app: { getPath: () => PROFILE_DIR, isPackaged: false, getAppPath: () => process.cwd() },
+ BrowserWindow: { getAllWindows: () => [] },
+ safeStorage: {
+ isEncryptionAvailable: () => false,
+ encryptString: (value: string) => Buffer.from(value),
+ decryptString: (value: Buffer) => value.toString()
+ }
+}))
+
+vi.mock('@huggingface/transformers', () => ({
+ env: {},
+ pipeline: async () => {
+ throw new Error('no embedding model in this journey')
+ }
+}))
+
+import { getDB } from '../database'
+import { ensureRagStoreSchema } from '../rag/store'
+import { ensureRecordSchema, recordSearch } from '../records'
+
+/** Noon, local time, on 2026-10-14; "yesterday" is the 13th. */
+const NOW = new Date(2026, 9, 14, 12, 0, 0).getTime()
+const YESTERDAY_NOON = new Date(2026, 9, 13, 12, 0, 0).getTime()
+const LAST_WEEK = new Date(2026, 9, 6, 12, 0, 0).getTime()
+
+/** SQLite's UTC text timestamp, as the capture pipeline stores it. */
+const sqliteTime = (ms: number): string => new Date(ms).toISOString().slice(0, 19).replace('T', ' ')
+
+const ids = { moment: 0, oldMoment: 0, todo: 0, done: 0, dismissed: 0, meeting: 0, chinese: 0 }
+
+function moment(summary: string, ms: number): number {
+ return Number(
+ getDB()
+ .prepare("INSERT INTO observations (summary, surface, ts) VALUES (?, 'Notes', ?)")
+ .run(summary, sqliteTime(ms)).lastInsertRowid
+ )
+}
+
+function todo(
+ text: string,
+ status: string,
+ ms: number,
+ extra: { due?: string; dueAt?: number; entity?: string } = {}
+): number {
+ return Number(
+ getDB()
+ .prepare(
+ `INSERT INTO action_items (text, raw_text, status, created_at, due, due_at, entity_name, confidence)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0.9)`
+ )
+ .run(text, text, status, ms, extra.due ?? null, extra.dueAt ?? null, extra.entity ?? null)
+ .lastInsertRowid
+ )
+}
+
+beforeAll(() => {
+ // Pro's capture, to-do and meeting tables, as its schema creates them; a core DB test cannot
+ // import Pro.
+ getDB().exec(`
+ CREATE TABLE IF NOT EXISTS observations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, summary TEXT NOT NULL, surface TEXT, surface_app TEXT,
+ url TEXT, source_session_id TEXT, source_key TEXT, memory_id INTEGER,
+ category TEXT NOT NULL DEFAULT 'work', engagement REAL NOT NULL DEFAULT 0,
+ salience REAL NOT NULL DEFAULT 0, ts DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS frames (id INTEGER PRIMARY KEY AUTOINCREMENT, image_path TEXT, text TEXT, surface TEXT, url TEXT, ts DATETIME, source TEXT);
+ CREATE TABLE IF NOT EXISTS observation_frames (observation_id INTEGER, frame_id INTEGER);
+ CREATE TABLE IF NOT EXISTS action_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, raw_text TEXT, due TEXT,
+ entity_name TEXT, source_app TEXT, source_summary TEXT, source_ts INTEGER,
+ confidence REAL NOT NULL DEFAULT 0.6, status TEXT NOT NULL DEFAULT 'open',
+ created_at INTEGER NOT NULL DEFAULT 0, dedup_key TEXT UNIQUE, due_at INTEGER
+ );
+ CREATE TABLE IF NOT EXISTS meetings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, transcript TEXT, summary TEXT,
+ started_at INTEGER, ended_at INTEGER, duration_sec INTEGER, created_at INTEGER NOT NULL DEFAULT 0
+ );
+ `)
+ ensureRagStoreSchema()
+ // The index and its triggers exist before anything is recorded, as at app start-up.
+ ensureRecordSchema()
+
+ ids.moment = moment('Reviewed the vendor contract renewal terms', YESTERDAY_NOON)
+ ids.oldMoment = moment('Sketched the garden layout', LAST_WEEK)
+ ids.chinese = moment('明天的会议改到下午三点', YESTERDAY_NOON)
+ ids.todo = todo('Send the signed contract to legal', 'suggested', YESTERDAY_NOON, {
+ dueAt: new Date(2026, 9, 16, 17, 0, 0).getTime(),
+ entity: 'Priya'
+ })
+ ids.done = todo('Book the venue', 'done', YESTERDAY_NOON, { due: 'by Friday' })
+ ids.dismissed = todo('Order more staples', 'dismissed', YESTERDAY_NOON)
+ ids.meeting = Number(
+ getDB()
+ .prepare('INSERT INTO meetings (title, summary, started_at) VALUES (?, ?, ?)')
+ .run('Vendor sync', 'Agreed the renewal price', YESTERDAY_NOON).lastInsertRowid
+ )
+})
+
+afterAll(() => {
+ getDB().close()
+ if (originalDataDir === undefined) delete process.env.OFFGRID_DATA_DIR
+ else process.env.OFFGRID_DATA_DIR = originalDataDir
+ fs.rmSync(PROFILE_DIR, { recursive: true, force: true })
+})
+
+describe('record sources', () => {
+ it('lists the records of a day when the question is only that day', async () => {
+ const { hits, window } = await recordSearch('yesterday', { semantic: false, now: NOW })
+ expect(window).toMatchObject({
+ start: new Date(2026, 9, 13).getTime(),
+ end: new Date(2026, 9, 14).getTime()
+ })
+ const keys = hits.map((hit) => hit.key)
+ expect(keys).toEqual(
+ expect.arrayContaining([
+ `obs:${ids.moment}`,
+ `obs:${ids.chinese}`,
+ `act:${ids.todo}`,
+ `act:${ids.done}`,
+ `mtg:${ids.meeting}`
+ ])
+ )
+ // Another day's moment and a dismissed to-do are not that day's records.
+ expect(keys).not.toContain(`obs:${ids.oldMoment}`)
+ expect(keys).not.toContain(`act:${ids.dismissed}`)
+ })
+
+ it('reads a to-do as the list shows it: its state, who it is for and when it is due', async () => {
+ const { hits } = await recordSearch('signed contract legal', {
+ semantic: false,
+ window: null,
+ kinds: ['action']
+ })
+ expect(hits[0]).toMatchObject({
+ key: `act:${ids.todo}`,
+ kind: 'action',
+ title: 'Suggested to-do · Priya',
+ surface: 'To-do'
+ })
+ // A resolved deadline reads as its day.
+ expect(hits[0]!.text).toMatch(/^Send the signed contract to legal \(due .+16.+\)$/)
+
+ const done = await recordSearch('book venue', {
+ semantic: false,
+ window: null,
+ kinds: ['action']
+ })
+ expect(done.hits[0]).toMatchObject({
+ key: `act:${ids.done}`,
+ title: 'Done',
+ text: 'Book the venue (due by Friday)'
+ })
+ })
+
+ it('finds a two-character run in a script written without spaces', async () => {
+ const { hits } = await recordSearch('会议', { semantic: false, window: null })
+ expect(hits.map((hit) => hit.key)).toContain(`obs:${ids.chinese}`)
+ })
+
+ it('keeps a time-filtered keyword search inside its day', async () => {
+ const yesterday = await recordSearch('vendor yesterday', { semantic: false, now: NOW })
+ const keys = yesterday.hits.map((hit) => hit.key)
+ expect(keys).toEqual(expect.arrayContaining([`obs:${ids.moment}`, `mtg:${ids.meeting}`]))
+ const lastMonth = await recordSearch('vendor last month', { semantic: false, now: NOW })
+ expect(lastMonth.hits.map((hit) => hit.key)).not.toContain(`obs:${ids.moment}`)
+ })
+})
diff --git a/src/main/__tests__/record-vectors.dbtest.ts b/src/main/__tests__/record-vectors.dbtest.ts
new file mode 100644
index 000000000..d0e7dca0e
--- /dev/null
+++ b/src/main/__tests__/record-vectors.dbtest.ts
@@ -0,0 +1,224 @@
+/**
+ * Meaning search over records, against the real profile database: the embed queue the record
+ * triggers fill, the background drain that writes vectors into record_vectors, the in-memory int8
+ * index, and the hydrated hits a search returns. Electron and the embedding model are the only
+ * controlled boundaries. The model is a bag-of-words embedder, so texts that share words are near
+ * each other and the ranking can be read from the test data.
+ */
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+const PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-record-vectors-'))
+const originalDataDir = process.env.OFFGRID_DATA_DIR
+process.env.OFFGRID_DATA_DIR = PROFILE_DIR
+
+const model = vi.hoisted(() => ({ calls: 0, texts: [] as string[] }))
+
+vi.mock('electron', () => ({
+ app: { getPath: () => PROFILE_DIR, isPackaged: false, getAppPath: () => process.cwd() },
+ BrowserWindow: { getAllWindows: () => [] },
+ safeStorage: {
+ isEncryptionAvailable: () => false,
+ encryptString: (value: string) => Buffer.from(value),
+ decryptString: (value: Buffer) => value.toString()
+ }
+}))
+
+vi.mock('@huggingface/transformers', () => {
+ const DIMENSIONS = 64
+ // One dimension per word (by a stable hash), normalized: shared words are what make texts near.
+ const embed = (text: string): number[] => {
+ const vector = new Array(DIMENSIONS).fill(0)
+ const words = text
+ .replace(/^(query|passage): /, '')
+ .toLowerCase()
+ .split(/[^a-z0-9]+/)
+ .filter((word) => word.length > 2)
+ for (const word of words) {
+ let hash = 0
+ for (const char of word) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
+ vector[hash % DIMENSIONS]! += 1
+ }
+ const norm = Math.hypot(...vector) || 1
+ return vector.map((value) => value / norm)
+ }
+ return {
+ env: { backends: { onnx: { wasm: {} } } },
+ pipeline: async () => async (input: string | string[]) => {
+ const texts = Array.isArray(input) ? input : [input]
+ model.calls++
+ model.texts.push(...texts)
+ const rows = texts.map(embed)
+ return {
+ data: Float32Array.from(rows.flat()),
+ dims: [rows.length, DIMENSIONS],
+ tolist: () => rows
+ }
+ }
+ }
+})
+
+import { getDB } from '../database'
+import {
+ embedRecordBacklog,
+ ensureRecordSchema,
+ recordIndexStatus,
+ recordSearch,
+ releaseRecordIndex
+} from '../records'
+
+function moment(summary: string, ts = '2026-10-10 10:00:00'): number {
+ return Number(
+ getDB()
+ .prepare("INSERT INTO observations (summary, surface, ts) VALUES (?, 'Notes', ?)")
+ .run(summary, ts).lastInsertRowid
+ )
+}
+
+function meeting(title: string, summary: string): number {
+ return Number(
+ getDB()
+ .prepare('INSERT INTO meetings (title, summary, started_at) VALUES (?, ?, ?)')
+ .run(title, summary, Date.parse('2026-10-11T09:00:00Z')).lastInsertRowid
+ )
+}
+
+/** Meaning search alone. The Search screen's keywords need every word (keywordTypeahead), and no
+ * record holds this one, so every hit here comes from the vectors. */
+const UNMATCHED_WORD = 'xylophonequartz'
+
+async function meaning(question: string, kinds?: string[]): Promise {
+ const { hits } = await recordSearch(`${question} ${UNMATCHED_WORD}`, {
+ semantic: true,
+ keywordTypeahead: true,
+ window: null,
+ ...(kinds ? { kinds } : {})
+ })
+ return hits.map((hit) => hit.key)
+}
+
+beforeAll(() => {
+ // Pro's capture and meeting tables, as its schema creates them; a core DB test cannot import Pro.
+ getDB().exec(`
+ CREATE TABLE IF NOT EXISTS observations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, summary TEXT NOT NULL, surface TEXT, surface_app TEXT,
+ url TEXT, source_session_id TEXT, source_key TEXT, memory_id INTEGER,
+ category TEXT NOT NULL DEFAULT 'work', engagement REAL NOT NULL DEFAULT 0,
+ salience REAL NOT NULL DEFAULT 0, ts DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS frames (id INTEGER PRIMARY KEY AUTOINCREMENT, image_path TEXT, text TEXT, surface TEXT, url TEXT, ts DATETIME, source TEXT);
+ CREATE TABLE IF NOT EXISTS observation_frames (observation_id INTEGER, frame_id INTEGER);
+ CREATE TABLE IF NOT EXISTS meetings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, transcript TEXT, summary TEXT,
+ started_at INTEGER, ended_at INTEGER, duration_sec INTEGER, created_at INTEGER NOT NULL DEFAULT 0
+ );
+ `)
+ ensureRecordSchema()
+})
+
+beforeEach(() => {
+ releaseRecordIndex()
+ model.calls = 0
+ model.texts.length = 0
+})
+
+afterAll(() => {
+ releaseRecordIndex()
+ getDB().close()
+ if (originalDataDir === undefined) delete process.env.OFFGRID_DATA_DIR
+ else process.env.OFFGRID_DATA_DIR = originalDataDir
+ fs.rmSync(PROFILE_DIR, { recursive: true, force: true })
+})
+
+describe('record vectors', () => {
+ it('embeds every queued record in the background, then finds them by meaning', async () => {
+ const budget = moment('Quarterly budget review with the finance team')
+ moment('Dentist appointment moved to Tuesday morning')
+ const launch = meeting('Product launch planning', 'Launch checklist and press timing agreed')
+
+ expect(recordIndexStatus().pending).toBe(3)
+ await embedRecordBacklog()
+ expect(recordIndexStatus()).toEqual({ vectors: 3, pending: 0 })
+ // Records are embedded as passages, in batches.
+ expect(model.texts.every((text) => text.startsWith('passage: '))).toBe(true)
+
+ expect((await meaning('finance budget quarterly'))[0]).toBe(`obs:${budget}`)
+ // The question was embedded as a query.
+ expect(model.texts).toContain(`query: finance budget quarterly ${UNMATCHED_WORD}`)
+ // Keyword search alone finds nothing for it: the hits above are meaning hits.
+ const keywordOnly = await recordSearch(`finance budget quarterly ${UNMATCHED_WORD}`, {
+ typeahead: true,
+ window: null
+ })
+ expect(keywordOnly.hits).toEqual([])
+ expect(await meaning('launch checklist', ['meeting'])).toEqual([`mtg:${launch}`])
+ // A kind with no records matches nothing.
+ expect(await meaning('budget', ['attachment'])).toEqual([])
+ })
+
+ it('embeds again only what changed, and forgets a record whose text is gone', async () => {
+ const edited = moment('Hiking trip packing list')
+ const unchanged = moment('Garden watering schedule')
+ await embedRecordBacklog()
+ model.texts.length = 0
+
+ getDB()
+ .prepare('UPDATE observations SET summary = ? WHERE id = ?')
+ .run('Hiking trip permits and maps', edited)
+ getDB().prepare('UPDATE observations SET summary = summary WHERE id = ?').run(unchanged)
+ expect(recordIndexStatus().pending).toBe(2)
+ await embedRecordBacklog()
+
+ // The unchanged record leaves the queue without a forward pass; the edited one is embedded.
+ expect(model.texts).toEqual(['passage: Hiking trip permits and maps'])
+ expect(recordIndexStatus().pending).toBe(0)
+ expect((await meaning('hiking permits maps'))[0]).toBe(`obs:${edited}`)
+
+ getDB().prepare("UPDATE observations SET summary = '' WHERE id = ?").run(unchanged)
+ await embedRecordBacklog()
+ const stored = getDB()
+ .prepare('SELECT COUNT(*) AS n FROM record_vectors WHERE key = ?')
+ .get(`obs:${unchanged}`)
+ expect(stored).toEqual({ n: 0 })
+ })
+
+ it('keeps a loaded index in step with writes and deletes', async () => {
+ const first = moment('Violin lesson notes on bowing')
+ await embedRecordBacklog()
+ // Load the index with one search, then write and delete while it is in memory.
+ expect((await meaning('violin bowing lesson'))[0]).toBe(`obs:${first}`)
+
+ const second = moment('Cello rehearsal schedule for the orchestra')
+ await embedRecordBacklog()
+ expect((await meaning('cello orchestra rehearsal'))[0]).toBe(`obs:${second}`)
+
+ getDB().prepare('DELETE FROM observations WHERE id = ?').run(first)
+ expect(await meaning('violin bowing lesson')).not.toContain(`obs:${first}`)
+
+ // Dropped (idle, or its data deleted), the index reloads from SQLite on the next search.
+ releaseRecordIndex()
+ expect((await meaning('cello orchestra rehearsal'))[0]).toBe(`obs:${second}`)
+ })
+
+ it('searches only the time window it is given', async () => {
+ const october = moment('Tax filing documents gathered', '2026-10-05 12:00:00')
+ const july = moment('Tax filing extension requested', '2026-07-05 12:00:00')
+ await embedRecordBacklog()
+ const window = {
+ start: Date.parse('2026-10-01T00:00:00Z'),
+ end: Date.parse('2026-11-01T00:00:00Z'),
+ phrase: 'October'
+ }
+ const { hits } = await recordSearch(`tax filing ${UNMATCHED_WORD}`, {
+ semantic: true,
+ keywordTypeahead: true,
+ window,
+ kinds: ['screen']
+ })
+ const keys = hits.map((hit) => hit.key)
+ expect(keys).toContain(`obs:${october}`)
+ expect(keys).not.toContain(`obs:${july}`)
+ })
+})
diff --git a/src/main/__tests__/sd-server-fallback.test.ts b/src/main/__tests__/sd-server-fallback.test.ts
index 37e31fa31..f073fa608 100644
--- a/src/main/__tests__/sd-server-fallback.test.ts
+++ b/src/main/__tests__/sd-server-fallback.test.ts
@@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { spawnMock, findBinariesMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
- findBinariesMock: vi.fn(() => ['/bin/sd-cuda/sd-server', '/bin/sd/sd-server'])
+ // The image ladder: CUDA first, then Vulkan (the shared backend policy decides the order).
+ findBinariesMock: vi.fn(async () => [
+ { path: '/bin/sd-cuda/sd-server', backend: 'cuda', cpuOnly: false },
+ { path: '/bin/sd/sd-server', backend: 'vulkan', cpuOnly: false }
+ ])
}))
vi.mock('child_process', () => ({ spawn: spawnMock, execSync: vi.fn() }))
vi.mock('fs', () => ({ default: { existsSync: () => true } }))
@@ -19,7 +23,11 @@ vi.mock('../runtime-backends', () => ({
}))
import { sdServer } from '../sd-server'
-function processStub(): EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: ReturnType } {
+function processStub(): EventEmitter & {
+ stdout: EventEmitter
+ stderr: EventEmitter
+ kill: ReturnType
+} {
const proc = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
@@ -45,40 +53,55 @@ describe('resident image runtime fallback', () => {
})
it('starts Vulkan when CUDA cannot load and reports the selected binary', async () => {
- spawnMock.mockImplementationOnce(() => {
- const proc = processStub()
- queueMicrotask(() => proc.emit('error', new Error('CUDA driver missing')))
- return proc
- }).mockImplementation(() => processStub())
- vi.stubGlobal('fetch', vi.fn(async () => {
- if (spawnMock.mock.calls.length < 2) throw new Error('not ready')
- return { ok: true }
- }))
+ spawnMock
+ .mockImplementationOnce(() => {
+ const proc = processStub()
+ queueMicrotask(() => proc.emit('error', new Error('CUDA driver missing')))
+ return proc
+ })
+ .mockImplementation(() => processStub())
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ if (spawnMock.mock.calls.length < 2) throw new Error('not ready')
+ return { ok: true }
+ })
+ )
const starting = sdServer.ensureUp({ modelPath: '/model.gguf' })
await vi.advanceTimersByTimeAsync(1000)
await starting
expect(spawnMock.mock.calls.map(([binary]) => binary)).toEqual([
- '/bin/sd-cuda/sd-server', '/bin/sd/sd-server'
+ '/bin/sd-cuda/sd-server',
+ '/bin/sd/sd-server'
])
expect(sdServer.getBinaryPath()).toBe('/bin/sd/sd-server')
})
it('does not start a fallback after the user stops startup', async () => {
spawnMock.mockImplementation(() => processStub())
- vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('not ready') }))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new Error('not ready')
+ })
+ )
const starting = sdServer.ensureUp({ modelPath: '/model.gguf' })
const rejected = expect(starting).rejects.toThrow()
sdServer.stop()
await vi.advanceTimersByTimeAsync(1000)
await rejected
- expect(spawnMock).toHaveBeenCalledTimes(1)
+ // Stopping during startup ends the ladder: at most the first engine was started.
+ expect(spawnMock.mock.calls.length).toBeLessThanOrEqual(1)
})
it('keeps the loaded CUDA server for the same model and clears it after a crash', async () => {
const proc = processStub()
spawnMock.mockReturnValue(proc)
- vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true })))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({ ok: true }))
+ )
await sdServer.ensureUp({ modelPath: '/model.gguf' })
await sdServer.ensureUp({ modelPath: '/model.gguf' })
@@ -96,14 +119,20 @@ describe('resident image runtime fallback', () => {
queueMicrotask(() => proc.emit('error', new Error('runtime failed')))
return proc
})
- vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('not ready') }))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new Error('not ready')
+ })
+ )
const starting = sdServer.ensureUp({ modelPath: '/model.gguf' })
const rejected = expect(starting).rejects.toThrow('runtime failed')
await vi.advanceTimersByTimeAsync(1000)
await rejected
expect(spawnMock.mock.calls.map(([binary]) => binary)).toEqual([
- '/bin/sd-cuda/sd-server', '/bin/sd/sd-server'
+ '/bin/sd-cuda/sd-server',
+ '/bin/sd/sd-server'
])
expect(sdServer.getBinaryPath()).toBeNull()
})
diff --git a/src/main/__tests__/search-backfill-short-frames.dbtest.ts b/src/main/__tests__/search-backfill-short-frames.dbtest.ts
index 1c9f50012..0618f2ae8 100644
--- a/src/main/__tests__/search-backfill-short-frames.dbtest.ts
+++ b/src/main/__tests__/search-backfill-short-frames.dbtest.ts
@@ -1,11 +1,11 @@
/**
- * Search indexing drains capture frames too short to embed (backlog item 33), against the real
+ * Short capture frames never hold the search backlog open (backlog item 33), against the real
* profile database.
*
- * The pending scans no longer measure frame text in SQL. A short frame is selected, skipped
- * without an embedding, and recorded as handled, so the backlog empties instead of keeping it.
- * Electron, the embedding model and the native vector store are the only controlled boundaries:
- * the model throws if asked, so this proves a short frame never reaches it.
+ * A frame is not a search record: its words belong to the moment it is linked to, which is what
+ * gets embedded. So frames never enter the embedding queue, and a run of short frames ("ok",
+ * "see you at 3") leaves nothing pending. Electron and the embedding model are the only controlled
+ * boundaries: the model throws if asked, so this proves a frame never reaches it.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import fs from 'node:fs'
@@ -30,9 +30,6 @@ vi.mock('@huggingface/transformers', () => ({
},
env: {}
}))
-vi.mock('@lancedb/lancedb', () => ({
- connect: async () => ({ openTable: async () => ({}), tableNames: async () => [] })
-}))
import { getDB } from '../database'
import { runBackfill, searchStatus } from '../search'
@@ -59,18 +56,16 @@ afterAll(() => {
})
describe('search indexing backlog', () => {
- it('records frames too short to embed as handled, so the backlog empties', async () => {
+ it('never queues short capture frames, so the backlog stays empty', async () => {
+ expect((await searchStatus()).pending).toBe(0)
const insert = getDB().prepare("INSERT INTO frames (surface, text) VALUES ('Slack', ?)")
insert.run('ok')
insert.run('see you at 3')
- expect((await searchStatus()).pending).toBe(2)
+ expect((await searchStatus()).pending).toBe(0)
await runBackfill()
expect((await searchStatus()).pending).toBe(0)
- const handled = getDB()
- .prepare("SELECT key FROM vec_indexed WHERE key LIKE 'frame:%' ORDER BY key")
- .all() as { key: string }[]
- expect(handled.map((row) => row.key)).toEqual(['frame:1', 'frame:2'])
+ expect(getDB().prepare('SELECT COUNT(*) AS n FROM record_vectors').get()).toEqual({ n: 0 })
})
})
diff --git a/src/main/__tests__/search-backlog-count.dbtest.ts b/src/main/__tests__/search-backlog-count.dbtest.ts
deleted file mode 100644
index 4d37ad7b8..000000000
--- a/src/main/__tests__/search-backlog-count.dbtest.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-/**
- * The search backfill no longer recounts all seven pending sources after every batch. These
- * journeys prove the progress it reports still equals the full seven-source count, that the
- * backlog reaches an exact zero, and that items added during or after a run are still found.
- * Electron, the embedding model and the vector store are the only replaced boundaries.
- */
-import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
-import fs from 'node:fs'
-import os from 'node:os'
-import path from 'node:path'
-
-const PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-backlog-count-'))
-const originalDataDir = process.env.OFFGRID_DATA_DIR
-process.env.OFFGRID_DATA_DIR = PROFILE_DIR
-
-vi.mock('electron', () => ({
- app: { getPath: () => PROFILE_DIR, isPackaged: false, getAppPath: () => process.cwd() },
- safeStorage: {
- isEncryptionAvailable: () => false,
- encryptString: (value: string) => Buffer.from(value),
- decryptString: (value: Buffer) => value.toString()
- }
-}))
-vi.mock('../embeddings', () => ({
- embeddings: { generateEmbedding: async () => [0.1, 0.2, 0.3] }
-}))
-vi.mock('../vectors', () => ({
- addChunks: async () => {},
- searchVectors: async () => [],
- vectorCount: async () => 0
-}))
-
-import { getDB } from '../database'
-import { runBackfill, searchStatus } from '../search'
-import { PENDING_COUNT_SQL } from '../search-backfill-queries'
-
-const TEXT = 'a captured line long enough to be embedded'
-
-function exactPending(): number {
- return (getDB().prepare(PENDING_COUNT_SQL).get() as { c: number }).c
-}
-
-/** Add `n` pending items to each of the seven sources. */
-function seed(n: number, tag: string): void {
- const db = getDB()
- db.transaction(() => {
- for (let index = 0; index < n; index++) {
- const text = `${TEXT} ${tag} ${index}`
- db.prepare("INSERT INTO frames (surface, text) VALUES ('Slack', ?)").run(text)
- db.prepare("INSERT INTO observations (summary, surface) VALUES (?, 'Slack')").run(text)
- db.prepare('INSERT INTO chat_summaries (session_id, summary) VALUES (?, ?)').run(
- `s-${tag}-${index}`,
- text
- )
- db.prepare('INSERT INTO meetings (title, summary) VALUES (?, ?)').run('Standup', text)
- db.prepare('INSERT INTO memories (content) VALUES (?)').run(text)
- const entity = db
- .prepare("INSERT INTO entities (name, type, summary, hidden) VALUES (?, 'Person', ?, 0)")
- .run(`Person ${tag} ${index}`, text)
- db.prepare('INSERT INTO entity_facts (entity_id, fact) VALUES (?, ?)').run(
- entity.lastInsertRowid,
- text
- )
- }
- })()
-}
-
-beforeAll(() => {
- // Pro's capture tables, as its schema creates them; a core DB test cannot import Pro.
- getDB().exec(`
- CREATE TABLE IF NOT EXISTS frames (id INTEGER PRIMARY KEY AUTOINCREMENT, ts DATETIME DEFAULT CURRENT_TIMESTAMP, surface TEXT, url TEXT, text TEXT, source TEXT);
- CREATE TABLE IF NOT EXISTS observations (id INTEGER PRIMARY KEY AUTOINCREMENT, summary TEXT, surface TEXT, url TEXT, ts DATETIME);
- CREATE TABLE IF NOT EXISTS meetings (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, summary TEXT, transcript TEXT, started_at INTEGER);
- `)
- try {
- getDB().exec('ALTER TABLE entities ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0')
- } catch {
- /* already present */
- }
-})
-
-beforeEach(() => {
- getDB().exec(`
- DELETE FROM vec_indexed; DELETE FROM frames; DELETE FROM observations; DELETE FROM chat_summaries;
- DELETE FROM meetings; DELETE FROM memories; DELETE FROM entity_facts; DELETE FROM entities;
- `)
-})
-
-afterAll(() => {
- getDB().close()
- if (originalDataDir === undefined) delete process.env.OFFGRID_DATA_DIR
- else process.env.OFFGRID_DATA_DIR = originalDataDir
- fs.rmSync(PROFILE_DIR, { recursive: true, force: true })
-})
-
-describe('search backfill backlog count', () => {
- beforeAll(() => {
- getDB().exec('CREATE TABLE IF NOT EXISTS vec_indexed (key TEXT PRIMARY KEY)')
- })
-
- it('reports the full seven-source count after every batch and finishes at zero', async () => {
- seed(20, 'a')
- expect((await searchStatus()).pending).toBe(140)
-
- const reported: { remaining: number; exact: number }[] = []
- await runBackfill((progress) =>
- reported.push({ remaining: progress.remaining, exact: exactPending() })
- )
-
- expect(reported.length).toBeGreaterThan(2)
- for (const step of reported) expect(step.remaining).toBe(step.exact)
- expect(reported.at(-1)!.remaining).toBe(0)
- expect((await searchStatus()).pending).toBe(0)
- const sources = getDB()
- .prepare(
- "SELECT substr(key, 1, instr(key, ':') - 1) AS source, COUNT(*) AS n FROM vec_indexed GROUP BY source ORDER BY source"
- )
- .all()
- expect(sources).toEqual([
- { source: 'ent', n: 20 },
- { source: 'fact', n: 20 },
- { source: 'frame', n: 20 },
- { source: 'mem', n: 20 },
- { source: 'mtg', n: 20 },
- { source: 'obs', n: 20 },
- { source: 'sum', n: 20 }
- ])
- })
-
- it('does not report done while items added during the run are still pending', async () => {
- seed(10, 'b')
- let added = false
- const reported: number[] = []
- await runBackfill((progress) => {
- reported.push(progress.remaining)
- if (!added) {
- added = true
- seed(3, 'late')
- }
- })
-
- expect(reported.at(-1)).toBe(0)
- expect(exactPending()).toBe(0)
- expect(
- (getDB().prepare('SELECT COUNT(*) AS n FROM vec_indexed').get() as { n: number }).n
- ).toBe(13 * 7)
- })
-
- it('notices items added after a completed run', async () => {
- seed(2, 'c')
- await runBackfill()
- expect((await searchStatus()).pending).toBe(0)
-
- seed(1, 'd')
- expect((await searchStatus()).pending).toBe(7)
- await runBackfill()
- expect((await searchStatus()).pending).toBe(0)
- })
-})
diff --git a/src/main/__tests__/search-ranking.test.ts b/src/main/__tests__/search-ranking.test.ts
deleted file mode 100644
index b8b41943f..000000000
--- a/src/main/__tests__/search-ranking.test.ts
+++ /dev/null
@@ -1,423 +0,0 @@
-/**
- * Regression tests for unified-search RANKING behaviour added this session:
- * - recency bias (this week > last month > last quarter > older)
- * - own-content nudge so chats / KB docs aren't buried under ambient captures
- * - "Match" sort = literal term overlap
- * Pure math (no DB/Electron), mirroring model-sizing.test.ts.
- */
-import { describe, it, expect } from 'vitest'
-import {
- recencyBoost,
- kindBoost,
- matchScore,
- queryTerms,
- ftsExpr,
- rrf,
- fuseHits,
- applyBoosts,
- rankResults,
- likeMatch,
- LIKE_COLUMNS,
- epochMsSql,
- type RawHit,
- type SearchResult
-} from '../search-ranking'
-
-const DAY = 86_400_000
-const NOW = 1_800_000_000_000 // fixed "now" so tests are deterministic
-
-// A minimal RawHit for fusion tests. Overrides win over the defaults.
-function hit(over: Partial & { key: string }): RawHit {
- return {
- kind: 'screen',
- refId: 1,
- title: 'Title',
- snippet: 'Snippet',
- surface: 'Screen',
- url: null,
- ts: 0,
- ...over
- }
-}
-
-describe('recencyBoost — recent first, tiered', () => {
- it('this week beats last month beats last quarter beats older', () => {
- const week = recencyBoost(NOW - 3 * DAY, NOW)
- const month = recencyBoost(NOW - 20 * DAY, NOW)
- const quarter = recencyBoost(NOW - 60 * DAY, NOW)
- const older = recencyBoost(NOW - 200 * DAY, NOW)
- expect(week).toBeGreaterThan(month)
- expect(month).toBeGreaterThan(quarter)
- expect(quarter).toBeGreaterThan(older)
- expect(older).toBe(0)
- })
-
- it('no timestamp → no boost', () => {
- expect(recencyBoost(0, NOW)).toBe(0)
- })
-
- it('boost stays within ~one RRF rank (does not dominate)', () => {
- // RRF top rank ≈ 1/60 ≈ 0.0167; the freshest boost must not exceed it, so a
- // strong older multi-list match can still outrank a weak recent one.
- expect(recencyBoost(NOW, NOW)).toBeLessThanOrEqual(1 / 60)
- })
-})
-
-describe('kindBoost — own deliberate content surfaces', () => {
- it('chats and KB docs get a nudge; ambient kinds do not', () => {
- expect(kindBoost('chat')).toBeGreaterThan(0)
- expect(kindBoost('doc')).toBeGreaterThan(0)
- expect(kindBoost('screen')).toBe(0)
- expect(kindBoost('meeting')).toBe(0)
- expect(kindBoost('entity')).toBe(0)
- })
-
- it('a fresh chat outranks an equally-ranked ambient screen capture', () => {
- const base = 1 / 60 // both matched once
- const chat = base + recencyBoost(NOW - 1 * DAY, NOW) + kindBoost('chat')
- const screen = base + recencyBoost(NOW - 1 * DAY, NOW) + kindBoost('screen')
- expect(chat).toBeGreaterThan(screen)
- })
-})
-
-describe('matchScore — literal term overlap for the Match sort', () => {
- it('counts occurrences of each term, case-insensitive', () => {
- expect(matchScore('Praveen and Mac, Praveen again', ['praveen'])).toBe(2)
- expect(matchScore('Off Grid AI sync plan', ['off', 'grid'])).toBe(2)
- })
-
- it('ranks a denser match above a sparse one', () => {
- const terms = ['off', 'grid']
- const dense = matchScore('Off Grid AI — Off Grid AI roadmap', terms)
- const sparse = matchScore('a grid of icons', terms)
- expect(dense).toBeGreaterThan(sparse)
- })
-
- it('no terms / empty haystack → 0', () => {
- expect(matchScore('anything', [])).toBe(0)
- expect(matchScore('', ['x'])).toBe(0)
- })
-})
-
-describe('queryTerms — the single tokeniser', () => {
- it('lowercases, splits on punctuation/whitespace, drops symbols', () => {
- expect(queryTerms('Off-Grid SYNC, plan!', 12)).toEqual(['off', 'grid', 'sync', 'plan'])
- })
-
- it('keeps unicode letters and digits', () => {
- expect(queryTerms('café 2024 déjà', 12)).toEqual(['café', '2024', 'déjà'])
- })
-
- it('caps at max terms', () => {
- expect(queryTerms('a b c d e', 3)).toEqual(['a', 'b', 'c'])
- })
-
- it('a punctuation-only / empty query yields no terms', () => {
- expect(queryTerms(' --- ', 12)).toEqual([])
- expect(queryTerms('', 6)).toEqual([])
- })
-})
-
-describe('ftsExpr — FTS5 prefix-match expression', () => {
- it('wraps each of up to 12 tokens as a quoted prefix term', () => {
- expect(ftsExpr('Off Grid AI')).toBe('"off"* "grid"* "ai"*')
- })
-
- it('drops punctuation so user input can never be an FTS syntax error', () => {
- // A bare quote / paren would break MATCH; tokenising strips it.
- expect(ftsExpr('a"b (c)')).toBe('"a"* "b"* "c"*')
- })
-
- it('caps at 12 terms', () => {
- const q = Array.from({ length: 20 }, (_, i) => `t${i}`).join(' ')
- expect(ftsExpr(q).split(' ').length).toBe(12)
- })
-
- it('empty / symbol-only query → empty match (callers treat as no keyword pass)', () => {
- expect(ftsExpr('')).toBe('')
- expect(ftsExpr('***')).toBe('')
- })
-})
-
-describe('rrf — reciprocal-rank fusion weight', () => {
- it('rank 0 is the strongest and weights decay with rank', () => {
- expect(rrf(0)).toBeCloseTo(1 / 60)
- expect(rrf(0)).toBeGreaterThan(rrf(1))
- expect(rrf(1)).toBeGreaterThan(rrf(9))
- })
-})
-
-describe('fuseHits — RRF fusion + dedupe by key', () => {
- it('a key in two lists accumulates both lists’ rrf weights', () => {
- const a: RawHit[] = [hit({ key: 'obs:1' }), hit({ key: 'obs:2' })]
- const b: RawHit[] = [hit({ key: 'obs:2' }), hit({ key: 'obs:3' })]
- const fused = fuseHits([a, b])
- // obs:2 = rank1 in a (rrf 1) + rank0 in b (rrf 0) -> two contributions.
- expect(fused.get('obs:2')!.score).toBeCloseTo(rrf(1) + rrf(0))
- expect(fused.get('obs:1')!.score).toBeCloseTo(rrf(0))
- expect(fused.get('obs:3')!.score).toBeCloseTo(rrf(1))
- expect(fused.size).toBe(3)
- })
-
- it('an item ranked high in two lists beats one ranked high in only one', () => {
- const a: RawHit[] = [hit({ key: 'both' }), hit({ key: 'solo' })]
- const b: RawHit[] = [hit({ key: 'both' })]
- const fused = fuseHits([a, b])
- expect(fused.get('both')!.score).toBeGreaterThan(fused.get('solo')!.score)
- })
-
- it('the first list a key appears in seeds its shape; later lists only add score', () => {
- const a: RawHit[] = [hit({ key: 'k', title: 'FIRST', snippet: 'first snippet', surface: 'A' })]
- const b: RawHit[] = [
- hit({ key: 'k', title: 'SECOND', snippet: 'second snippet', surface: 'B' })
- ]
- const fused = fuseHits([a, b])
- const r = fused.get('k')!
- expect(r.title).toBe('FIRST')
- expect(r.snippet).toBe('first snippet')
- expect(r.surface).toBe('A')
- })
-
- it('caps snippet at 280 chars and falls back title/surface/ts when blank', () => {
- const long = 'x'.repeat(400)
- const fused = fuseHits([
- [hit({ key: 'k', kind: 'memory', title: '', snippet: long, surface: '', ts: 0 })]
- ])
- const r = fused.get('k')!
- expect(r.snippet.length).toBe(280)
- expect(r.title).toBe('memory') // empty title → kind
- expect(r.surface).toBe('')
- expect(r.ts).toBe(0)
- expect(r.imagePath).toBeNull()
- })
-
- it('empty input → empty map; disjoint lists keep every key once', () => {
- expect(fuseHits([]).size).toBe(0)
- expect(fuseHits([[], []]).size).toBe(0)
- const fused = fuseHits([[hit({ key: 'a' })], [hit({ key: 'b' })]])
- expect([...fused.keys()].sort()).toEqual(['a', 'b'])
- })
-
- it('a single source still fuses (scores follow rank order)', () => {
- const fused = fuseHits([[hit({ key: 'a' }), hit({ key: 'b' }), hit({ key: 'c' })]])
- expect(fused.get('a')!.score).toBeGreaterThan(fused.get('b')!.score)
- expect(fused.get('b')!.score).toBeGreaterThan(fused.get('c')!.score)
- })
-})
-
-describe('fuseHits — snippets read as prose', () => {
- it('drops markdown, fences and tags from a stored chat or note', () => {
- const fused = fuseHits([
- [
- hit({
- key: 'chat:1',
- snippet:
- '## Plan\n**Ship** the `beta` on [Friday](https://x.test).\n```html\nHi
\n```'
- })
- ]
- ])
- expect(fused.get('chat:1')!.snippet).toBe('Plan Ship the beta on Friday. Hi')
- })
-
- it('keeps snake_case words and caps the snippet at 280 characters', () => {
- const fused = fuseHits([[hit({ key: 'a', snippet: `use search_messages ${'x'.repeat(400)}` })]])
- const snippet = fused.get('a')!.snippet
- expect(snippet.startsWith('use search_messages ')).toBe(true)
- expect(snippet).toHaveLength(280)
- })
-})
-
-describe('applyBoosts — recency + own-content nudge in place', () => {
- it('adds the recency and kind boosts to each result score', () => {
- const fused = fuseHits([[hit({ key: 'chat:1', kind: 'chat', ts: NOW - 1 * DAY })]])
- const before = fused.get('chat:1')!.score
- applyBoosts(fused.values(), NOW)
- expect(fused.get('chat:1')!.score).toBeCloseTo(
- before + recencyBoost(NOW - 1 * DAY, NOW) + kindBoost('chat')
- )
- })
-
- it('a screen capture with no timestamp gets no boost', () => {
- const fused = fuseHits([[hit({ key: 'obs:1', kind: 'screen', ts: 0 })]])
- const before = fused.get('obs:1')!.score
- applyBoosts(fused.values(), NOW)
- expect(fused.get('obs:1')!.score).toBe(before)
- })
-})
-
-describe('rankResults — filter then sort', () => {
- // Build three fused results with known scores/timestamps.
- const results = (): SearchResult[] => [
- {
- key: 'obs:1',
- kind: 'screen' as const,
- refId: 1,
- title: 'Off Grid AI plan',
- snippet: 'grid grid',
- surface: 'Slack',
- url: null,
- ts: NOW - 100 * DAY,
- imagePath: null,
- score: 0.1
- },
- {
- key: 'chat:1',
- kind: 'chat' as const,
- refId: 0,
- title: 'A chat',
- snippet: 'off topic',
- surface: 'Chat',
- url: 'c1',
- ts: NOW - 1 * DAY,
- imagePath: null,
- score: 0.3
- },
- {
- key: 'mem:1',
- kind: 'memory' as const,
- refId: 2,
- title: 'note',
- snippet: 'nothing',
- surface: 'Memory',
- url: null,
- ts: NOW - 5 * DAY,
- imagePath: null,
- score: 0.2
- }
- ]
-
- it('relevance (default) sorts by descending score', () => {
- const out = rankResults(results(), { query: 'Off Grid AI' })
- expect(out.map((r) => r.key)).toEqual(['chat:1', 'mem:1', 'obs:1'])
- })
-
- it('recency sorts newest first, score breaks ties', () => {
- const out = rankResults(results(), { query: 'x', sort: 'recency' })
- expect(out.map((r) => r.key)).toEqual(['chat:1', 'mem:1', 'obs:1'])
- })
-
- it('match sorts by literal term overlap in title+snippet, score breaks ties', () => {
- // "grid" appears twice in obs:1 snippet + once in its title -> densest match.
- const out = rankResults(results(), { query: 'Off Grid AI', sort: 'match' })
- expect(out[0]!.key).toBe('obs:1')
- })
-
- it('match sort is NOT capped at 12 terms (uses the full query)', () => {
- // 13th term is the only one that matches -> it must still count.
- const many = [
- 'a1',
- 'a2',
- 'a3',
- 'a4',
- 'a5',
- 'a6',
- 'a7',
- 'a8',
- 'a9',
- 'a10',
- 'a11',
- 'a12',
- 'zebra'
- ].join(' ')
- const rs = [
- {
- key: 'a',
- kind: 'screen' as const,
- refId: 1,
- title: 'nothing',
- snippet: 'here',
- surface: 'X',
- url: null,
- ts: 0,
- imagePath: null,
- score: 0.5
- },
- {
- key: 'b',
- kind: 'screen' as const,
- refId: 2,
- title: 'zebra',
- snippet: 'zebra',
- surface: 'X',
- url: null,
- ts: 0,
- imagePath: null,
- score: 0.1
- }
- ]
- const out = rankResults(rs, { query: many, sort: 'match' })
- expect(out[0]!.key).toBe('b') // matched the 13th term despite lower score
- })
-
- it('source filter keeps only matching surfaces, case-insensitive', () => {
- const out = rankResults(results(), { query: 'x', sources: ['slack'] })
- expect(out.map((r) => r.key)).toEqual(['obs:1'])
- })
-
- it('kind filter gives Replay only captured screen results', () => {
- const out = rankResults(results(), { query: 'x', kinds: ['screen'] })
- expect(out.map((r) => r.key)).toEqual(['obs:1'])
- })
-
- it('excludeChatId drops that exact chat and nothing else', () => {
- const out = rankResults(results(), { query: 'x', excludeChatId: '1' })
- expect(out.map((r) => r.key)).not.toContain('chat:1')
- expect(out.map((r) => r.key)).toContain('obs:1')
- })
-
- it('empty input → empty output', () => {
- expect(rankResults([], { query: 'x' })).toEqual([])
- })
-})
-
-describe('likeMatch — shared LIKE WHERE + params (facet counts == results)', () => {
- it('builds one OR-group per term over the columns, AND-ed together', () => {
- const { where } = likeMatch(['a', 'b'], ['x', 'y'])
- expect(where).toBe(
- '(lower(a) LIKE ? OR lower(b) LIKE ?) AND (lower(a) LIKE ? OR lower(b) LIKE ?)'
- )
- })
-
- it('binds columns.length lowercased-wildcard params per term, in order', () => {
- const { args } = likeMatch(['a', 'b'], ['x', 'y'])
- expect(args).toEqual(['%x%', '%x%', '%y%', '%y%'])
- })
-
- it('clause placeholder count always equals the params length (can never disagree)', () => {
- for (const cols of Object.values(LIKE_COLUMNS)) {
- const { where, args } = likeMatch(cols, ['foo', 'bar', 'baz'])
- expect((where.match(/\?/g) || []).length).toBe(args.length)
- expect(args.length).toBe(cols.length * 3)
- }
- })
-
- it('handles a single-column source (frame) as a bare OR-group of one', () => {
- expect(likeMatch(LIKE_COLUMNS.frame, ['q'])).toEqual({
- where: '(lower(text) LIKE ?)',
- args: ['%q%']
- })
- })
-
- it('empty terms yield an empty clause and no params', () => {
- expect(likeMatch(['a'], [])).toEqual({ where: '', args: [] })
- })
-
- it('the column sets match the search.ts queries (facet + hit both build from these)', () => {
- // Guard the single source: these are the columns the two divergent copies used.
- expect(LIKE_COLUMNS.chat).toEqual(['rm.content', 'rc.title'])
- expect(LIKE_COLUMNS.doc).toEqual(['c.content', 'd.name'])
- expect(LIKE_COLUMNS.meeting).toEqual(['title', 'summary', 'transcript'])
- })
-})
-
-describe('epochMsSql — the shared SQLite datetime → epoch-ms fragment', () => {
- it('produces the exact fragment the 6 search.ts SELECT sites used', () => {
- expect(epochMsSql('ts')).toBe("CAST(strftime('%s', ts) AS INTEGER)*1000")
- })
-
- it('interpolates a qualified column identifier verbatim', () => {
- expect(epochMsSql('rc.updated_at')).toBe("CAST(strftime('%s', rc.updated_at) AS INTEGER)*1000")
- expect(epochMsSql('d.created_at')).toBe("CAST(strftime('%s', d.created_at) AS INTEGER)*1000")
- expect(epochMsSql('o.ts')).toBe("CAST(strftime('%s', o.ts) AS INTEGER)*1000")
- })
-})
diff --git a/src/main/__tests__/settings-consumers.dbtest.ts b/src/main/__tests__/settings-consumers.dbtest.ts
index f9d883ca1..e15476cb7 100644
--- a/src/main/__tests__/settings-consumers.dbtest.ts
+++ b/src/main/__tests__/settings-consumers.dbtest.ts
@@ -45,15 +45,15 @@ describe('prompt persistence', () => {
})
it('reads, fills, and resets a persisted custom template', () => {
- db.saveSetting('prompt:ragChat', 'Question: {{QUERY}}')
+ db.saveSetting('prompt:sessionSummary', 'Summarize: {{CONVERSATION_TEXT}}')
- expect(getPrompt('ragChat', { QUERY: 'where is the data?' })).toBe(
- 'Question: where is the data?'
+ expect(getPrompt('sessionSummary', { CONVERSATION_TEXT: 'we met about the budget' })).toBe(
+ 'Summarize: we met about the budget'
)
- resetPrompt('ragChat')
- expect(getPromptTemplate('ragChat')).toBe(
- PROMPT_REGISTRY.find((prompt) => prompt.key === 'ragChat')?.defaultTemplate
+ resetPrompt('sessionSummary')
+ expect(getPromptTemplate('sessionSummary')).toBe(
+ PROMPT_REGISTRY.find((prompt) => prompt.key === 'sessionSummary')?.defaultTemplate
)
})
})
diff --git a/src/main/__tests__/tools-search.dbtest.ts b/src/main/__tests__/tools-search.dbtest.ts
index 646cb9fdd..a8185ae8c 100644
--- a/src/main/__tests__/tools-search.dbtest.ts
+++ b/src/main/__tests__/tools-search.dbtest.ts
@@ -64,7 +64,8 @@ beforeAll(async () => {
db.exec(`
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT, summary TEXT NOT NULL, surface TEXT, url TEXT,
- category TEXT NOT NULL DEFAULT 'work', ts DATETIME DEFAULT CURRENT_TIMESTAMP, source_key TEXT
+ category TEXT NOT NULL DEFAULT 'work', ts DATETIME DEFAULT CURRENT_TIMESTAMP, source_key TEXT,
+ salience REAL NOT NULL DEFAULT 0
);
CREATE VIRTUAL TABLE IF NOT EXISTS observation_fts USING fts5(summary, content='observations', content_rowid='id');
CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
diff --git a/src/main/__tests__/tts-onnx-worker.test.ts b/src/main/__tests__/tts-onnx-worker.test.ts
index 53fa500a2..14447abcb 100644
--- a/src/main/__tests__/tts-onnx-worker.test.ts
+++ b/src/main/__tests__/tts-onnx-worker.test.ts
@@ -11,21 +11,14 @@ const host = vi.hoisted(() => ({
readDirectories: [] as string[],
device: 'coreml' as string
}))
-vi.mock('node:worker_threads', () => ({
- parentPort: { on: host.on, postMessage: host.post },
- workerData: {
- get modelsDir() {
- return host.modelsDir
- },
- get modelReadDirectories() {
- return host.readDirectories
- }
- }
-}))
+// The process boundary: a helper process's parent port (utility-host.ts sends init, then requests).
+Object.defineProperty(process, 'parentPort', {
+ value: { on: host.on, postMessage: host.post },
+ configurable: true
+})
vi.mock('kokoro-js', () => ({ KokoroTTS: { from_pretrained: host.load } }))
vi.mock('../embeddings-env', () => ({ configureTransformersEnv: vi.fn() }))
vi.mock('../onnx-device', () => ({
- onnxDeviceCandidates: () => [host.device, 'cpu'],
loadWithOnnxFallback: async (load: (device: string) => Promise) => ({
runtime: await load(host.device),
device: host.device
@@ -33,6 +26,23 @@ vi.mock('../onnx-device', () => ({
}))
let profile: string
let output: string
+
+/** Load the helper, send it the host's init message, and return its request channel. */
+async function startWorker(): Promise<(request: unknown) => void> {
+ await import('../tts-onnx-worker')
+ const listener = host.on.mock.calls[0]![1] as (event: { data: unknown }) => void
+ listener({
+ data: {
+ type: 'init',
+ data: {
+ modelsDir: host.modelsDir,
+ modelReadDirectories: host.readDirectories,
+ devices: [host.device, 'cpu']
+ }
+ }
+ })
+ return (request) => listener({ data: request })
+}
beforeEach(() => {
profile = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-onnx-speech-')))
host.modelsDir = path.join(profile, 'models')
@@ -55,7 +65,7 @@ beforeEach(() => {
host.generate.mockResolvedValue({ data: new Float32Array([-1, 0, 1]), sampling_rate: 24000 })
})
afterEach(() => fs.rmSync(profile, { recursive: true, force: true }))
-describe('ONNX speech worker response evidence', () => {
+describe('voice helper response evidence', () => {
it('loads a complete scan-folder package while the chosen folder is offline', async () => {
const scan = path.join(profile, 'scan')
const packageRoot = path.join(scan, '.cache/onnx-community/Kokoro-82M-v1.0-ONNX')
@@ -72,8 +82,7 @@ describe('ONNX speech worker response evidence', () => {
}
host.readDirectories.push(scan)
fs.rmdirSync(host.modelsDir)
- await import('../tts-onnx-worker')
- host.on.mock.calls[0]
+ ;(await startWorker())({ id: 8, type: 'prepare', voice: 'af_heart' })
await vi.waitFor(() =>
expect(host.post).toHaveBeenCalledWith({ id: 8, type: 'complete', device: 'coreml' })
)
@@ -86,8 +95,7 @@ describe('ONNX speech worker response evidence', () => {
it('refuses a speech download when storage is offline and the package is incomplete', async () => {
fs.rmdirSync(host.modelsDir)
- await import('../tts-onnx-worker')
- host.on.mock.calls[0]
+ ;(await startWorker())({ id: 9, type: 'prepare', voice: 'af_heart' })
await vi.waitFor(() =>
expect(host.post).toHaveBeenCalledWith(
expect.objectContaining({
@@ -103,14 +111,14 @@ describe('ONNX speech worker response evidence', () => {
it('uses FP32 for CUDA speech so the T4 does not return NaN audio', async () => {
host.device = 'cuda'
- await import('../tts-onnx-worker')
- host.on.mock.calls[0]
- await vi.waitFor(() => expect(host.post).toHaveBeenCalledWith({ id: 7, type: 'complete', device: 'cuda' }))
+ ;(await startWorker())({ id: 7, type: 'prepare', voice: 'af_heart' })
+ await vi.waitFor(() =>
+ expect(host.post).toHaveBeenCalledWith({ id: 7, type: 'complete', device: 'cuda' })
+ )
expect(host.load.mock.calls[0]![1]).toMatchObject({ dtype: 'fp32', device: 'cuda' })
})
it('reports the actual provider, emits progress and reuses its loaded runtime', async () => {
- await import('../tts-onnx-worker')
- const receive = host.on.mock.calls[0]![1]
+ const receive = await startWorker()
receive({ id: 1, type: 'prepare', voice: 'af_heart' })
await vi.waitFor(() =>
expect(host.post).toHaveBeenCalledWith({ id: 1, type: 'complete', device: 'coreml' })
@@ -139,8 +147,7 @@ describe('ONNX speech worker response evidence', () => {
])
})
it('returns an error for incomplete synthesis without claiming completion', async () => {
- await import('../tts-onnx-worker')
- host.on.mock.calls[0]
+ ;(await startWorker())({ id: 3, type: 'synthesize', voice: 'af_heart' })
await vi.waitFor(() =>
expect(host.post).toHaveBeenCalledWith({
id: 3,
@@ -153,8 +160,7 @@ describe('ONNX speech worker response evidence', () => {
it('does not write a silent voice reply', async () => {
host.generate.mockResolvedValue({ data: new Float32Array(2400), sampling_rate: 24000 })
- await import('../tts-onnx-worker')
- host.on.mock.calls[0])({
id: 6,
type: 'synthesize',
text: 'Hello',
@@ -175,11 +181,14 @@ describe('ONNX speech worker response evidence', () => {
it('retries model loading after a provider failure', async () => {
host.load.mockRejectedValueOnce(new Error('provider unavailable'))
- await import('../tts-onnx-worker')
- const receive = host.on.mock.calls[0]![1]
+ const receive = await startWorker()
receive({ id: 4, type: 'prepare', voice: 'af_heart' })
await vi.waitFor(() =>
- expect(host.post).toHaveBeenCalledWith({ id: 4, type: 'error', error: 'provider unavailable' })
+ expect(host.post).toHaveBeenCalledWith({
+ id: 4,
+ type: 'error',
+ error: 'provider unavailable'
+ })
)
receive({ id: 5, type: 'prepare', voice: 'af_heart' })
await vi.waitFor(() =>
diff --git a/src/main/__tests__/tts-onnx.test.ts b/src/main/__tests__/tts-onnx.test.ts
index ab1df00bc..f77719203 100644
--- a/src/main/__tests__/tts-onnx.test.ts
+++ b/src/main/__tests__/tts-onnx.test.ts
@@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EventEmitter } from 'node:events'
+// The process boundary: Electron's utilityProcess. The host, its request map, crash handling and
+// the helper's init message are production code.
interface TestWorker extends EventEmitter {
postMessage: ReturnType
- terminate: ReturnType
+ kill: ReturnType
}
const host = vi.hoisted(() => ({ workers: [] as TestWorker[] }))
vi.mock('node:fs', async (original) => ({
@@ -10,16 +12,21 @@ vi.mock('node:fs', async (original) => ({
existsSync: () => true
}))
vi.mock('../backend-preferences', () => ({ getBackendPreference: () => 'auto' }))
-vi.mock('node:worker_threads', async () => {
+vi.mock('electron', async () => {
const { EventEmitter } = await import('node:events')
+ class FakeUtilityProcess extends EventEmitter {
+ postMessage = vi.fn()
+ kill = vi.fn(() => {
+ this.emit('exit', 0)
+ return true
+ })
+ }
return {
- Worker: class extends EventEmitter {
- postMessage = vi.fn()
- terminate = vi.fn().mockResolvedValue(0)
- unref = vi.fn()
- constructor() {
- super()
- host.workers.push(this)
+ utilityProcess: {
+ fork: () => {
+ const child = new FakeUtilityProcess()
+ host.workers.push(child as unknown as TestWorker)
+ return child
}
}
}
@@ -28,7 +35,7 @@ import { OnnxSpeechRuntime } from '../tts-onnx'
beforeEach(() => {
host.workers.length = 0
})
-describe('speech worker lifecycle', () => {
+describe('voice helper process lifecycle', () => {
it('uses the actual completion device and forwards progress for each request', async () => {
const runtime = new OnnxSpeechRuntime()
const progress = vi.fn()
@@ -61,8 +68,10 @@ describe('speech worker lifecycle', () => {
)
worker.emit('message', { id: 2, type: 'complete', device: 'cuda' })
await expect(second).resolves.toBe('cuda')
+ // The helper's first message is its init data; requests follow.
+ expect(worker.postMessage.mock.calls[0]?.[0]).toMatchObject({ type: 'init' })
await runtime.close()
- expect(worker.terminate).toHaveBeenCalledOnce()
+ expect(worker.kill).toHaveBeenCalledOnce()
})
it('rejects worker errors and starts a new worker after a crash', async () => {
const runtime = new OnnxSpeechRuntime()
@@ -71,7 +80,7 @@ describe('speech worker lifecycle', () => {
host.workers[0]!.emit('message', { id: 1, type: 'error', error: 'provider failed' })
await failed
const second = runtime.prepare('af_heart')
- const crashed = expect(second).rejects.toThrow('exited with code 9')
+ const crashed = expect(second).rejects.toThrow('exit code 9')
host.workers[0]!.emit('exit', 9)
await crashed
const third = runtime.prepare('af_heart')
diff --git a/src/main/__tests__/tts-spawn-contract.test.ts b/src/main/__tests__/tts-spawn-contract.test.ts
index 038e286dd..d292525c1 100644
--- a/src/main/__tests__/tts-spawn-contract.test.ts
+++ b/src/main/__tests__/tts-spawn-contract.test.ts
@@ -2,16 +2,19 @@ import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
-const src = readFileSync(join(__dirname, '..', 'tts.ts'), 'utf8')
- .replace(/\/\*[\s\S]*?\*\//g, '')
- .replace(/\/\/.*$/gm, '')
+const strip = (text: string): string =>
+ text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
+const src = strip(readFileSync(join(__dirname, '..', 'tts.ts'), 'utf8'))
+// The helper's location is resolved once, for tts.ts and the voice catalogue alike.
+const availability = strip(readFileSync(join(__dirname, '..', 'runtime-availability.ts'), 'utf8'))
describe('tts.ts ExecuTorch runtime contract', () => {
it('resolves the packaged native helper before the sibling development checkout', () => {
- expect(src).toMatch(
+ expect(src).toContain('executorchSpeechBinary()')
+ expect(availability).toMatch(
/resourceDirs\(\)\.map\(\(root\) => path\.join\(root, 'bin', 'executorch-speech'\)\)/
)
- expect(src).toContain(
+ expect(availability).toContain(
"path.resolve(process.cwd(), '../executorch-speech/native/bin/executorch-speech')"
)
})
diff --git a/src/main/__tests__/vectors-predicates.test.ts b/src/main/__tests__/vectors-predicates.test.ts
deleted file mode 100644
index af9204c70..000000000
--- a/src/main/__tests__/vectors-predicates.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * Unit tests for the pure SQL-predicate builders extracted from vectors.ts:
- * kindsPredicate (quote-escaping) + olderThanPredicate (floors the cutoff).
- * No LanceDB — pure import-and-assert.
- */
-import { describe, it, expect } from 'vitest'
-import { kindsPredicate, olderThanPredicate } from '../vectors-predicates'
-
-describe('kindsPredicate — kind IN (...) with SQL-quote escaping', () => {
- it('builds a single-kind predicate', () => {
- expect(kindsPredicate(['screen'])).toBe("kind IN ('screen')")
- })
-
- it('builds a multi-kind predicate, comma-separated', () => {
- expect(kindsPredicate(['screen', 'meeting', 'memory'])).toBe(
- "kind IN ('screen', 'meeting', 'memory')"
- )
- })
-
- it("escapes a single quote in a kind by doubling it (' -> '')", () => {
- expect(kindsPredicate(["o'brien"])).toBe("kind IN ('o''brien')")
- })
-
- it('escapes every quote in a value', () => {
- expect(kindsPredicate(["a'b'c"])).toBe("kind IN ('a''b''c')")
- })
-
- it('an empty list yields an empty IN clause', () => {
- expect(kindsPredicate([])).toBe('kind IN ()')
- })
-})
-
-describe('olderThanPredicate — kinds AND floored cutoff', () => {
- it('appends the age filter with the cutoff floored to an integer', () => {
- expect(olderThanPredicate(['screen'], 1_700_000_000_123.9)).toBe(
- "kind IN ('screen') AND ts < 1700000000123"
- )
- })
-
- it('an integer cutoff is unchanged', () => {
- expect(olderThanPredicate(['meeting', 'memory'], 42)).toBe(
- "kind IN ('meeting', 'memory') AND ts < 42"
- )
- })
-
- it('reuses the same escaping as kindsPredicate', () => {
- expect(olderThanPredicate(["o'brien"], 10.99)).toBe("kind IN ('o''brien') AND ts < 10")
- })
-})
diff --git a/src/main/accessibility/__tests__/decision-runtime.test.ts b/src/main/accessibility/__tests__/decision-runtime.test.ts
index bf6c2aabf..15e122ad6 100644
--- a/src/main/accessibility/__tests__/decision-runtime.test.ts
+++ b/src/main/accessibility/__tests__/decision-runtime.test.ts
@@ -29,7 +29,13 @@ vi.mock('../../llm', () => ({
vi.mock('../../llm/http-post', () => ({ postCompletionOnce: mocks.post }))
vi.mock('../../llm/settings-math', () => ({ buildLaunchArgs: vi.fn(() => ['--serve']) }))
vi.mock('../../llm/spawn-env', () => ({ engineSpawnEnv: vi.fn(() => ({ TEST_ENGINE: '1' })) }))
-vi.mock('../../runtime-env', () => ({ binRoots: () => ['/bundle'], exe: (name: string) => name }))
+vi.mock('../../runtime-env', () => ({
+ binRoots: () => ['/bundle'],
+ exe: (name: string) => name,
+ // No CUDA pack manifest in this build.
+ resourceFile: () => null,
+ dataDir: () => '/data'
+}))
vi.mock('../../backend-preferences', () => ({ getBackendPreference: () => 'auto' }))
vi.mock('../../free-port', () => ({ isPortFree: vi.fn(), pickFreePort: mocks.pickPort }))
vi.mock('../../kill-orphan-port', () => ({ reapOrphanProcessesOnPort: mocks.reap }))
@@ -64,7 +70,13 @@ beforeEach(() => {
python: '/runtime/python',
script: '/runtime/server.py'
})
- mocks.exists.mockImplementation((candidate: string) => !candidate.includes('llama-cuda'))
+ // A macOS bundle: one Metal engine folder (no CUDA, CPU-only or legacy root engine).
+ mocks.exists.mockImplementation(
+ (candidate: string) =>
+ !candidate.includes('llama-cuda') &&
+ !candidate.includes('llama-cpu') &&
+ candidate !== '/bundle/llama-server'
+ )
mocks.pickPort.mockResolvedValue(8460)
mocks.parse.mockReturnValue({ choice: 1, confidence: 0.8, probabilities: [0.2, 0.8] })
mocks.post.mockResolvedValue('{"choice":"B"}')
@@ -211,7 +223,7 @@ describe('DecisionRuntime', () => {
await runtime.shutdown()
const failing = new DecisionRuntime()
- mocks.spawn.mockImplementationOnce(() => {
+ const outOfMemory = (): FakeProcess => {
const child = new FakeProcess()
queueMicrotask(() => {
child.stderr.emit('data', 'CUDA out of memory')
@@ -219,8 +231,12 @@ describe('DecisionRuntime', () => {
child.emit('close', 1)
})
return child
- })
+ }
+ // The GPU engine runs out of memory, then the same engine CPU-only does too.
+ mocks.spawn.mockClear()
+ mocks.spawn.mockImplementationOnce(outOfMemory).mockImplementationOnce(outOfMemory)
vi.mocked(fetch).mockRejectedValue(new Error('not ready'))
await expect(failing.start('offgrid/decider')).rejects.toMatchObject({ code: 'out_of_memory' })
+ expect(mocks.spawn).toHaveBeenCalledTimes(2)
})
})
diff --git a/src/main/accessibility/ax-agent.ts b/src/main/accessibility/ax-agent.ts
index 3d30d8815..b1fa525b6 100644
--- a/src/main/accessibility/ax-agent.ts
+++ b/src/main/accessibility/ax-agent.ts
@@ -110,7 +110,7 @@ export interface ElementTaskDeps {
control?: ElementTaskControl
}
-export interface ElementTaskControl {
+interface ElementTaskControl {
snapshot(): Pick
waitUntilRunnable(signal?: AbortSignal): Promise>
markObservationReady(): boolean
@@ -439,8 +439,8 @@ export function buildElementDecisionContext(input: {
const MAX_CONSECUTIVE_PARSE_FAILURES = 3
const MAX_CONSECUTIVE_NO_PROGRESS = 3
-export const AX_INVALID_REPLY_SUMMARY = `The action model returned an invalid reply ${MAX_CONSECUTIVE_PARSE_FAILURES} times in a row.`
-export const AX_NO_PROGRESS_SUMMARY = 'The action did not produce its required result.'
+const AX_INVALID_REPLY_SUMMARY = `The action model returned an invalid reply ${MAX_CONSECUTIVE_PARSE_FAILURES} times in a row.`
+const AX_NO_PROGRESS_SUMMARY = 'The action did not produce its required result.'
/** A stable signature of an actuating step, used to detect a runaway loop. Two
* consecutive identical signatures mean the model is repeating itself (it sent
diff --git a/src/main/accessibility/ax-decision.ts b/src/main/accessibility/ax-decision.ts
index b207f9c5c..a43c3a295 100644
--- a/src/main/accessibility/ax-decision.ts
+++ b/src/main/accessibility/ax-decision.ts
@@ -743,19 +743,3 @@ export async function chooseFactorizedElementStep(
...(writerTargetIndex !== undefined ? { writerTargetIndex } : {})
}
}
-
-/** Keep every model call within the model's trained A-J option head. Large AX
- * trees are narrowed through bounded groups, then scored again inside the
- * selected group. */
-export async function chooseElementStep(
- context: string,
- snapshot: AxSnapshot,
- score: ScoreOptions
-): Promise<{ step: ElementStep; confidence: number }> {
- const decision = await chooseFactorizedElementStep(context, snapshot, score)
- return { step: decision.step, confidence: decision.result.combinedConfidence }
-}
-
-export function serializeElementStep(step: ElementStep): string {
- return JSON.stringify(step)
-}
diff --git a/src/main/accessibility/ax-ranking.ts b/src/main/accessibility/ax-ranking.ts
index 3e9c4fa23..fd93910f0 100644
--- a/src/main/accessibility/ax-ranking.ts
+++ b/src/main/accessibility/ax-ranking.ts
@@ -21,7 +21,7 @@ function intersection(a: AxRect, b: AxRect): number {
return width * height
}
-export function overlapRatio(a: AxRect, b: AxRect): number {
+function overlapRatio(a: AxRect, b: AxRect): number {
const overlap = intersection(a, b)
const smaller = Math.min(a.width * a.height, b.width * b.height)
return smaller > 0 ? overlap / smaller : 0
diff --git a/src/main/accessibility/ax-state.ts b/src/main/accessibility/ax-state.ts
index ec431df1d..bd11692f6 100644
--- a/src/main/accessibility/ax-state.ts
+++ b/src/main/accessibility/ax-state.ts
@@ -7,7 +7,7 @@ export interface AxRect {
height: number
}
-export interface AxCaptureGeometry {
+interface AxCaptureGeometry {
sourceBounds: AxRect
encodedWidth: number
encodedHeight: number
@@ -16,7 +16,7 @@ export interface AxCaptureGeometry {
}
export type ObservationSource = 'ax' | 'ocr' | 'ax+ocr'
-export type ObservationModality = 'ax' | 'uia' | 'ocr' | 'capture'
+type ObservationModality = 'ax' | 'uia' | 'ocr' | 'capture'
export interface WindowIdentity {
platform: NodeJS.Platform
@@ -27,7 +27,7 @@ export interface WindowIdentity {
bounds: AxRect
}
-export interface SourceStatus {
+interface SourceStatus {
available: boolean
degradedReason?: string
}
@@ -40,9 +40,9 @@ export interface WindowBoundObservation {
sources: Record
}
-export type CandidateRisk = 'reversible' | 'private' | 'authentication' | 'payment' | 'destructive'
+type CandidateRisk = 'reversible' | 'private' | 'authentication' | 'payment' | 'destructive'
-export type CandidateAction =
+type CandidateAction =
| { type: 'activate'; method: 'press' | 'click' | 'hover'; x: number; y: number }
| { type: 'type'; text: string; submit: boolean }
| { type: 'key'; keys: string }
@@ -111,7 +111,7 @@ export interface ComputerUseDecisionResult {
latencyMs: number
}
-export type VerificationStatus = 'satisfied' | 'unsatisfied' | 'unknown' | 'timeout'
+type VerificationStatus = 'satisfied' | 'unsatisfied' | 'unknown' | 'timeout'
export interface ComputerUseVerificationResult {
status: VerificationStatus
diff --git a/src/main/accessibility/decision-model-loader.ts b/src/main/accessibility/decision-model-loader.ts
index e3f33b713..d8ace0c16 100644
--- a/src/main/accessibility/decision-model-loader.ts
+++ b/src/main/accessibility/decision-model-loader.ts
@@ -34,13 +34,19 @@ async function ensureDedicatedRuntime(
session: NonNullable
): Promise {
if (decisionRuntime.running) return
- if (!decisionRuntime.memoryEvicted && session.restartsRemaining <= 0) {
+ if (
+ isKevModel(session.modelId) &&
+ !decisionRuntime.memoryEvicted &&
+ session.restartsRemaining <= 0
+ ) {
throw new DecisionRuntimeError(
'The dedicated Decision runtime stopped before selection.',
'startup'
)
}
- if (!decisionRuntime.memoryEvicted) session.restartsRemaining -= 1
+ // The shared llama runtime owns its restart budget. Keep Kev unchanged.
+ if (isKevModel(session.modelId) && !decisionRuntime.memoryEvicted)
+ session.restartsRemaining -= 1
console.log('[computer-use] loading Decision runtime after eviction or recovery')
await decisionRuntime.start(session.modelId)
}
diff --git a/src/main/accessibility/decision-runtime.ts b/src/main/accessibility/decision-runtime.ts
index d653e035c..e124b6507 100644
--- a/src/main/accessibility/decision-runtime.ts
+++ b/src/main/accessibility/decision-runtime.ts
@@ -1,6 +1,7 @@
-import { beginRuntimeBackend, parseNativeBackend } from '../runtime-backends'
+import { DedicatedLlamaRuntime } from '../llm/dedicated-llama-runtime'
+import { getBackendPreference } from '../backend-preferences'
+import { beginRuntimeBackend } from '../runtime-backends'
import { spawn, type ChildProcess } from 'node:child_process'
-import path from 'node:path'
import { currentAIRequest } from '../ai-request-log'
import { Mutex } from 'async-mutex'
import { prepareModelMemory, registerModelEvictor } from '../model-memory'
@@ -11,7 +12,6 @@ import {
type OptionDecision
} from '../llm'
import { postCompletionOnce } from '../llm/http-post'
-import { buildLaunchArgs } from '../llm/settings-math'
import { totalmem } from 'node:os'
import type { GatewayMode } from '@offgrid/models'
@@ -20,11 +20,7 @@ let gatewayModeSource: () => GatewayMode = () => 'single'
export function setDecisionGatewayModeSource(source: () => GatewayMode): void {
gatewayModeSource = source
}
-import { engineSpawnEnv } from '../llm/spawn-env'
-import { selectLocalEngine } from '../llm/select-local-engine'
-import { getBackendPreference } from '../backend-preferences'
import { isPortFree, pickFreePort } from '../free-port'
-import { reapOrphanProcessesOnPort } from '../kill-orphan-port'
import {
KEV_9B_ID,
isKevModel,
@@ -58,13 +54,43 @@ export class DecisionRuntime {
private startPromise: Promise | null = null
private modelId: string | null = null
private stderr = ''
- private backendOutput = ''
private backend: string | undefined
private backendState?: ReturnType
+ private readonly runtime = new DedicatedLlamaRuntime({
+ id: 'decision',
+ label: 'Decision',
+ modality: 'decision',
+ port: DECIDER_PORT,
+ readyTimeoutMs: 120_000,
+ modelPaths: async () => {
+ const artifact = await resolveComputerUseModelArtifact(this.modelId!)
+ if (!artifact)
+ throw new DecisionRuntimeError('The selected Decision model is not installed.', 'missing')
+ return {
+ model: this.modelId!,
+ modelPath: artifact.primaryPath,
+ mmProjPath: artifact.projectorPath ?? ''
+ }
+ },
+ extraArgs: () => ({
+ effectiveCtxSize: DECIDER_CONTEXT_SIZE,
+ flashAttn: true,
+ kvCacheType: 'q8_0',
+ speculativeDecoding: 'off',
+ threads: undefined,
+ batchSize: 128,
+ reportModelPlacement: true,
+ // Team gateway mode opens slots here too, so several clients' decisions run at once.
+ // (Single mode passes nothing new, so its argv is unchanged.)
+ ...(gatewayModeSource() === 'team'
+ ? { gatewayMode: 'team' as const, totalRamBytes: totalmem() }
+ : {})
+ })
+ })
readonly timing: DecisionRuntimeTiming = { coldStartMs: 0, warmDecisionMs: [] }
get activePort(): number {
- return this.port
+ return isKevModel(this.modelId) ? this.port : this.runtime.port
}
get activeModelId(): string | null {
@@ -72,7 +98,9 @@ export class DecisionRuntime {
}
get running(): boolean {
- return this.process !== null && this.process.exitCode === null
+ return isKevModel(this.modelId)
+ ? this.process !== null && this.process.exitCode === null
+ : this.runtime.isRunning
}
async start(modelId: string): Promise {
@@ -87,93 +115,26 @@ export class DecisionRuntime {
private async startInternal(modelId: string): Promise {
await prepareModelMemory('decision')
this.memoryEvicted = false
+ if (this.modelId === modelId && !isKevModel(modelId)) {
+ await this.startDedicated()
+ return
+ }
await this.shutdown()
if (isKevModel(modelId)) return this.startKev(modelId)
- const artifact = await resolveComputerUseModelArtifact(modelId)
- if (!artifact)
- throw new DecisionRuntimeError('The selected Decision model is not installed.', 'missing')
- reapOrphanProcessesOnPort(
- DECIDER_PORT,
- (command) => /llama-server/i.test(command),
- 'Decision llama-server'
- )
- const port = await pickFreePort(DECIDER_PORT, isPortFree, 20)
- if (port === null)
- throw new DecisionRuntimeError(
- 'No private port is available for the Decision runtime.',
- 'startup'
- )
- const serverPath = await selectLocalEngine(getBackendPreference('decision'))
- if (!serverPath)
- throw new DecisionRuntimeError('The bundled Decision engine is missing.', 'startup')
- this.port = port
this.modelId = modelId
- this.stderr = ''
- this.backendOutput = ''
- this.backend = undefined
- const backendState = beginRuntimeBackend('decision', this.modelId!)
- this.backendState = backendState
- const args = buildLaunchArgs({
- modelPath: artifact.primaryPath,
- mmProjPath: artifact.projectorPath ?? '',
- port,
- // Mapika Decider supports 32k tokens. Keep enough room for structured
- // Computer Use state without reserving the model's full context window.
- effectiveCtxSize: DECIDER_CONTEXT_SIZE,
- gpuLayers: getBackendPreference('decision') === 'cpu' ? 0 : 99,
- flashAttn: true,
- kvCacheType: 'q8_0',
- speculativeDecoding: 'off',
- threads: undefined,
- batchSize: 128,
- reportModelPlacement: true,
- // Team gateway mode opens slots here too, so several clients' decisions run at once.
- // (Single mode passes nothing new, so its argv is unchanged.)
- ...(gatewayModeSource() === 'team'
- ? { gatewayMode: 'team' as const, totalRamBytes: totalmem() }
- : {})
- })
+ await this.startDedicated()
+ }
+
+ private async startDedicated(): Promise {
const startedAt = Date.now()
- const process = spawn(serverPath, args, {
- env: {
- ...globalThis.process.env,
- ...engineSpawnEnv({
- platform: globalThis.process.platform,
- binDir: path.dirname(serverPath),
- currentEnv: globalThis.process.env
- })
- },
- stdio: ['ignore', 'ignore', 'pipe']
- })
- this.process = process
- let loading = true
- process.stderr!.on('data', (chunk) => {
- if (loading) {
- const output = String(chunk)
- this.stderr = `${this.stderr}${output}`.slice(-16_384)
- this.backendOutput = `${this.backendOutput}${output}`.slice(-65_536)
- backendState.observe(output)
- console.log(`[Decision runtime] ${output}`)
- }
- })
- process.once('close', () => {
- backendState.stop()
- if (this.process === process) this.process = null
- })
try {
- await this.waitUntilReady(startedAt)
- loading = false // Level 4 can contain request data after startup.
- this.backend = parseNativeBackend(this.backendOutput)?.backend
- backendState.ready(this.backend)
+ await this.runtime.start()
this.timing.coldStartMs = Date.now() - startedAt
} catch (error) {
- await this.shutdown()
- const outOfMemory = /out of memory|cannot allocate|metal.*alloc|cuda.*memory/i.test(
- this.stderr
- )
+ if (error instanceof DecisionRuntimeError) throw error
throw new DecisionRuntimeError(
error instanceof Error ? error.message : 'The Decision runtime did not start.',
- outOfMemory ? 'out_of_memory' : 'startup'
+ this.runtime.failureCode === 'out_of_memory' ? 'out_of_memory' : 'startup'
)
}
}
@@ -270,11 +231,12 @@ export class DecisionRuntime {
if (!this.running)
throw new DecisionRuntimeError('The Decision runtime is not running.', 'startup')
const run = async (): Promise => {
- this.backendState?.recordRequest()
+ if (isKevModel(this.modelId)) this.backendState?.recordRequest()
+ else this.runtime.recordRequest()
const startedAt = Date.now()
currentAIRequest()?.update({
model: this.modelId ?? undefined,
- backend: this.backend ?? 'Unknown',
+ backend: (isKevModel(this.modelId) ? this.backend : this.runtime.backend) ?? 'Unknown',
effectiveRequest: { context, question, options }
})
if (isKevModel(this.modelId)) {
@@ -321,7 +283,7 @@ export class DecisionRuntime {
}
const prompt = buildDecisionPrompt(context, question, options)
const body = JSON.stringify(buildDecisionRequest(prompt, options.length))
- const raw = await postCompletionOnce(this.port, body, undefined, signal, '/completion')
+ const raw = await postCompletionOnce(this.activePort, body, undefined, signal, '/completion')
currentAIRequest()?.update({ effectiveRequest: JSON.parse(body), response: JSON.parse(raw) })
this.timing.warmDecisionMs.push(Date.now() - startedAt)
if (this.timing.warmDecisionMs.length > 200) this.timing.warmDecisionMs.shift()
@@ -331,16 +293,31 @@ export class DecisionRuntime {
}
async health(): Promise {
+ if (!isKevModel(this.modelId)) return this.runtime.health()
if (!this.running) return false
try {
- const endpoint = isKevModel(this.modelId) ? '/v1/models' : '/health'
- return (await fetch(`http://127.0.0.1:${this.port}${endpoint}`)).ok
+ return (await fetch(`http://127.0.0.1:${this.port}/v1/models`)).ok
} catch {
return false
}
}
+ shouldBeRunning(): boolean {
+ return isKevModel(this.modelId) ? this.running : this.runtime.shouldBeRunning()
+ }
+
+ async restart(): Promise {
+ const modelId = this.modelId
+ if (modelId && isKevModel(modelId)) {
+ await this.shutdown()
+ await this.start(modelId)
+ return
+ }
+ await this.runtime.restart()
+ }
+
async shutdown(): Promise {
+ await this.runtime.stop()
this.backendState?.stop()
const process = this.process
this.process = null
@@ -363,7 +340,7 @@ export class DecisionRuntime {
export const decisionRuntime = new DecisionRuntime()
registerModelEvictor('decision', async () => {
- if (decisionRuntime.running) {
+ if (decisionRuntime.running || decisionRuntime.shouldBeRunning()) {
decisionRuntime.memoryEvicted = true
await decisionRuntime.shutdown()
}
diff --git a/src/main/actions/connector-rail.ts b/src/main/actions/connector-rail.ts
index 722583252..46c9e4b64 100644
--- a/src/main/actions/connector-rail.ts
+++ b/src/main/actions/connector-rail.ts
@@ -7,7 +7,7 @@
*/
import type { ActionRecord, ExecuteResult } from '@offgrid/use'
-export interface ConnectorCallResult {
+interface ConnectorCallResult {
ok: boolean
result?: unknown
error?: string
diff --git a/src/main/actions/emit.ts b/src/main/actions/emit.ts
index ad5e13da0..d50df49c9 100644
--- a/src/main/actions/emit.ts
+++ b/src/main/actions/emit.ts
@@ -94,7 +94,7 @@ const quoteBareKeys = (text: string): string =>
* Repair candidates in trust order: the raw text first, then progressively
* repaired variants. Deduped; each is tried against the fail-closed schema.
*/
-export function extractCandidates(raw: string): string[] {
+function extractCandidates(raw: string): string[] {
const candidates: string[] = [raw.trim()]
const unfenced = stripFences(raw).trim()
candidates.push(unfenced)
diff --git a/src/main/actions/gate-host.ts b/src/main/actions/gate-host.ts
index f99eefa95..30e63d277 100644
--- a/src/main/actions/gate-host.ts
+++ b/src/main/actions/gate-host.ts
@@ -169,7 +169,7 @@ export function needsApproval(action: Pick): boolean {
/** A routine runs with nobody watching: anything it does beyond reading waits for approval,
* including the native actions a person in Chat runs straight away. */
-export function unattended(action: Pick): boolean {
+function unattended(action: Pick): boolean {
return action.source === 'routine' && action.risk !== 'read'
}
diff --git a/src/main/active-models.ts b/src/main/active-models.ts
index b8f7beb07..84dd06aa3 100644
--- a/src/main/active-models.ts
+++ b/src/main/active-models.ts
@@ -105,7 +105,3 @@ export function setRemoteModelSelected(modality: RemoteModelModality, selected:
export function remoteVoiceSelected(): boolean {
return remoteModelSelected('voice')
}
-
-export function setRemoteVoiceSelected(selected: boolean): void {
- setRemoteModelSelected('voice', selected)
-}
diff --git a/src/main/backend-facts-cache.ts b/src/main/backend-facts-cache.ts
new file mode 100644
index 000000000..9e23de56e
--- /dev/null
+++ b/src/main/backend-facts-cache.ts
@@ -0,0 +1,24 @@
+// What backend-facts remembers between reads: cached device answers and the backends a fault
+// demoted. A leaf, so the backend choice store can clear it when a choice changes.
+
+import {
+ BACKEND_MODALITIES,
+ type BackendFacts,
+ type BackendModality,
+ type NativeBackend
+} from '@offgrid/models'
+
+export const demoted: BackendFacts['demoted'] = {}
+export const probes = new Map>()
+
+export function demoteBackend(modality: BackendModality, backend: NativeBackend): void {
+ const backends = (demoted[modality] ??= [])
+ if (!backends.includes(backend)) backends.push(backend)
+}
+export function clearDemotions(modality?: BackendModality): void {
+ for (const value of modality ? [modality] : BACKEND_MODALITIES) delete demoted[value]
+}
+/** Forget cached device answers after a pack install, a driver change or wake. */
+export function invalidateBackendFacts(): void {
+ probes.clear()
+}
diff --git a/src/main/backend-facts.ts b/src/main/backend-facts.ts
new file mode 100644
index 000000000..8e161c606
--- /dev/null
+++ b/src/main/backend-facts.ts
@@ -0,0 +1,239 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import {
+ BACKEND_MODALITIES,
+ NVIDIA_DRIVER_FLOORS,
+ backendChoicesFor,
+ backendLadder,
+ nvidiaDriverMeets,
+ type BackendFacts,
+ type BackendReason,
+ type BackendModality,
+ type BackendChoice,
+ type EngineCandidate,
+ type NativeBackend,
+ type OnnxDevice
+} from '@offgrid/models'
+import { binRoots, exe } from './runtime-env'
+import { gpuDeviceAvailable } from './llm/gpu-device-probe'
+import { performancePackStatus, installedPerformancePackBin } from './performance-pack'
+import { getBackendPreference } from './backend-preferences'
+import { demoted, probes } from './backend-facts-cache'
+import {
+ cachedNvidiaDriverVersion,
+ hasLinuxNvidiaDriver,
+ hasWindowsNvidiaDriver
+} from './nvidia-driver'
+import { onnxCudaVerified } from './onnx-host/onnx-cuda-verified'
+
+/** The installed NVIDIA driver version, as last read (nvidia-driver.ts). */
+export function nvidiaDriverVersion(): string | null {
+ return cachedNvidiaDriverVersion()
+}
+
+function hasLinuxGpuDevice(): boolean {
+ if (hasLinuxNvidiaDriver()) return true
+ try {
+ return fs.readdirSync('/dev/dri').some((name) => name.startsWith('renderD'))
+ } catch {
+ return false
+ }
+}
+
+/** Scan paths here; ordering and fallback belong to the shared policy. */
+function scanEngineCandidates(
+ modality: BackendModality,
+ binaryName?: string,
+ prism = false
+): EngineCandidate[] {
+ if (modality === 'tts' || modality === 'embeddings') return []
+ const platform = process.platform
+ const native = platform === 'darwin' ? 'metal' : 'vulkan'
+ const llama = ['llm', 'grounding', 'decision'].includes(modality)
+ const prefix = prism ? 'llama-prism' : 'llama'
+ const folders: [string, NativeBackend][] = []
+ if (llama) {
+ if (platform === 'win32' || platform === 'linux') folders.push([`${prefix}-cuda`, 'cuda'])
+ folders.push([prefix, native], [`${prefix}-cpu`, 'cpu'])
+ if (!prism) folders.push(['', native])
+ } else if (modality === 'image') {
+ folders.push(['sd-cuda', 'cuda'], ['sd', native], ['sd-cpu', 'cpu'])
+ } else {
+ folders.push(
+ ['whisper-cuda', 'cuda'],
+ ['whisper', platform === 'win32' ? 'cuda' : native],
+ ['whisper-vulkan', 'vulkan'],
+ ['whisper-cpu', 'cpu']
+ )
+ if (platform === 'darwin' && (!binaryName || binaryName === 'whisper-server')) {
+ folders.push(['whisper-server', 'metal'])
+ }
+ }
+ const names = binaryName
+ ? [binaryName]
+ : llama
+ ? ['llama-server']
+ : modality === 'image'
+ ? ['sd-server', 'sd-cli']
+ : ['whisper-cli', 'whisper-server']
+ const candidates: EngineCandidate[] = []
+ const driver = nvidiaDriverVersion()
+ for (const [folder, backend] of folders) {
+ for (const root of binRoots()) {
+ for (const name of names) {
+ const id = path.resolve(root, folder, exe(name))
+ if (!fs.existsSync(id) || candidates.some((candidate) => candidate.id === id)) continue
+ let reason: EngineCandidate['reason']
+ if (backend === 'cuda') {
+ const present =
+ platform === 'win32'
+ ? hasWindowsNvidiaDriver()
+ : platform === 'linux' && hasLinuxNvidiaDriver()
+ const floor = NVIDIA_DRIVER_FLOORS.cuda12[platform as 'win32' | 'linux']
+ if (!present) reason = { code: 'no-device' }
+ else if (driver && floor && !nvidiaDriverMeets(driver, floor))
+ reason = { code: 'driver-too-old', have: driver, need: floor }
+ }
+ if (
+ modality === 'image' &&
+ backend === 'vulkan' &&
+ platform === 'win32' &&
+ !fs.existsSync(
+ path.join(
+ process.env.WINDIR ?? process.env.SystemRoot ?? 'C:\\Windows',
+ 'System32',
+ 'vulkan-1.dll'
+ )
+ )
+ )
+ reason = { code: 'no-device' }
+ if (modality === 'stt' && backend !== 'cpu' && platform === 'linux' && !hasLinuxGpuDevice())
+ reason = { code: 'no-device' }
+ candidates.push({ id, backend, usable: !reason, ...(reason ? { reason } : {}) })
+ }
+ }
+ }
+ return candidates
+}
+
+export async function engineCandidates(
+ modality: BackendModality,
+ binaryName?: string,
+ prism = false
+): Promise {
+ return probeCandidates(modality, scanEngineCandidates(modality, binaryName, prism))
+}
+
+async function probeCandidates(
+ modality: BackendModality,
+ candidates: EngineCandidate[]
+): Promise {
+ if (
+ !['llm', 'grounding', 'decision'].includes(modality) ||
+ !['win32', 'linux'].includes(process.platform)
+ )
+ return candidates
+ return Promise.all(
+ candidates.map(async (candidate) => {
+ if (!candidate.usable || candidate.backend === 'cpu') return candidate
+ let probe = probes.get(candidate.id)
+ if (!probe) {
+ probe = gpuDeviceAvailable(
+ candidate.id,
+ candidate.backend === 'cuda' ? 'CUDA' : 'Vulkan',
+ process.platform
+ )
+ probes.set(candidate.id, probe)
+ }
+ return (await probe)
+ ? candidate
+ : { ...candidate, usable: false, reason: { code: 'no-device' as const } }
+ })
+ )
+}
+
+/** Availability checks only inspect installed files; launches use nativeLadder. */
+export function nativeBinaryInstalled(modality: BackendModality, binaryName: string): boolean {
+ return scanEngineCandidates(modality, binaryName).length > 0
+}
+
+function runtimeFacts(): BackendFacts {
+ const status = performancePackStatus()
+ const packBin = installedPerformancePackBin()
+ const installed = packBin !== null
+ const driver = nvidiaDriverVersion()
+ const floor = NVIDIA_DRIVER_FLOORS.cuda13.linux
+ const libraries = !!packBin && fs.existsSync(path.join(packBin, 'onnx-cuda', 'libcublasLt.so.13'))
+ const onnxCuda: BackendFacts['onnxCuda'] =
+ process.platform === 'win32'
+ ? onnxCudaVerified()
+ : process.platform === 'linux'
+ ? !installed || !libraries
+ ? { usable: false, reason: { code: 'needs-pack' } }
+ : !nvidiaDriverMeets(driver, floor)
+ ? {
+ usable: false,
+ reason: { code: 'driver-too-old', have: driver ?? 'unknown', need: floor }
+ }
+ : { usable: true }
+ : { usable: false, reason: { code: 'no-device' } }
+ return {
+ platform: process.platform,
+ arch: process.arch,
+ engines: {},
+ nvidiaDriver: driver,
+ pack: { offered: status.phase !== 'unavailable' && status.phase !== 'not-needed', installed },
+ onnxCuda,
+ demoted
+ }
+}
+
+export async function backendFacts(modality?: BackendModality): Promise {
+ const facts = runtimeFacts()
+ await Promise.all(
+ (modality ? [modality] : BACKEND_MODALITIES).map(async (value) => {
+ facts.engines[value] = await engineCandidates(value)
+ })
+ )
+ return facts
+}
+
+export { clearDemotions, demoteBackend, invalidateBackendFacts } from './backend-facts-cache'
+/** One rung of a native engine ladder: the binary, what it was built for, and whether it must run
+ * CPU-only (llama `--device none`, whisper `-ng`, sd `--backend cpu`). */
+export interface NativeStep {
+ path: string
+ backend: NativeBackend
+ cpuOnly: boolean
+ reason?: BackendReason
+}
+
+export async function nativeLadder(
+ modality: BackendModality,
+ binaryName?: string,
+ preference = getBackendPreference(modality),
+ prism = false
+): Promise {
+ const facts = runtimeFacts()
+ facts.engines[modality] =
+ preference === 'cpu'
+ ? scanEngineCandidates(modality, binaryName, prism)
+ : await engineCandidates(modality, binaryName, prism)
+ return backendLadder(modality, preference, facts).map((step) => ({
+ path: step.engine,
+ backend: step.backend as NativeBackend,
+ cpuOnly: step.cpuOnly,
+ reason: step.reason
+ }))
+}
+export function onnxLadder(modality: 'tts' | 'embeddings'): OnnxDevice[] {
+ return backendLadder(modality, getBackendPreference(modality), runtimeFacts()).map(
+ (step) => step.engine as OnnxDevice
+ )
+}
+export async function backendChoicesForUi(): Promise> {
+ const facts = await backendFacts()
+ return Object.fromEntries(
+ BACKEND_MODALITIES.map((modality) => [modality, backendChoicesFor(modality, facts)])
+ ) as Record
+}
diff --git a/src/main/backend-preferences.ts b/src/main/backend-preferences.ts
index fd8d01e8a..810ce02cd 100644
--- a/src/main/backend-preferences.ts
+++ b/src/main/backend-preferences.ts
@@ -1,3 +1,5 @@
+import { normalizeBackendPreference } from '@offgrid/models'
+import { clearDemotions, invalidateBackendFacts } from './backend-facts-cache'
import { getSetting, saveSetting } from './database'
import {
BACKEND_MODALITIES,
@@ -22,11 +24,19 @@ export function setBackendPreference(
modality: BackendModality,
preference: BackendPreference
): BackendPreferences {
- if (!BACKEND_MODALITIES.includes(modality) ||
- !backendChoices(modality, process.platform).includes(preference)) {
+ if (
+ !BACKEND_MODALITIES.includes(modality) ||
+ !backendChoices(modality, process.platform).includes(preference)
+ ) {
throw new Error('Invalid model backend setting')
}
+ preference = normalizeBackendPreference(modality, preference)
+ const previous = getBackendPreference(modality)
const next = { ...getBackendPreferences(), [modality]: preference }
saveSetting(KEY, next)
+ if (previous !== preference) {
+ invalidateBackendFacts()
+ clearDemotions(modality)
+ }
return next
}
diff --git a/src/main/backup/index.ts b/src/main/backup/index.ts
index 88f2b9f57..df1a8c596 100644
--- a/src/main/backup/index.ts
+++ b/src/main/backup/index.ts
@@ -29,4 +29,4 @@ export function createDesktopBackupEngine(): DesktopBackupEngine {
)
}
-export type { DesktopBackupData, DesktopRestoreSummary, DesktopBackupDelivery }
+export type { DesktopBackupDelivery }
diff --git a/src/main/backup/types.ts b/src/main/backup/types.ts
index a129cb694..c606cab61 100644
--- a/src/main/backup/types.ts
+++ b/src/main/backup/types.ts
@@ -1,6 +1,6 @@
import { BundleError } from '@offgrid/sync/portable'
-export interface DesktopBackupChunk {
+interface DesktopBackupChunk {
content: string
position: number
}
diff --git a/src/main/best-settings.ts b/src/main/best-settings.ts
index e873a2a21..cc5a7a4d8 100644
--- a/src/main/best-settings.ts
+++ b/src/main/best-settings.ts
@@ -3,11 +3,10 @@
// hardware facts and writes the chosen settings through each owner's own setter.
import os from 'node:os'
-import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { llm } from './llm'
-import { selectLocalEngine } from './llm/select-local-engine'
+import { nativeLadder } from './backend-facts'
import { getBackendPreferences, setBackendPreference } from './backend-preferences'
import { getResidency, setResidencyMode } from './runtime-residency'
import { getSetting, saveSetting } from './database'
@@ -38,12 +37,16 @@ async function applePerformanceCores(): Promise {
/** The GPU backend of the engine llama.cpp would load with, after its own device probe. */
async function nativeGpu(): Promise {
- if (process.platform === 'darwin') return 'Metal'
- const engine = await selectLocalEngine('auto')
- const directory = engine ? path.basename(path.dirname(engine)) : ''
- if (directory === 'llama-cuda') return 'CUDA'
- if (directory === 'llama') return 'Vulkan'
- return null
+ const step = (await nativeLadder('llm', undefined, 'auto')).find(
+ (candidate) => !candidate.cpuOnly
+ )
+ return step?.backend === 'cuda'
+ ? 'CUDA'
+ : step?.backend === 'vulkan'
+ ? 'Vulkan'
+ : step?.backend === 'metal'
+ ? 'Metal'
+ : null
}
export async function detectMachine(): Promise {
diff --git a/src/main/browser/__tests__/browser-playwright-task-support.test.ts b/src/main/browser/__tests__/browser-playwright-task-support.test.ts
index bf02a070b..266cf8d07 100644
--- a/src/main/browser/__tests__/browser-playwright-task-support.test.ts
+++ b/src/main/browser/__tests__/browser-playwright-task-support.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import type { SemanticDecision } from '../browser-playwright-policy'
-import { isUncertainActionTimeout, uncertainActionKey } from '../browser-playwright-task-support'
+import { VisionGuard } from '../../vision/vision-guard'
+import type { BrowserPlaywrightTaskInput } from '../browser-playwright-task-contract'
+import {
+ observeWithCurrentLease,
+ isUncertainActionTimeout,
+ uncertainActionKey
+} from '../browser-playwright-task-support'
function decision(overrides: Partial = {}): SemanticDecision {
return {
@@ -55,3 +61,69 @@ describe('uncertain browser actions', () => {
)
})
})
+
+describe('semantic image observations', () => {
+ function input(): BrowserPlaywrightTaskInput {
+ return {
+ goal: 'Read the saved result',
+ plan: { version: 1, phases: [{ id: 'phase-1', title: 'Read the result' }] },
+ guard: new VisionGuard({ taskId: 'semantic-image', kind: 'web_use' }),
+ session: {
+ snapshot: async () => ({ text: 'status "Saved" [ref=s1]', isError: false }),
+ recoverPage: async () => {
+ throw new Error('Unexpected recovery')
+ },
+ call: async () => {
+ throw new Error('Unexpected action')
+ }
+ },
+ activeDriver: () => null,
+ activeUrl: () => 'https://example.test/result',
+ waitForUser: async () => undefined,
+ takeGuidance: () => [],
+ onStep: () => undefined,
+ onPhase: () => undefined,
+ onProgress: () => undefined
+ }
+ }
+
+ it('returns the captured frame with its semantic observation', async () => {
+ const task = input()
+ const screenshot = { image: '/frames/current.png', bounds: { width: 1200, height: 750 } }
+ task.captureObservationImage = async () => screenshot
+ expect(await observeWithCurrentLease(task)).toEqual({
+ text: 'status "Saved" [ref=s1]',
+ isError: false,
+ screenshot
+ })
+ })
+
+ it('discards both the snapshot and frame when input control changes during capture', async () => {
+ const task = input()
+ let captures = 0
+ task.captureObservationImage = async () => {
+ captures += 1
+ if (captures === 1) {
+ task.guard.pause()
+ task.guard.resume()
+ }
+ return { image: `/frames/${captures}.png`, bounds: { width: 1200, height: 750 } }
+ }
+ expect((await observeWithCurrentLease(task)).screenshot?.image).toBe('/frames/2.png')
+ })
+
+ it('keeps text-only sessions usable without a capture provider', async () => {
+ expect(await observeWithCurrentLease(input())).toEqual({
+ text: 'status "Saved" [ref=s1]',
+ isError: false
+ })
+ })
+
+ it('does not silently remove an image when its capture fails', async () => {
+ const task = input()
+ task.captureObservationImage = async () => {
+ throw new Error('Capture unavailable')
+ }
+ await expect(observeWithCurrentLease(task)).rejects.toThrow('Capture unavailable')
+ })
+})
diff --git a/src/main/browser/browser-host.ts b/src/main/browser/browser-host.ts
index d28cf014b..4c15814ad 100644
--- a/src/main/browser/browser-host.ts
+++ b/src/main/browser/browser-host.ts
@@ -15,6 +15,11 @@
* on a real display in the e2e tour and the real-machine pass, not here.
*/
import { BrowserWindow, WebContentsView, ipcMain } from 'electron'
+import { createBrowserVisionScreen } from './browser-vision-screen'
+import {
+ getActiveRemoteVisionServer,
+ getRemoteVisionServerSettings
+} from '../vision/remote-vision-server'
import { randomUUID } from 'node:crypto'
import {
BrowserDriver,
@@ -939,6 +944,13 @@ class BrowserHost implements BrowserRailHost {
const page = this.sessions.findJourney(journeyId) ?? record
return { view: page.resource, driver: this.driverFor(page) }
}
+ const semanticScreen = createBrowserVisionScreen({
+ goal,
+ taskId,
+ journeyId,
+ activePage,
+ settings: getWebUseSettings()
+ })
const recordSemanticEvidence = createBrowserSemanticEvidenceRecorder({
goal,
taskId,
@@ -1038,6 +1050,28 @@ class BrowserHost implements BrowserRailHost {
currentAction: action
})
},
+ captureObservationImage: async () => {
+ // The semantic route has selected its model before entering this loop.
+ // Keep existing text-only model and extension behavior unchanged.
+ const { llm } = await import('../llm')
+ const session = currentRemoteScreenTaskSession()
+ const remote = session ? session.activeServer : getActiveRemoteVisionServer()
+ if (remote) {
+ const saved = getRemoteVisionServerSettings().servers.find(
+ (server) =>
+ server.id === remote.id &&
+ server.enabled !== false &&
+ server.endpoint === remote.endpoint
+ )
+ const supportsImages = saved?.modelCatalog?.some(
+ (model) => model.id === remote.model && model.inputModalities?.includes('image')
+ )
+ return saved?.screenFramesAllowed && supportsImages
+ ? semanticScreen.capture()
+ : undefined
+ }
+ return llm.hasVision() ? semanticScreen.capture() : undefined
+ },
onObservation: recordSemanticEvidence,
signal: owner.controller.signal
}
diff --git a/src/main/browser/browser-playwright-policy.ts b/src/main/browser/browser-playwright-policy.ts
index c004dbf26..89ca848bf 100644
--- a/src/main/browser/browser-playwright-policy.ts
+++ b/src/main/browser/browser-playwright-policy.ts
@@ -130,6 +130,7 @@ export interface BrowserSemanticDecisionRequest {
goal: string
plan: TaskExecutionPlan
snapshot: string
+ image?: string
step: number
maxSteps: number
recoveryNote: string
@@ -192,7 +193,8 @@ Rules:
${boundedSnapshot(request.snapshot, request.goal)}
`
- const raw = await llm.chat(prompt, [], undefined, undefined, {
+ const raw = await llm.chat(prompt, request.image ? [request.image] : [], undefined, undefined, {
+ requireImages: Boolean(request.image),
disableThinking: true,
temperature: 0.1,
responseFormat: useDecider ? PLAYWRIGHT_CANDIDATES_FORMAT : PLAYWRIGHT_STEP_FORMAT,
diff --git a/src/main/browser/browser-playwright-task-contract.ts b/src/main/browser/browser-playwright-task-contract.ts
index 8a5423413..c2bb728cc 100644
--- a/src/main/browser/browser-playwright-task-contract.ts
+++ b/src/main/browser/browser-playwright-task-contract.ts
@@ -1,4 +1,5 @@
import type { TaskExecutionPlan } from '../../shared/task-execution-plan'
+import type { VisionScreen } from '../vision/vision-agent'
import type { VisionGuard } from '../vision/vision-guard'
import type { BrowserDriver } from './browser-driver'
import type { SemanticPageSession } from './playwright-mcp-session'
@@ -14,6 +15,7 @@ export interface BrowserSemanticObservation {
step: number
phase: 'observing' | 'checking' | 'waiting' | 'complete'
summary: string
+ screenshot?: Awaited>
}
export interface BrowserPlaywrightTaskInput {
@@ -29,6 +31,8 @@ export interface BrowserPlaywrightTaskInput {
onStep: (note: string) => void
onPhase: (phaseId: string) => void
onProgress: (step: number, phase: 'observing' | 'thinking' | 'acting', action: string) => void
+ /** Capture a model frame within the same lease as the semantic snapshot. */
+ captureObservationImage?: () => Promise> | undefined>
onObservation?: (observation: BrowserSemanticObservation) => Promise
signal?: AbortSignal
}
diff --git a/src/main/browser/browser-playwright-task-support.ts b/src/main/browser/browser-playwright-task-support.ts
index 9105aad12..65fe5c747 100644
--- a/src/main/browser/browser-playwright-task-support.ts
+++ b/src/main/browser/browser-playwright-task-support.ts
@@ -109,6 +109,10 @@ export async function observeWithCurrentLease(
let observation: PlaywrightToolResult
try {
observation = await input.session.snapshot(combinedSignal(input.signal, lease.signal))
+ if (!observation.isError && input.captureObservationImage) {
+ const screenshot = await input.captureObservationImage()
+ observation = { ...observation, screenshot }
+ }
} catch (error) {
if (!lease.signal.aborted) throw error
continue
diff --git a/src/main/browser/browser-playwright-task.ts b/src/main/browser/browser-playwright-task.ts
index 8944b5b8d..1b6fd3d67 100644
--- a/src/main/browser/browser-playwright-task.ts
+++ b/src/main/browser/browser-playwright-task.ts
@@ -149,7 +149,12 @@ async function runSemanticLoop(
: 'The page did not expose usable semantic controls.'
)
}
- await publishObservation(input, { step: 0, phase: 'observing', summary: 'Initial page' })
+ await publishObservation(input, {
+ step: 0,
+ phase: 'observing',
+ summary: 'Initial page',
+ screenshot: observation.screenshot
+ })
const state: SemanticLoopState = {
observation,
@@ -205,6 +210,9 @@ async function runSemanticStep(
input.signal?.throwIfAborted()
await input.guard.waitUntilRunnable(input.signal)
if (input.guard.isHalted) input.signal?.throwIfAborted()
+ if (input.guard.snapshot().observationRequired) {
+ state.observation = await observeWithCurrentLease(input)
+ }
input.onProgress(step, 'thinking', 'Deciding the next step')
const modelLease = input.guard.currentActionLease()
let decision: SemanticDecision
@@ -213,6 +221,7 @@ async function runSemanticStep(
goal: input.goal,
plan: input.plan,
snapshot: state.observation.text,
+ image: state.observation.screenshot?.image,
step,
maxSteps: MAX_STEPS,
recoveryNote: state.recoveryNote,
@@ -354,7 +363,12 @@ async function terminalDecision(
input.onStep(`verification failed: ${failure}`)
return { ok: false, fallback: false, summary: failure, handoffs: state.handoffs }
}
- await publishObservation(input, { step, phase: 'complete', summary })
+ await publishObservation(input, {
+ step,
+ phase: 'complete',
+ summary,
+ screenshot: verification.screenshot
+ })
if (!input.guard.complete()) return undefined
input.onStep(`verified: ${summary}`)
return { ok: true, fallback: false, summary, handoffs: state.handoffs }
@@ -379,7 +393,8 @@ async function performHumanHandoff(
await publishObservation(input, {
step,
phase: 'waiting',
- summary: 'Continued after user input'
+ summary: 'Continued after user input',
+ screenshot: state.observation.screenshot
})
state.lastFingerprint = fingerprint(state.observation.text)
state.lastActionKey = ''
@@ -468,7 +483,12 @@ async function performSemanticAction(
const recovery = await recoverObservationError(input, state)
if (recovery === 'recovered') return undefined
if (recovery) return recovery
- await publishObservation(input, { step, phase: 'checking', summary: actionLabel(decision) })
+ await publishObservation(input, {
+ step,
+ phase: 'checking',
+ summary: actionLabel(decision),
+ screenshot: state.observation.screenshot
+ })
updateProgressEvidence(context, { decision, actionKey, before })
remember(
state,
@@ -498,7 +518,12 @@ async function handleUncertainAction(
`The page could not be checked after an uncertain action: ${compact(state.observation.text)}`
)
}
- await publishObservation(input, { step, phase: 'checking', summary: UNCERTAIN_ACTION_NOTE })
+ await publishObservation(input, {
+ step,
+ phase: 'checking',
+ summary: UNCERTAIN_ACTION_NOTE,
+ screenshot: state.observation.screenshot
+ })
if (state.uncertainCount >= 2) {
await performHumanHandoff(
context,
diff --git a/src/main/browser/browser-semantic-evidence.ts b/src/main/browser/browser-semantic-evidence.ts
index 25e203930..e0f328741 100644
--- a/src/main/browser/browser-semantic-evidence.ts
+++ b/src/main/browser/browser-semantic-evidence.ts
@@ -35,7 +35,7 @@ export function createBrowserSemanticEvidenceRecorder(
})
const executionDevice = getTaskExecutionDevice()
return async (observation) => {
- const screenshot = await screen.capture()
+ const screenshot = observation.screenshot ?? (await screen.capture())
appendTaskStepDetail(
input.taskId,
'web_use',
diff --git a/src/main/browser/playwright-mcp-session.ts b/src/main/browser/playwright-mcp-session.ts
index 62d08e2e5..2b012ed8e 100644
--- a/src/main/browser/playwright-mcp-session.ts
+++ b/src/main/browser/playwright-mcp-session.ts
@@ -1,3 +1,4 @@
+import type { VisionScreen } from '../vision/vision-agent'
import { setTimeout as delay } from 'node:timers/promises'
import { createConnection } from '@playwright/mcp'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
@@ -34,6 +35,8 @@ export interface PlaywrightToolResult {
text: string
isError: boolean
uncertain?: true
+ /** Local model frame paired with this observation; never an MCP action argument. */
+ screenshot?: Awaited>
}
/**
diff --git a/src/main/chat-fallback.ts b/src/main/chat-fallback.ts
index bd06265d0..897292844 100644
--- a/src/main/chat-fallback.ts
+++ b/src/main/chat-fallback.ts
@@ -1,12 +1,15 @@
// Applies the shared chat fallback policy (@offgrid/models nextChatFallbackStep): a request that
// does not fit or was refused fails once; a server that could not answer is retried on the same
// model, then on at most one other local model, with one notice. It no longer walks every
-// installed model and remote server.
+// installed model and remote server. GPU faults restart the same model, then use its CPU engine.
import { nextChatFallbackStep, type ChatFailureKind } from '@offgrid/models'
+import { demoteBackend } from './backend-facts'
import { ModelServerError } from './llm/http-post'
import { parseRemoteVisionModelId, remoteVisionModelId } from '../shared/remote-vision-server'
+let deviceFallbackNotified = false
+
function failureKind(error: unknown): ChatFailureKind {
if (error instanceof ModelServerError) return error.kind
if (error instanceof Error && error.name === 'AbortError') return 'aborted'
@@ -40,11 +43,15 @@ export async function runChatWithFallback(
options: {
signal?: AbortSignal
canRetry?: () => boolean
+ onDeviceFallback?: (message: string) => void
+ onRestarting?: (restarting: boolean) => void
/** Both arguments are display names, never model ids. */
onModelChanged?: (failed: string, next: string) => void
} = {}
): Promise<{ result: T; modelName?: string }> {
let failures = 0
+ let deviceFailures = 0
+ let deviceRecovery = false
let modelName: string | undefined
let documentScale: number | undefined
for (;;) {
@@ -54,13 +61,47 @@ export async function runChatWithFallback(
} catch (error) {
if (options.signal?.aborted || options.canRetry?.() === false) throw error
failures++
- const kind = failureKind(error)
+ const { llm } = await import('./llm')
+ const reportedKind = failureKind(error)
+ const kind =
+ reportedKind === 'device_fault' ||
+ ((reportedKind === 'unavailable' || reportedKind === 'unknown') &&
+ (llm.hasDeviceFault() || deviceRecovery))
+ ? 'device_fault'
+ : reportedKind
+ if (kind === 'device_fault') {
+ deviceRecovery = true
+ deviceFailures++
+ }
const step = nextChatFallbackStep({
kind,
- failures,
- localAlternatives: failures === 2 && kind !== 'overflow' ? await localAlternatives() : []
+ failures: kind === 'device_fault' ? deviceFailures : failures,
+ localAlternatives:
+ failures === 2 && kind !== 'overflow' && !deviceRecovery ? await localAlternatives() : []
})
if (step.action === 'fail') throw error
+ if (step.action === 'restart-same' || step.action === 'restart-cpu') {
+ if (step.action === 'restart-cpu') {
+ const backend = llm.gpuBackend()
+ if (backend) demoteBackend('llm', backend)
+ // A second GPU must not replace the failed GPU during CPU recovery.
+ for (const gpu of ['cuda', 'vulkan', 'metal'] as const) demoteBackend('llm', gpu)
+ }
+ options.onRestarting?.(true)
+ try {
+ await llm.restartSameModel()
+ } finally {
+ options.onRestarting?.(false)
+ }
+ if (options.signal?.aborted) throw error
+ if (step.action === 'restart-cpu' && !deviceFallbackNotified && options.onDeviceFallback) {
+ deviceFallbackNotified = true
+ options.onDeviceFallback(
+ 'Chat moved to the CPU. The GPU stopped responding. Replies will be slower.'
+ )
+ }
+ continue
+ }
if (step.action === 'retry-same') continue
if (step.action === 'retry-smaller') {
documentScale = step.scale
diff --git a/src/main/data-privacy.ts b/src/main/data-privacy.ts
index c1d419361..d682ebf64 100644
--- a/src/main/data-privacy.ts
+++ b/src/main/data-privacy.ts
@@ -7,7 +7,7 @@ import path from 'path'
import { app } from 'electron'
import { getDB } from './database'
import { ATTACHED_DOCUMENT_TABLES } from './documents/sqlite-store'
-import { deleteByKinds, deleteByKindsOlderThan, resetVectors } from './vectors'
+import { releaseRecordIndex } from './records/store'
export interface DataCategory {
id: 'chats' | 'memories' | 'captures' | 'meetings' | 'images'
@@ -123,9 +123,10 @@ const CHAT_TABLES = [
'rag_conversations',
'rag_messages',
'chat_summaries',
- // Documents attached to chats: their text, passages, vectors and per-chat exclusions.
- ...ATTACHED_DOCUMENT_TABLES
+ 'conversation_document_exclusions'
]
+// memories, master_memory and entity_facts are retired (nothing writes or reads them); they stay
+// here so the rows a profile already has are still deleted with the rest.
const MEMORY_TABLES = [
'memories',
'master_memory',
@@ -135,6 +136,31 @@ const MEMORY_TABLES = [
'entity_sessions'
]
+/** Who keeps documents outside chats (a meeting its transcript, a long dictation its text): the
+ * owner prefix (`meeting` for "meeting:12") and the table its id lives in. Registered by the
+ * owning feature, so core never names its tables. */
+const keptDocumentOwners = new Map()
+
+export function registerKeptDocumentOwner(prefix: string, table: string): void {
+ keptDocumentOwners.set(prefix, table)
+}
+
+/**
+ * After rows were deleted by a category clear or retention: owners that are gone keep nothing,
+ * and every document nothing else uses (no chat, knowledge base or remaining owner) is deleted.
+ * Throws when a document cannot be deleted.
+ */
+async function forgetDocumentsOfDeletedOwners(): Promise {
+ if (!keptDocumentOwners.size) return
+ const { releaseDocumentsOfDeletedOwners, removeUnreferencedDocuments } = await import(
+ './documents'
+ )
+ let released = 0
+ for (const [prefix, table] of keptDocumentOwners)
+ released += releaseDocumentsOfDeletedOwners(prefix, table)
+ if (released) await removeUnreferencedDocuments()
+}
+
// The SINGLE source of truth for a FULL erase ("Delete all my data"): every store
// that holds personal data. deleteAllData iterates this — so a new personal table
// or directory is erased by REGISTERING it here (or, for a pro feature, via
@@ -152,15 +178,24 @@ interface PersonalStore {
}
const CORE_PERSONAL: PersonalStore[] = [
{ tables: CHAT_TABLES, dirs: ['uploads'] },
+ // Every indexed document (chat attachments, knowledge-base files, meeting transcripts, long
+ // dictations) and which owner keeps which.
+ { tables: ATTACHED_DOCUMENT_TABLES, dirs: [] },
{ tables: MEMORY_TABLES, dirs: ['entity-photos'] },
// Project metadata + knowledge base. Children precede parents so deletion also
// works when SQLite foreign-key enforcement is enabled.
{ tables: ['rag_documents', 'projects'], dirs: [] },
{ tables: ['connectors', 'secrets'], dirs: [] }, // MCP integrations + their OAuth tokens (must not survive a wipe)
+ // Memory-search vectors and the queue of records waiting to be embedded. The in-memory index
+ // built from them is dropped first.
+ { tables: ['record_vectors', 'record_queue'], dirs: [], beforeDelete: releaseRecordIndex },
{ tables: ['user_profile'], dirs: [] },
{ tables: [], dirs: ['captures'] },
{ tables: [], dirs: ['meetings'] },
- { tables: [], dirs: ['generated-images', 'artifacts-library', 'style-thumbs'] }
+ { tables: [], dirs: ['generated-images', 'artifacts-library', 'style-thumbs'] },
+ // The retired LanceDB index held a plaintext copy of captured text. The record schema removes it
+ // at first launch without waiting; a wipe erases it too, in case that removal did not finish.
+ { tables: [], dirs: ['lancedb'] }
]
const personalStores: PersonalStore[] = [...CORE_PERSONAL]
@@ -336,43 +371,29 @@ export async function clearCategory(
try {
return await withDeletionGuards({ scope: id, olderThanDays }, async () => {
switch (id) {
- case 'chats':
+ case 'chats': {
clearTables(...CHAT_TABLES)
clearDirs(ud('uploads'))
+ // The documents attached to those chats go with them; a knowledge base's files and the
+ // transcripts a meeting or dictation keeps stay.
+ const { removeUnreferencedDocuments } = await import('./documents')
+ await removeUnreferencedDocuments()
break
+ }
case 'memories':
+ // Each row's delete trigger removes its record vector in the same statement.
clearTables(...MEMORY_TABLES)
clearDirs(ud('entity-photos'))
- // Delete ONLY the memory-side vectors (not the shared lancedb dir — that
- // would wipe capture/meeting/chat vectors and dangle the live handle).
- await deleteByKinds(['memory', 'entity', 'fact'])
break
case 'captures':
- if (olderThanDays && olderThanDays > 0) {
- clearDirsOlderThan(olderThanDays, ud('captures'))
- await deleteByKindsOlderThan(['screen'], Date.now() - olderThanDays * 86_400_000) // prune stale capture vectors too
- } else {
- clearDirs(ud('captures'))
- await deleteByKinds(['screen']) // full clear → drop capture vectors too
- // Registered cleaners below remove the semantic source rows. Drop their indexing
- // receipts here as well so a future capture can never inherit a stale marker.
- try {
- getDB()
- .prepare("DELETE FROM vec_indexed WHERE key LIKE 'obs:%' OR key LIKE 'frame:%'")
- .run()
- } catch {
- /* search index has not been initialized */
- }
- }
+ // Registered cleaners below remove the capture rows; their delete triggers remove the
+ // record vectors and full-text entries with them.
+ if (olderThanDays && olderThanDays > 0) clearDirsOlderThan(olderThanDays, ud('captures'))
+ else clearDirs(ud('captures'))
break
case 'meetings':
- if (olderThanDays && olderThanDays > 0) {
- clearDirsOlderThan(olderThanDays, ud('meetings'))
- await deleteByKindsOlderThan(['meeting'], Date.now() - olderThanDays * 86_400_000) // prune stale meeting vectors too
- } else {
- clearDirs(ud('meetings'))
- await deleteByKinds(['meeting']) // full clear → drop meeting vectors too
- }
+ if (olderThanDays && olderThanDays > 0) clearDirsOlderThan(olderThanDays, ud('meetings'))
+ else clearDirs(ud('meetings'))
pruneDanglingMeetings() // drop rows whose media we just deleted (no ghosts)
break
case 'images':
@@ -382,6 +403,10 @@ export async function clearCategory(
for (const cleaner of categoryCleaners.get(id)?.values() ?? []) {
await cleaner({ olderThanDays })
}
+ // A meeting or recording deleted above (in full or by age) no longer keeps its transcript.
+ await forgetDocumentsOfDeletedOwners()
+ // Vectors of deleted rows went with them (delete triggers); the in-memory copy goes too.
+ releaseRecordIndex()
return { success: true }
})
} catch (e) {
@@ -400,9 +425,8 @@ export async function deleteAllData(): Promise<{ success: boolean }> {
return await withDeletionGuards({ scope: 'all' }, () => {
for (const store of personalStores) store.beforeDelete?.()
clearTables(...personalStores.flatMap((s) => s.tables ?? []))
- clearDirs(...personalStores.flatMap((s) => s.dirs ?? []).map((d) => ud(d)), ud('lancedb'))
+ clearDirs(...personalStores.flatMap((s) => s.dirs ?? []).map((d) => ud(d)))
clearFiles(...personalStores.flatMap((s) => s.files ?? []).map((file) => ud(file)))
- resetVectors() // the lancedb dir is gone — drop cached handles so it reopens clean
pruneDanglingMeetings() // media is gone now → drop all meeting rows (no ghosts)
return { success: true }
})
diff --git a/src/main/database.ts b/src/main/database.ts
index ccca31243..aeda528a8 100644
--- a/src/main/database.ts
+++ b/src/main/database.ts
@@ -5,7 +5,15 @@ import { selectedVersionRepairs, type StoredVersionRow } from '../shared/chat-ve
import Database from 'better-sqlite3-multiple-ciphers'
import { safeStorage } from 'electron'
import path from 'path'
-import { escapeFtsMatch } from './fts-match'
+import {
+ FTS_SCHEMA_VERSION,
+ FTS_TOKENIZE,
+ FTS_TRIGRAM_TOKENIZE,
+ ftsQuery,
+ ftsTypeahead,
+ needsTrigram,
+ normaliseForIndex
+} from '@offgrid/rag/records'
import { dataDir } from './runtime-env'
import fs from 'fs'
import crypto from 'crypto'
@@ -20,13 +28,6 @@ import type {
UserProfileContract
} from '../shared/ipc-contracts'
-/**
- * The full-text schema this build expects. Bump it when an FTS table, its columns or its triggers
- * change: that is what makes every existing profile rebuild its indexes exactly once, on the next
- * launch, instead of on every launch.
- */
-const FTS_SCHEMA_VERSION = 1
-
/** Conversation-list page size when a caller does not ask for one, and the ceiling it may ask for. */
const DEFAULT_CONVERSATION_PAGE = 500
const MAX_CONVERSATION_PAGE = 2_000
@@ -73,30 +74,633 @@ function loadOrCreateKey(dbPath: string): string | null {
}
}
-// Cosine similarity function for vector search
-// Returns similarity between 0 and 1 (1 = identical)
-function cosineSimilarity(v1Str: string, v2Str: string): number {
+// --- Full-text indexes --------------------------------------------------------------------------
+// Every FTS5 table uses one tokenizer (FTS_TOKENIZE: keeps Devanagari vowel signs inside words) and
+// indexes text exactly as normaliseForIndex leaves it (NFC, no zero-width joiners), so a word
+// matches the same way in every corpus. Triggers normalise through offgrid_fts_text(); a rebuild
+// re-inserts through it too, never with FTS5's 'rebuild' command, which would read the raw text.
+// Each table records the FTS_SCHEMA_VERSION it was built with in fts_schema, so a tokenizer or
+// normalisation change rebuilds every profile's indexes exactly once, in the background after
+// start-up (never inside getDB()). frame_fts is the exception: see frame-text-index.ts.
+//
+// Chinese, Japanese, Thai, Lao, Khmer and Burmese are written without spaces, so unicode61 makes a
+// whole run one token and a word inside it never matches. Each index with `trigram` set has a twin,
+// `${name}_tri`, on FTS5's trigram tokenizer (any substring of three characters or more), holding
+// only the rows whose text has such a script (offgrid_needs_trigram). See ensureTrigramIndex.
+
+/** The SQLite functions the FTS triggers call. Every connection that writes indexed tables needs them. */
+export function registerFtsFunctions(database: Database.Database): void {
+ database.function('offgrid_fts_text', { deterministic: true }, (value: unknown) =>
+ typeof value === 'string' ? normaliseForIndex(value) : (value as null)
+ )
+ // 1 when any argument is text in a script written without spaces (the trigram twins' filter).
+ database.function(
+ 'offgrid_needs_trigram',
+ { deterministic: true, varargs: true },
+ (...values: unknown[]) =>
+ values.some((value) => typeof value === 'string' && needsTrigram(value)) ? 1 : 0
+ )
+}
+
+/** An external-content FTS5 index over one table's text columns, kept in step by triggers. */
+export interface FtsIndexSpec {
+ /** The FTS5 table. */
+ name: string
+ /** The table it indexes, and the column that is that table's rowid. */
+ source: string
+ rowid: string
+ /** Searched columns; their text is normalised as it is indexed. */
+ columns: string[]
+ /** Stored with each row but not searched (e.g. the owning conversation). */
+ unindexed?: string[]
+ /** Its triggers are `${triggers}_ai`, `_ad` and `_au`. */
+ triggers: string
+ /** Also keep a trigram twin (`${name}_tri`) of the rows in no-space scripts. */
+ trigram?: boolean
+}
+
+export const CORE_FTS_INDEXES: FtsIndexSpec[] = [
+ {
+ name: 'rag_message_fts',
+ source: 'rag_messages',
+ rowid: 'id',
+ columns: ['content'],
+ unindexed: ['conversation_id'],
+ triggers: 'rag_messages',
+ trigram: true
+ },
+ {
+ name: 'summary_fts',
+ source: 'chat_summaries',
+ rowid: 'rowid',
+ columns: ['summary'],
+ unindexed: ['session_id'],
+ triggers: 'summaries',
+ trigram: true
+ },
+ {
+ name: 'entity_fts',
+ source: 'entities',
+ rowid: 'id',
+ columns: ['name', 'summary', 'type'],
+ triggers: 'entities',
+ trigram: true
+ }
+]
+
+/** Screen moments. Pro creates `observations`; the index is rebuilt here once the table exists. */
+export const OBSERVATION_FTS: FtsIndexSpec = {
+ name: 'observation_fts',
+ source: 'observations',
+ rowid: 'id',
+ columns: ['summary'],
+ triggers: 'observations',
+ trigram: true
+}
+
+/**
+ * The legacy `memories` and `entity_facts` stores are retired: nothing writes or searches them. Their
+ * rows stay (the user's data, cleared by "Delete all my data"); their full-text indexes, which were
+ * derived from those rows, and the triggers that kept them are removed so no stale copy of the text
+ * outlives the rows. Idempotent.
+ */
+function retireLegacyMemoryIndexes(database: Database.Database): void {
+ database.exec(`
+ DROP TRIGGER IF EXISTS memories_ai;
+ DROP TRIGGER IF EXISTS memories_ad;
+ DROP TRIGGER IF EXISTS memories_au;
+ DROP TRIGGER IF EXISTS memories_next_ai;
+ DROP TRIGGER IF EXISTS memories_next_ad;
+ DROP TRIGGER IF EXISTS memories_next_au;
+ DROP TRIGGER IF EXISTS entity_facts_ai;
+ DROP TRIGGER IF EXISTS entity_facts_ad;
+ DROP TRIGGER IF EXISTS entity_facts_au;
+ DROP TRIGGER IF EXISTS entity_facts_next_ai;
+ DROP TRIGGER IF EXISTS entity_facts_next_ad;
+ DROP TRIGGER IF EXISTS entity_facts_next_au;
+ DROP TABLE IF EXISTS memory_fts;
+ DROP TABLE IF EXISTS memory_fts_next;
+ DROP TABLE IF EXISTS entity_fact_fts;
+ DROP TABLE IF EXISTS entity_fact_fts_next;
+ `)
+ for (const name of ['memory_fts', 'entity_fact_fts']) clearRebuildCursor(database, name)
+}
+
+/** Whether a table (or virtual table) exists. The one check every store uses: Pro creates many of
+ * the tables core reads, lazily. */
+export function tableExists(database: Database.Database, name: string): boolean {
+ return !!database
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
+ .get(name)
+}
+
+/** True when `name` exists and was built with this build's tokenizer and normalisation. */
+export function ftsIndexIsCurrent(database: Database.Database, name: string): boolean {
+ database.exec(
+ 'CREATE TABLE IF NOT EXISTS fts_schema (name TEXT PRIMARY KEY, version INTEGER NOT NULL)'
+ )
+ const row = database.prepare('SELECT version FROM fts_schema WHERE name = ?').get(name) as
+ | { version: number }
+ | undefined
+ return !!row && row.version >= FTS_SCHEMA_VERSION && tableExists(database, name)
+}
+
+export function markFtsIndexCurrent(database: Database.Database, name: string): void {
+ database.exec(
+ 'CREATE TABLE IF NOT EXISTS fts_schema (name TEXT PRIMARY KEY, version INTEGER NOT NULL)'
+ )
+ database
+ .prepare(
+ 'INSERT INTO fts_schema (name, version) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET version = excluded.version'
+ )
+ .run(name, FTS_SCHEMA_VERSION)
+}
+
+/** Deleted terms are removed from the index itself, not only marked (FTS5 'secure-delete'). */
+export function enableFtsSecureDelete(database: Database.Database, name: string): void {
+ database.exec(`INSERT INTO ${name}(${name}, rank) VALUES('secure-delete', 1)`)
+}
+
+function ftsRowValues(spec: FtsIndexSpec, row: 'new' | 'old'): string {
+ return [
+ `${row}.${spec.rowid}`,
+ ...spec.columns.map((column) => `offgrid_fts_text(${row}.${column})`),
+ ...(spec.unindexed ?? []).map((column) => `${row}.${column}`)
+ ].join(', ')
+}
+
+function ftsColumnNames(spec: FtsIndexSpec): string {
+ return ['rowid', ...spec.columns, ...(spec.unindexed ?? [])].join(', ')
+}
+
+/** Create the three sync triggers if they are missing. */
+export function installFtsTriggers(database: Database.Database, spec: FtsIndexSpec): void {
+ const { name, source, triggers } = spec
+ const columns = ftsColumnNames(spec)
+ database.exec(`
+ CREATE TRIGGER IF NOT EXISTS ${triggers}_ai AFTER INSERT ON ${source} BEGIN
+ INSERT INTO ${name}(${columns}) VALUES (${ftsRowValues(spec, 'new')});
+ END;
+ CREATE TRIGGER IF NOT EXISTS ${triggers}_ad AFTER DELETE ON ${source} BEGIN
+ INSERT INTO ${name}(${name}, ${columns}) VALUES ('delete', ${ftsRowValues(spec, 'old')});
+ END;
+ CREATE TRIGGER IF NOT EXISTS ${triggers}_au AFTER UPDATE ON ${source} BEGIN
+ INSERT INTO ${name}(${name}, ${columns}) VALUES ('delete', ${ftsRowValues(spec, 'old')});
+ INSERT INTO ${name}(${columns}) VALUES (${ftsRowValues(spec, 'new')});
+ END;
+ `)
+}
+
+/** Re-index every row of the source table (the index is emptied first). One statement: only for
+ * rare repairs (the legacy purge, "clear memories"); a schema upgrade rebuilds in the background
+ * (ensureFtsIndex queues it). */
+export function rebuildFtsIndex(database: Database.Database, spec: FtsIndexSpec): void {
+ database.exec(`INSERT INTO ${spec.name}(${spec.name}) VALUES('delete-all')`)
+ database.exec(
+ `INSERT INTO ${spec.name}(${ftsColumnNames(spec)})
+ SELECT ${ftsSelectValues(spec)} FROM ${spec.source}`
+ )
+ const twin = trigramName(spec)
+ // A twin still being filled in the background is left to finish (it reads the rows as they are).
+ if (spec.trigram && ftsIndexIsCurrent(database, twin)) {
+ database.exec(`INSERT INTO ${twin}(${twin}) VALUES('delete-all')`)
+ database.exec(
+ `INSERT INTO ${twin}(${trigramColumnNames(spec)})
+ SELECT ${trigramSelectValues(spec)} FROM ${spec.source} WHERE ${trigramFilter(spec)}`
+ )
+ }
+}
+
+function ftsSelectValues(spec: FtsIndexSpec): string {
+ return [
+ spec.rowid,
+ ...spec.columns.map((column) => `offgrid_fts_text(${column})`),
+ ...(spec.unindexed ?? [])
+ ].join(', ')
+}
+
+function createFtsTable(database: Database.Database, spec: FtsIndexSpec, name: string): void {
+ const columns = [...spec.columns, ...(spec.unindexed ?? []).map((c) => `${c} UNINDEXED`)]
+ database.exec(`
+ CREATE VIRTUAL TABLE ${name} USING fts5(
+ ${columns.join(', ')},
+ content='${spec.source}'${spec.rowid === 'rowid' ? '' : `, content_rowid='${spec.rowid}'`},
+ tokenize = "${FTS_TOKENIZE}"
+ );
+ `)
+ enableFtsSecureDelete(database, name)
+}
+
+/**
+ * Make `spec`'s index usable without reading its rows, so it is safe inside getDB() and on any
+ * request path:
+ * - current: put back triggers something dropped;
+ * - missing: create it (empty) with its triggers; an empty source table makes it current;
+ * - built by an older schema: leave it serving, with the triggers it was built with (their
+ * deletes must match what it indexed), until the background rebuild replaces it.
+ * Returns true when the index still needs rebuilding (queued; startIndexRebuilds runs it). A no-op
+ * until the source table exists.
+ */
+/** Indexes small enough to verify at startup (to-dos, meetings): FTS5's own integrity check. */
+const VERIFIED_AT_STARTUP = new Set(['action_fts', 'meeting_fts'])
+const verified = new Set()
+
+/**
+ * An index FTS5 reports inconsistent with its table (an entry deleted that was never added) is
+ * marked out of date, so the background rebuild replaces it. Once per index per launch.
+ */
+function verifySmallIndex(database: Database.Database, spec: FtsIndexSpec): void {
+ if (!VERIFIED_AT_STARTUP.has(spec.name) || verified.has(spec.name)) return
+ verified.add(spec.name)
+ if (!tableExists(database, spec.name) || !ftsIndexIsCurrent(database, spec.name)) return
try {
- const v1 = JSON.parse(v1Str) as number[]
- const v2 = JSON.parse(v2Str) as number[]
+ database.exec(`INSERT INTO ${spec.name}(${spec.name}, rank) VALUES ('integrity-check', 1)`)
+ } catch (error) {
+ console.warn(`[fts] ${spec.name} is inconsistent; rebuilding it:`, (error as Error).message)
+ // Its triggers stop now, so edits cannot fail on it; the rebuild puts them back with the swap.
+ database.exec(`
+ DROP TRIGGER IF EXISTS ${spec.triggers}_ai;
+ DROP TRIGGER IF EXISTS ${spec.triggers}_ad;
+ DROP TRIGGER IF EXISTS ${spec.triggers}_au;
+ `)
+ database.prepare('DELETE FROM fts_schema WHERE name = ?').run(spec.name)
+ }
+}
+
+export function ensureFtsIndex(database: Database.Database, spec: FtsIndexSpec): boolean {
+ verifySmallIndex(database, spec)
+ const pending = ensureMainFtsIndex(database, spec)
+ if (spec.trigram) ensureTrigramIndex(database, spec)
+ return pending
+}
+
+function ensureMainFtsIndex(database: Database.Database, spec: FtsIndexSpec): boolean {
+ if (!tableExists(database, spec.source)) return false
+ if (ftsIndexIsCurrent(database, spec.name)) {
+ installFtsTriggers(database, spec)
+ return false
+ }
+ if (!tableExists(database, spec.name)) {
+ database.transaction(() => {
+ database.exec(`
+ DROP TRIGGER IF EXISTS ${spec.triggers}_ai;
+ DROP TRIGGER IF EXISTS ${spec.triggers}_ad;
+ DROP TRIGGER IF EXISTS ${spec.triggers}_au;
+ `)
+ createFtsTable(database, spec, spec.name)
+ // Triggers only once every existing row is indexed. On a table that already has rows, an
+ // update or delete would otherwise remove an entry that was never added, which FTS5 (with
+ // secure-delete) reports as a malformed index. The background rebuild indexes the rows into a
+ // twin with guarded triggers and installs these triggers when it swaps the twin in.
+ if (!database.prepare(`SELECT 1 FROM ${spec.source} LIMIT 1`).get()) {
+ installFtsTriggers(database, spec)
+ markFtsIndexCurrent(database, spec.name)
+ }
+ })()
+ if (ftsIndexIsCurrent(database, spec.name)) return false
+ }
+ queueIndexRebuild(spec.name, ftsRebuildStep(spec))
+ return true
+}
+
+// --- Trigram twins -------------------------------------------------------------------------------
+// `${name}_tri` is an external-content FTS5 table over the same source table and columns, on the
+// trigram tokenizer, holding only the rows offgrid_needs_trigram picks (mostly Latin profiles hold
+// next to nothing in it). Its triggers carry the same filter, so a delete removes exactly what an
+// insert indexed; 'secure-delete' erases the deleted terms, as on every other index. A missing or
+// outdated twin is created empty with its triggers and filled in the background in rowid order
+// (fts_rebuild holds how far): until then its triggers index only rows already copied, and later
+// rows are copied with their latest text, so no row is indexed twice or deleted unindexed. frame_fts
+// has no twin: it is every word ever on screen, and observation summaries cover the moments.
+
+/** The trigram twin of an index. */
+export function trigramName(spec: { name: string }): string {
+ return `${spec.name}_tri`
+}
+
+function trigramColumnNames(spec: FtsIndexSpec): string {
+ return ['rowid', ...spec.columns].join(', ')
+}
+
+function trigramRowValues(spec: FtsIndexSpec, row: 'new' | 'old'): string {
+ return [
+ `${row}.${spec.rowid}`,
+ ...spec.columns.map((column) => `offgrid_fts_text(${row}.${column})`)
+ ].join(', ')
+}
+
+function trigramSelectValues(spec: FtsIndexSpec): string {
+ return [spec.rowid, ...spec.columns.map((column) => `offgrid_fts_text(${column})`)].join(', ')
+}
+
+/** True for a row the twin indexes. `row` is 'new', 'old' or '' (the source table itself). */
+function trigramFilter(spec: FtsIndexSpec, row: 'new' | 'old' | '' = ''): string {
+ const prefix = row ? `${row}.` : ''
+ return `offgrid_needs_trigram(${spec.columns.map((column) => prefix + column).join(', ')})`
+}
- if (v1.length !== v2.length) return 0
+function createTrigramTable(database: Database.Database, spec: FtsIndexSpec): void {
+ const name = trigramName(spec)
+ database.exec(`
+ CREATE VIRTUAL TABLE ${name} USING fts5(
+ ${spec.columns.join(', ')},
+ content='${spec.source}'${spec.rowid === 'rowid' ? '' : `, content_rowid='${spec.rowid}'`},
+ tokenize = "${FTS_TRIGRAM_TOKENIZE}"
+ );
+ `)
+ enableFtsSecureDelete(database, name)
+}
+
+/** The twin's triggers. While it is being filled, only rows the fill has copied are kept in step. */
+function installTrigramTriggers(database: Database.Database, spec: FtsIndexSpec): void {
+ const name = trigramName(spec)
+ const columns = trigramColumnNames(spec)
+ rebuildState(database) // the triggers read fts_rebuild
+ const copied = (row: 'new' | 'old'): string =>
+ `${row}.${spec.rowid} <= COALESCE((SELECT cursor FROM fts_rebuild WHERE name = '${name}'), ${Number.MAX_SAFE_INTEGER})`
+ const indexed = (row: 'new' | 'old'): string => `${trigramFilter(spec, row)} AND ${copied(row)}`
+ database.exec(`
+ CREATE TRIGGER IF NOT EXISTS ${spec.triggers}_tri_ai AFTER INSERT ON ${spec.source}
+ WHEN ${indexed('new')} BEGIN
+ INSERT INTO ${name}(${columns}) VALUES (${trigramRowValues(spec, 'new')});
+ END;
+ CREATE TRIGGER IF NOT EXISTS ${spec.triggers}_tri_ad AFTER DELETE ON ${spec.source}
+ WHEN ${indexed('old')} BEGIN
+ INSERT INTO ${name}(${name}, ${columns}) VALUES ('delete', ${trigramRowValues(spec, 'old')});
+ END;
+ CREATE TRIGGER IF NOT EXISTS ${spec.triggers}_tri_au AFTER UPDATE OF ${spec.columns.join(', ')} ON ${spec.source}
+ WHEN ${trigramFilter(spec, 'old')} OR ${trigramFilter(spec, 'new')} BEGIN
+ INSERT INTO ${name}(${name}, ${columns}) SELECT 'delete', ${trigramRowValues(spec, 'old')} WHERE ${indexed('old')};
+ INSERT INTO ${name}(${columns}) SELECT ${trigramRowValues(spec, 'new')} WHERE ${indexed('new')};
+ END;
+ `)
+}
- let dotProduct = 0
- let normA = 0
- let normB = 0
+function dropTrigramTriggers(database: Database.Database, spec: FtsIndexSpec): void {
+ database.exec(`
+ DROP TRIGGER IF EXISTS ${spec.triggers}_tri_ai;
+ DROP TRIGGER IF EXISTS ${spec.triggers}_tri_ad;
+ DROP TRIGGER IF EXISTS ${spec.triggers}_tri_au;
+ `)
+}
- for (let i = 0; i < v1.length; i++) {
- // i < v1.length === v2.length (checked above), so both reads are in-bounds.
- dotProduct += v1[i]! * v2[i]!
- normA += v1[i]! * v1[i]!
- normB += v2[i]! * v2[i]!
+/**
+ * Make `spec`'s trigram twin usable without reading its rows (safe inside getDB()): a current twin
+ * gets back any trigger something dropped; a missing or outdated one is created empty and filled in
+ * the background after start-up (trigramFillStep); a fill under way resumes. A no-op until the
+ * source table exists.
+ */
+export function ensureTrigramIndex(database: Database.Database, spec: FtsIndexSpec): void {
+ if (!tableExists(database, spec.source)) return
+ const name = trigramName(spec)
+ if (ftsIndexIsCurrent(database, name)) {
+ installTrigramTriggers(database, spec)
+ return
+ }
+ if (rebuildCursor(database, name) === null || !tableExists(database, name)) {
+ database.transaction(() => {
+ dropTrigramTriggers(database, spec)
+ database.exec(`DROP TABLE IF EXISTS ${name}`)
+ createTrigramTable(database, spec)
+ if (database.prepare(`SELECT 1 FROM ${spec.source} LIMIT 1`).get()) {
+ setRebuildCursor(database, name, 0)
+ } else {
+ clearRebuildCursor(database, name)
+ markFtsIndexCurrent(database, name)
+ }
+ installTrigramTriggers(database, spec)
+ })()
+ if (ftsIndexIsCurrent(database, name)) return
+ } else {
+ installTrigramTriggers(database, spec)
+ }
+ queueIndexRebuild(name, trigramFillStep(spec))
+}
+
+/** One slice of filling a twin with the source rows that need it, in rowid order. The last slice
+ * ends the fill (its triggers then keep every row in step) in the same transaction. */
+function trigramFillStep(spec: FtsIndexSpec): () => boolean {
+ let batch = 500
+ const name = trigramName(spec)
+ return () => {
+ const database = getDB()
+ if (!tableExists(database, spec.source) || !tableExists(database, name)) return false
+ const cursor = rebuildCursor(database, name)
+ if (cursor === null) return false
+ const started = Date.now()
+ const upper = (
+ database
+ .prepare(
+ `SELECT MAX(r) AS r FROM (SELECT ${spec.rowid} AS r FROM ${spec.source}
+ WHERE ${spec.rowid} > ? ORDER BY ${spec.rowid} LIMIT ?)`
+ )
+ .get(cursor, batch) as { r: number | null }
+ ).r
+ if (upper === null) {
+ database.transaction(() => {
+ clearRebuildCursor(database, name)
+ markFtsIndexCurrent(database, name)
+ })()
+ return false
}
+ database.transaction(() => {
+ database
+ .prepare(
+ `INSERT INTO ${name}(${trigramColumnNames(spec)})
+ SELECT ${trigramSelectValues(spec)} FROM ${spec.source}
+ WHERE ${spec.rowid} > ? AND ${spec.rowid} <= ? AND ${trigramFilter(spec)}`
+ )
+ .run(cursor, upper)
+ setRebuildCursor(database, name, upper)
+ })()
+ batch = adaptBatch(batch, Date.now() - started)
+ return true
+ }
+}
- if (normA === 0 || normB === 0) return 0
- return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
- } catch {
- return 0
+// --- Background index rebuilds ------------------------------------------------------------------
+// A tokenizer or normalisation change (FTS_SCHEMA_VERSION) rebuilds each index beside the old one,
+// `${name}_next`, a slice at a time on the main thread's idle moments, while the old index keeps
+// answering searches. Rows are copied in rowid order; fts_rebuild holds how far the copy got, and
+// the _next triggers mirror only edits to rows already copied (later rows are copied with their
+// latest text). When every row is in, the new index replaces the old in one transaction. Progress
+// survives a restart. Nothing here runs until startIndexRebuilds() (after start-up). The same queue
+// and cursors (fts_rebuild) serve the document index rebuild, the trigram twins' fills and the
+// memory-search queue seeding.
+
+/** A slice indexes for about this long, then the thread rests. */
+const REBUILD_SLICE_MS = 20
+const REBUILD_PAUSE_MS = 60
+
+const pendingRebuilds = new Map boolean>()
+let rebuildsStarted = false
+let rebuildTimer: ReturnType | null = null
+
+/** Queue background work (one slice per call; it returns whether more remains) under `name`. */
+export function queueIndexRebuild(name: string, step: () => boolean): void {
+ if (!pendingRebuilds.has(name)) pendingRebuilds.set(name, step)
+ if (rebuildsStarted) scheduleRebuildSlice()
+}
+
+/** Start the queued index rebuilds, a slice at a time. Called once start-up has settled. */
+export function startIndexRebuilds(): void {
+ rebuildsStarted = true
+ scheduleRebuildSlice()
+}
+
+/** Stop between slices (shutdown, profile switch). Progress is kept; the next start resumes. */
+export function stopIndexRebuilds(): void {
+ rebuildsStarted = false
+ if (rebuildTimer) clearTimeout(rebuildTimer)
+ rebuildTimer = null
+}
+
+function scheduleRebuildSlice(): void {
+ if (rebuildTimer || !pendingRebuilds.size) return
+ rebuildTimer = setTimeout(runRebuildSlice, REBUILD_PAUSE_MS)
+ rebuildTimer.unref()
+}
+
+function runRebuildSlice(): void {
+ rebuildTimer = null
+ // A closed connection (shutdown, profile switch) is not reopened for background work.
+ if (!rebuildsStarted || !db?.open) return
+ const next = pendingRebuilds.entries().next()
+ if (next.done) return
+ const [name, step] = next.value
+ let more = false
+ try {
+ more = step()
+ } catch (error) {
+ // Not marked current, so it is retried on the next launch; the old index keeps serving.
+ console.error(`[database] background rebuild of ${name} failed; will retry`, error)
+ }
+ if (!more) pendingRebuilds.delete(name)
+ scheduleRebuildSlice()
+}
+
+/** Rows a slice copies; adjusted so a slice takes about REBUILD_SLICE_MS. */
+function adaptBatch(batch: number, elapsedMs: number): number {
+ if (elapsedMs > REBUILD_SLICE_MS) return Math.max(10, Math.floor(batch / 2))
+ if (elapsedMs < REBUILD_SLICE_MS / 2) return Math.min(5_000, batch * 2)
+ return batch
+}
+
+function rebuildState(database: Database.Database): void {
+ database.exec(
+ 'CREATE TABLE IF NOT EXISTS fts_rebuild (name TEXT PRIMARY KEY, cursor INTEGER NOT NULL)'
+ )
+}
+
+/** The highest rowid copied into `name`'s rebuild; null when no rebuild is under way. */
+export function rebuildCursor(database: Database.Database, name: string): number | null {
+ rebuildState(database)
+ const row = database.prepare('SELECT cursor FROM fts_rebuild WHERE name = ?').get(name) as
+ | { cursor: number }
+ | undefined
+ return row ? row.cursor : null
+}
+
+export function setRebuildCursor(database: Database.Database, name: string, cursor: number): void {
+ rebuildState(database)
+ database
+ .prepare(
+ 'INSERT INTO fts_rebuild (name, cursor) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET cursor = excluded.cursor'
+ )
+ .run(name, cursor)
+}
+
+export function clearRebuildCursor(database: Database.Database, name: string): void {
+ rebuildState(database)
+ database.prepare('DELETE FROM fts_rebuild WHERE name = ?').run(name)
+}
+
+/** Triggers that mirror edits to rows already copied into the rebuild. */
+function installRebuildTriggers(database: Database.Database, spec: FtsIndexSpec): void {
+ const next = `${spec.name}_next`
+ const columns = ftsColumnNames(spec)
+ const copied = (row: 'new' | 'old'): string =>
+ `${row}.${spec.rowid} <= (SELECT cursor FROM fts_rebuild WHERE name = '${spec.name}')`
+ database.exec(`
+ CREATE TRIGGER IF NOT EXISTS ${spec.triggers}_next_ai AFTER INSERT ON ${spec.source}
+ WHEN ${copied('new')} BEGIN
+ INSERT INTO ${next}(${columns}) VALUES (${ftsRowValues(spec, 'new')});
+ END;
+ CREATE TRIGGER IF NOT EXISTS ${spec.triggers}_next_ad AFTER DELETE ON ${spec.source}
+ WHEN ${copied('old')} BEGIN
+ INSERT INTO ${next}(${next}, ${columns}) VALUES ('delete', ${ftsRowValues(spec, 'old')});
+ END;
+ CREATE TRIGGER IF NOT EXISTS ${spec.triggers}_next_au AFTER UPDATE ON ${spec.source} BEGIN
+ INSERT INTO ${next}(${next}, ${columns}) SELECT 'delete', ${ftsRowValues(spec, 'old')} WHERE ${copied('old')};
+ INSERT INTO ${next}(${columns}) SELECT ${ftsRowValues(spec, 'new')} WHERE ${copied('new')};
+ END;
+ `)
+}
+
+function dropRebuildTriggers(database: Database.Database, spec: FtsIndexSpec): void {
+ database.exec(`
+ DROP TRIGGER IF EXISTS ${spec.triggers}_next_ai;
+ DROP TRIGGER IF EXISTS ${spec.triggers}_next_ad;
+ DROP TRIGGER IF EXISTS ${spec.triggers}_next_au;
+ `)
+}
+
+/** One slice of `spec`'s background rebuild; the last one swaps the new index in. */
+function ftsRebuildStep(spec: FtsIndexSpec): () => boolean {
+ let batch = 200
+ const next = `${spec.name}_next`
+ return () => {
+ const database = getDB()
+ if (!tableExists(database, spec.source) || ftsIndexIsCurrent(database, spec.name)) return false
+ let cursor = rebuildCursor(database, spec.name)
+ if (cursor === null || !tableExists(database, next)) {
+ database.transaction(() => {
+ dropRebuildTriggers(database, spec)
+ database.exec(`DROP TABLE IF EXISTS ${next}`)
+ createFtsTable(database, spec, next)
+ setRebuildCursor(database, spec.name, 0)
+ installRebuildTriggers(database, spec)
+ })()
+ cursor = 0
+ }
+ const started = Date.now()
+ const upper = (
+ database
+ .prepare(
+ `SELECT MAX(r) AS r FROM (SELECT ${spec.rowid} AS r FROM ${spec.source}
+ WHERE ${spec.rowid} > ? ORDER BY ${spec.rowid} LIMIT ?)`
+ )
+ .get(cursor, batch) as { r: number | null }
+ ).r
+ const copy = database.prepare(
+ `INSERT INTO ${next}(${ftsColumnNames(spec)})
+ SELECT ${ftsSelectValues(spec)} FROM ${spec.source}
+ WHERE ${spec.rowid} > ? AND ${spec.rowid} <= ?`
+ )
+ if (upper === null) {
+ // Every row is in: the new index replaces the old, with the normalising triggers.
+ database.transaction(() => {
+ copy.run(cursor, Number.MAX_SAFE_INTEGER)
+ dropRebuildTriggers(database, spec)
+ database.exec(`
+ DROP TRIGGER IF EXISTS ${spec.triggers}_ai;
+ DROP TRIGGER IF EXISTS ${spec.triggers}_ad;
+ DROP TRIGGER IF EXISTS ${spec.triggers}_au;
+ DROP TABLE IF EXISTS ${spec.name};
+ ALTER TABLE ${next} RENAME TO ${spec.name};
+ `)
+ installFtsTriggers(database, spec)
+ markFtsIndexCurrent(database, spec.name)
+ clearRebuildCursor(database, spec.name)
+ })()
+ return false
+ }
+ database.transaction(() => {
+ copy.run(cursor, upper)
+ setRebuildCursor(database, spec.name, upper)
+ })()
+ batch = adaptBatch(batch, Date.now() - started)
+ return true
}
}
@@ -128,15 +732,11 @@ export function getDB(): Database.Database {
db.pragma('busy_timeout = 5000')
db.pragma('temp_store = MEMORY')
db.pragma('cache_size = -65536')
-
- // Register custom function for vector search. Declare the two params EXPLICITLY:
- // better-sqlite3 derives the SQL arity from fn.length, and a rest param
- // (...args) has length 0 — so it registered as a 0-arg function and every
- // 2-arg call ("SELECT cosine_similarity(embedding, ?)") threw "wrong number of
- // arguments to function cosine_similarity()".
- db.function('cosine_similarity', (a: unknown, b: unknown) =>
- cosineSimilarity(a as string, b as string)
- )
+ // Deleted content is overwritten inside the pages it lived in (screenpipe leaves it; a deleted
+ // capture must not be readable from the file). FAST adds no I/O: pages already being written are
+ // zeroed, whole freed pages are not. The FTS5 indexes also use their own 'secure-delete'.
+ db.pragma('secure_delete = FAST')
+ registerFtsFunctions(db)
// Initialize Schema
db.exec(`
@@ -159,60 +759,17 @@ export function getDB(): Database.Database {
FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
- -- Legacy 'memories' for vector search (optional link to message_id later)
+ -- Legacy memories, retired: nothing writes, indexes, embeds or searches them. Existing rows stay
+ -- until the user deletes their data ("Delete all my data" clears them).
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
- raw_text TEXT,
+ raw_text TEXT,
source_app TEXT,
- session_id TEXT,
+ session_id TEXT,
message_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- embedding TEXT
- );
-
- CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
- content,
- content='memories'
- );
-
- CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(
- content,
- conversation_id UNINDEXED,
- content='messages',
- content_rowid='id'
- );
-
- -- Chat message content, searchable. The chat-list content search used to be a
- -- lower(content) LIKE match per term over rag_messages, which no index can serve: every
- -- keystroke's search read every message ever stored. External-content FTS5, same shape as
- -- message_fts above, so the index holds terms and the rows stay in rag_messages.
- CREATE VIRTUAL TABLE IF NOT EXISTS rag_message_fts USING fts5(
- content,
- conversation_id UNINDEXED,
- content='rag_messages',
- content_rowid='id'
- );
-
- CREATE VIRTUAL TABLE IF NOT EXISTS summary_fts USING fts5(
- summary,
- session_id UNINDEXED,
- content='chat_summaries'
- );
-
- CREATE VIRTUAL TABLE IF NOT EXISTS entity_fts USING fts5(
- name,
- summary,
- type,
- content='entities',
- content_rowid='id'
- );
-
- CREATE VIRTUAL TABLE IF NOT EXISTS entity_fact_fts USING fts5(
- fact,
- entity_id UNINDEXED,
- content='entity_facts',
- content_rowid='id'
+ embedding TEXT
);
CREATE TABLE IF NOT EXISTS entities (
@@ -225,6 +782,7 @@ export function getDB(): Database.Database {
UNIQUE(name, type)
);
+ -- Legacy entity facts, retired like memories: rows stay, nothing writes or reads them.
CREATE TABLE IF NOT EXISTS entity_facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER NOT NULL,
@@ -270,7 +828,7 @@ export function getDB(): Database.Database {
);
`)
- // Create Master Memory Table - cumulative summary of all summaries
+ // Master Memory Table - the retired cumulative profile; nothing writes or reads it.
db.exec(`
CREATE TABLE IF NOT EXISTS master_memory (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -318,77 +876,27 @@ export function getDB(): Database.Database {
initializeSettingsStore(db)
- // Triggers to keep FTS in sync
- const triggers = [
- `CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
- INSERT INTO memory_fts(rowid, content) VALUES (new.id, new.content);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
- INSERT INTO memory_fts(memory_fts, rowid, content) VALUES('delete', old.id, old.content);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
- INSERT INTO memory_fts(memory_fts, rowid, content) VALUES('delete', old.id, old.content);
- INSERT INTO memory_fts(rowid, content) VALUES (new.id, new.content);
- END;`,
-
- `CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
- INSERT INTO message_fts(rowid, content, conversation_id) VALUES (new.id, new.content, new.conversation_id);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
- INSERT INTO message_fts(message_fts, rowid, content, conversation_id) VALUES('delete', old.id, old.content, old.conversation_id);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
- INSERT INTO message_fts(message_fts, rowid, content, conversation_id) VALUES('delete', old.id, old.content, old.conversation_id);
- INSERT INTO message_fts(rowid, content, conversation_id) VALUES (new.id, new.content, new.conversation_id);
- END;`,
-
- `CREATE TRIGGER IF NOT EXISTS rag_messages_ai AFTER INSERT ON rag_messages BEGIN
- INSERT INTO rag_message_fts(rowid, content, conversation_id) VALUES (new.id, new.content, new.conversation_id);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS rag_messages_ad AFTER DELETE ON rag_messages BEGIN
- INSERT INTO rag_message_fts(rag_message_fts, rowid, content, conversation_id) VALUES('delete', old.id, old.content, old.conversation_id);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS rag_messages_au AFTER UPDATE ON rag_messages BEGIN
- INSERT INTO rag_message_fts(rag_message_fts, rowid, content, conversation_id) VALUES('delete', old.id, old.content, old.conversation_id);
- INSERT INTO rag_message_fts(rowid, content, conversation_id) VALUES (new.id, new.content, new.conversation_id);
- END;`,
-
- `CREATE TRIGGER IF NOT EXISTS summaries_ai AFTER INSERT ON chat_summaries BEGIN
- INSERT INTO summary_fts(rowid, summary, session_id) VALUES (new.rowid, new.summary, new.session_id);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS summaries_ad AFTER DELETE ON chat_summaries BEGIN
- INSERT INTO summary_fts(summary_fts, rowid, summary, session_id) VALUES('delete', old.rowid, old.summary, old.session_id);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS summaries_au AFTER UPDATE ON chat_summaries BEGIN
- INSERT INTO summary_fts(summary_fts, rowid, summary, session_id) VALUES('delete', old.rowid, old.summary, old.session_id);
- INSERT INTO summary_fts(rowid, summary, session_id) VALUES (new.rowid, new.summary, new.session_id);
- END;`,
-
- `CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
- INSERT INTO entity_fts(rowid, name, summary, type) VALUES (new.id, new.name, new.summary, new.type);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
- INSERT INTO entity_fts(entity_fts, rowid, name, summary, type) VALUES('delete', old.id, old.name, old.summary, old.type);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
- INSERT INTO entity_fts(entity_fts, rowid, name, summary, type) VALUES('delete', old.id, old.name, old.summary, old.type);
- INSERT INTO entity_fts(rowid, name, summary, type) VALUES (new.id, new.name, new.summary, new.type);
- END;`,
-
- `CREATE TRIGGER IF NOT EXISTS entity_facts_ai AFTER INSERT ON entity_facts BEGIN
- INSERT INTO entity_fact_fts(rowid, fact, entity_id) VALUES (new.id, new.fact, new.entity_id);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS entity_facts_ad AFTER DELETE ON entity_facts BEGIN
- INSERT INTO entity_fact_fts(entity_fact_fts, rowid, fact, entity_id) VALUES('delete', old.id, old.fact, old.entity_id);
- END;`,
- `CREATE TRIGGER IF NOT EXISTS entity_facts_au AFTER UPDATE ON entity_facts BEGIN
- INSERT INTO entity_fact_fts(entity_fact_fts, rowid, fact, entity_id) VALUES('delete', old.id, old.fact, old.entity_id);
- INSERT INTO entity_fact_fts(rowid, fact, entity_id) VALUES (new.id, new.fact, new.entity_id);
- END;`
- ]
-
- for (const trigger of triggers) {
- db.exec(trigger)
+ // Every full-text index over the core tables, on the shared tokenizer. message_fts indexed the
+ // legacy scraped `messages` table for the retired rag:chat retrieval; nothing reads it now.
+ db.exec(`
+ DROP TRIGGER IF EXISTS messages_ai;
+ DROP TRIGGER IF EXISTS messages_ad;
+ DROP TRIGGER IF EXISTS messages_au;
+ DROP TABLE IF EXISTS message_fts;
+ `)
+ // Cheap here: a missing index is created empty, and one built by an older schema keeps serving
+ // while it is rebuilt in the background after start-up (startIndexRebuilds).
+ for (const spec of [...CORE_FTS_INDEXES, OBSERVATION_FTS]) {
+ try {
+ ensureFtsIndex(db, spec)
+ } catch (error) {
+ console.error(`[database] full-text index ${spec.name} could not be prepared`, error)
+ }
+ }
+ try {
+ retireLegacyMemoryIndexes(db)
+ } catch (error) {
+ console.error('[database] legacy memory indexes could not be removed', error)
}
// The search index that makes the conversation list's content search an index lookup instead of
@@ -404,34 +912,6 @@ export function getDB(): Database.Database {
WHERE role = 'user' AND context LIKE '%"attachments"%'`
)
- /**
- * FTS backfill, versioned so it happens once per profile instead of once per launch.
- *
- * Rebuilding five full-text indexes reads every message, summary, entity and fact in the
- * database, synchronously, on Electron's main thread. It ran on EVERY startup - so the bigger a
- * user's history got, the longer their app took to become usable, for work that had already been
- * done. `PRAGMA user_version` is the gate: bumping FTS_SCHEMA_VERSION is how a future change to
- * an FTS table or trigger repairs every existing profile exactly once.
- *
- * Nothing is trimmed or dropped here. This only rebuilds a derived index from rows that stay
- * exactly as they are.
- */
- const installedFtsVersion = Number(db.pragma('user_version', { simple: true }) ?? 0)
- if (installedFtsVersion < FTS_SCHEMA_VERSION) {
- try {
- db.exec("INSERT INTO message_fts(message_fts) VALUES('rebuild')")
- db.exec("INSERT INTO rag_message_fts(rag_message_fts) VALUES('rebuild')")
- db.exec("INSERT INTO summary_fts(summary_fts) VALUES('rebuild')")
- db.exec("INSERT INTO entity_fts(entity_fts) VALUES('rebuild')")
- db.exec("INSERT INTO entity_fact_fts(entity_fact_fts) VALUES('rebuild')")
- db.pragma(`user_version = ${FTS_SCHEMA_VERSION}`)
- } catch (error) {
- // The version is deliberately NOT advanced: a failed rebuild must be retried on the next
- // launch, not remembered as done. Search degrades to whatever the triggers have indexed.
- console.error('[database] full-text index rebuild failed; will retry next launch', error)
- }
- }
-
// Migration: Add timestamp column to messages if it doesn't exist
try {
db.exec(`ALTER TABLE messages ADD COLUMN timestamp TEXT`)
@@ -576,7 +1056,6 @@ export interface ChatSessionRecord {
title: string | null
source_app: string | null
last_activity: string
- memory_count: number
entity_count: number
summary: string | null
}
@@ -591,27 +1070,15 @@ export interface MessageRecord {
created_at: string
}
-export interface MemoryRecord {
- id: number
- content: string
- raw_text: string | null
- source_app: string | null
- session_id: string | null
- message_id: number | null
- created_at: string
- name: string | null
-}
-
export function getChatSessions(): ChatSessionRecord[] {
const db = getDB()
- // Query conversations with memory and entity counts instead of message count
+ // Query conversations with entity counts instead of message count
const query = `
SELECT
c.id as session_id,
c.title,
c.app_name as source_app,
c.updated_at as last_activity,
- (SELECT COUNT(*) FROM memories mem WHERE mem.session_id = c.id) as memory_count,
(SELECT COUNT(DISTINCT es.entity_id) FROM entity_sessions es WHERE es.session_id = c.id) as entity_count,
(SELECT summary FROM chat_summaries cs WHERE cs.session_id = c.id) as summary
FROM conversations c
@@ -646,14 +1113,6 @@ export function getMemoriesForSession(sessionId: string, limit: number = 200): M
return messages
}
-export function getMemoryRecordsForSession(sessionId: string, limit: number = 200): MemoryRecord[] {
- const db = getDB()
- const stmt = db.prepare(
- 'SELECT * FROM memories WHERE session_id = ? ORDER BY created_at ASC LIMIT ?'
- )
- return stmt.all(sessionId, limit) as MemoryRecord[]
-}
-
export function checkMessageExists(hash: string, conversationId: string): boolean {
const db = getDB()
const stmt = db.prepare('SELECT id FROM messages WHERE conversation_id = ? AND hash = ? LIMIT 1')
@@ -661,31 +1120,10 @@ export function checkMessageExists(hash: string, conversationId: string): boolea
return !!result
}
-// === MASTER MEMORY ===
-
-export function getMasterMemory(): { content: string | null; updated_at: string | null } {
- const db = getDB()
- const result = db.prepare('SELECT content, updated_at FROM master_memory WHERE id = 1').get() as
- { content: string; updated_at: string } | undefined
- return result || { content: null, updated_at: null }
-}
-
-export function updateMasterMemory(content: string): void {
- const db = getDB()
- const stmt = db.prepare(`
- INSERT INTO master_memory (id, content, updated_at)
- VALUES (1, ?, CURRENT_TIMESTAMP)
- ON CONFLICT(id) DO UPDATE SET
- content = excluded.content,
- updated_at = excluded.updated_at
- `)
- stmt.run(content)
-}
-
// One-shot, idempotent purge of the legacy "My Memories" data: the AI-chat
// conversations the old watcher scraped (Claude / ChatGPT / Gemini, web +
-// desktop) and everything derived from them — messages, vector memories,
-// summaries, entity facts, and entities left orphaned afterwards. The shared
+// desktop) and everything derived from them — messages, the retired memories
+// and entity facts, summaries, and entities left orphaned afterwards. The shared
// `entities` table is ALSO fed by the current screen-capture/observation
// pipeline, so we only drop entities with no remaining link in entity_sessions,
// entity_facts, OR observation_entities. Once the legacy conversations are gone
@@ -742,10 +1180,10 @@ export function purgeLegacyChatImports(): Record | null {
: 0 // without obs links we can't tell current from legacy — leave them
// The stale consolidated profile is gone for good.
counts.masterMemory = db.prepare(`DELETE FROM master_memory`).run().changes
- // Rebuild external-content FTS indexes so they don't point at deleted rows.
- for (const t of ['memory_fts', 'message_fts', 'summary_fts', 'entity_fts', 'entity_fact_fts']) {
+ // Re-index the external-content FTS tables so they don't point at deleted rows.
+ for (const spec of CORE_FTS_INDEXES) {
try {
- db.prepare(`INSERT INTO ${t}(${t}) VALUES('rebuild')`).run()
+ rebuildFtsIndex(db, spec)
} catch {
/* table may be absent */
}
@@ -806,16 +1244,6 @@ export function updateEntitySummary(entityId: number, summary: string): void {
stmt.run(summary, entityId)
}
-export function addEntityFact(entityId: number, fact: string, sessionId?: string): boolean {
- const db = getDB()
- const stmt = db.prepare(`
- INSERT OR IGNORE INTO entity_facts (entity_id, fact, source_session_id)
- VALUES (?, ?, ?)
- `)
- const info = stmt.run(entityId, fact.trim(), sessionId || null)
- return info.changes > 0
-}
-
export function upsertEntitySession(entityId: number, sessionId: string): void {
const db = getDB()
const stmt = db.prepare(`
@@ -833,85 +1261,14 @@ export interface EntityRecord {
updated_at: string
}
-export interface EntityListRecord extends EntityRecord {
- fact_count: number
- session_count: number
-}
-
-export interface SessionEntityRecord extends EntityRecord {
- fact_count: number
-}
-
-export interface EntityFactRecord {
- id: number
- fact: string
- source_session_id: string | null
- created_at: string
-}
-
-export interface EntityDetailsRecord {
- entity: (EntityRecord & Record) | undefined
- facts: EntityFactRecord[]
-}
-
-export function getEntities(appName?: string): EntityListRecord[] {
- const db = getDB()
- let query = `
- SELECT
- e.id,
- e.name,
- e.type,
- e.summary,
- e.updated_at,
- COUNT(DISTINCT f.id) as fact_count,
- COUNT(DISTINCT es.session_id) as session_count
- FROM entities e
- LEFT JOIN entity_facts f ON f.entity_id = e.id
- LEFT JOIN entity_sessions es ON es.entity_id = e.id
- LEFT JOIN conversations c ON es.session_id = c.id
- `
-
- const params: unknown[] = []
- if (appName && appName !== 'All') {
- query += ` WHERE c.app_name LIKE ? `
- params.push(`%${appName}%`)
- }
-
- query += ` GROUP BY e.id ORDER BY e.updated_at DESC`
- const stmt = db.prepare(query)
- return stmt.all(...params) as EntityListRecord[]
-}
-
-export function getEntityDetails(entityId: number, appName?: string): EntityDetailsRecord {
- const db = getDB()
- const entity = db.prepare('SELECT * FROM entities WHERE id = ?').get(entityId) as
- (EntityRecord & Record) | undefined
-
- let factsQuery = `
- SELECT f.id, f.fact, f.source_session_id, f.created_at
- FROM entity_facts f
- LEFT JOIN conversations c ON f.source_session_id = c.id
- WHERE f.entity_id = ?
- `
- const params: unknown[] = [entityId]
- if (appName && appName !== 'All') {
- factsQuery += ` AND c.app_name LIKE ? `
- params.push(`%${appName}%`)
- }
- factsQuery += ` ORDER BY f.created_at DESC`
-
- const facts = db.prepare(factsQuery).all(...params) as EntityFactRecord[]
- return { entity, facts }
-}
+export type SessionEntityRecord = EntityRecord
export function getEntitiesForSession(sessionId: string): SessionEntityRecord[] {
const db = getDB()
const stmt = db.prepare(`
- SELECT e.id, e.name, e.type, e.summary, e.updated_at,
- COUNT(DISTINCT f.id) as fact_count
+ SELECT e.id, e.name, e.type, e.summary, e.updated_at
FROM entities e
JOIN entity_sessions es ON es.entity_id = e.id
- LEFT JOIN entity_facts f ON f.entity_id = e.id
WHERE es.session_id = ?
GROUP BY e.id
ORDER BY e.updated_at DESC
@@ -933,6 +1290,7 @@ export function deleteEntityRecord(entityId: number): boolean {
entityId,
entityId
)
+ // Retired facts are not read any more, but an entity's delete still takes them with it.
db.prepare('DELETE FROM entity_facts WHERE entity_id = ?').run(entityId)
db.prepare('DELETE FROM entity_sessions WHERE entity_id = ?').run(entityId)
return db.prepare('DELETE FROM entities WHERE id = ?').run(entityId).changes > 0
@@ -942,228 +1300,6 @@ export function deleteEntityRecord(entityId: number): boolean {
return deleted
}
-export function deleteMemory(memoryId: number): boolean {
- const db = getDB()
- const stmt = db.prepare('DELETE FROM memories WHERE id = ?')
- const info = stmt.run(memoryId)
- console.log(`Deleted memory ${memoryId}, changes: ${info.changes}`)
- return info.changes > 0
-}
-
-// === DASHBOARD STATS ===
-
-export interface DashboardStats {
- totalChats: number
- totalMemories: number
- totalEntities: number
- totalRelationships: number
- totalMessages: number
- todayChats: number
- todayMemories: number
- todayEntities: number
- totalFacts: number
- recentChats: Array<{
- session_id: string
- title: string | null
- app_name: string
- memory_count: number
- entity_count: number
- updated_at: string
- }>
- recentMemories: Array<{
- id: number
- content: string
- source_app: string
- created_at: string
- }>
- topEntities: Array<{
- id: number
- name: string
- type: string
- fact_count: number
- session_count: number
- }>
- entityTypeCounts: Array<{
- type: string
- count: number
- }>
- appDistribution: Array<{
- app_name: string
- chat_count: number
- memory_count: number
- }>
- activityByDay: Array<{
- date: string
- chats: number
- memories: number
- }>
-}
-
-export function getDashboardStats(): DashboardStats {
- const db = getDB()
-
- // Total counts
- const totalChats = (
- db.prepare('SELECT COUNT(*) as count FROM conversations').get() as { count: number }
- ).count
- const totalMemories = (
- db.prepare('SELECT COUNT(*) as count FROM memories').get() as { count: number }
- ).count
- const totalEntities = (
- db.prepare('SELECT COUNT(*) as count FROM entities').get() as { count: number }
- ).count
- const totalRelationships = (
- db.prepare('SELECT COUNT(*) as count FROM entity_edges').get() as { count: number }
- ).count
- const totalMessages = (
- db.prepare('SELECT COUNT(*) as count FROM messages').get() as { count: number }
- ).count
- const totalFacts = (
- db.prepare('SELECT COUNT(*) as count FROM entity_facts').get() as { count: number }
- ).count
-
- // Today's activity (convert stored timestamps to localtime before comparing)
- const todayChats = (
- db
- .prepare(
- `SELECT COUNT(*) as count FROM conversations WHERE date(updated_at, 'localtime') = date('now', 'localtime')`
- )
- .get() as { count: number }
- ).count
- const todayMemories = (
- db
- .prepare(
- `SELECT COUNT(*) as count FROM memories WHERE date(created_at, 'localtime') = date('now', 'localtime')`
- )
- .get() as { count: number }
- ).count
- const todayEntities = (
- db
- .prepare(
- `SELECT COUNT(*) as count FROM entities WHERE date(created_at, 'localtime') = date('now', 'localtime')`
- )
- .get() as { count: number }
- ).count
-
- // Recent chats (top 5)
- const recentChats = db
- .prepare(
- `
- SELECT
- c.id as session_id,
- c.title,
- c.app_name,
- c.updated_at,
- (SELECT COUNT(*) FROM memories m WHERE m.session_id = c.id) as memory_count,
- (SELECT COUNT(DISTINCT es.entity_id) FROM entity_sessions es WHERE es.session_id = c.id) as entity_count
- FROM conversations c
- ORDER BY c.updated_at DESC
- LIMIT 5
- `
- )
- .all() as DashboardStats['recentChats']
-
- // Recent memories (top 5)
- const recentMemories = db
- .prepare(
- `
- SELECT id, content, source_app, created_at
- FROM memories
- ORDER BY created_at DESC
- LIMIT 5
- `
- )
- .all() as DashboardStats['recentMemories']
-
- // Top entities by fact count
- const topEntities = db
- .prepare(
- `
- SELECT
- e.id,
- e.name,
- e.type,
- COUNT(DISTINCT f.id) as fact_count,
- COUNT(DISTINCT es.session_id) as session_count
- FROM entities e
- LEFT JOIN entity_facts f ON f.entity_id = e.id
- LEFT JOIN entity_sessions es ON es.entity_id = e.id
- GROUP BY e.id
- ORDER BY fact_count DESC
- LIMIT 6
- `
- )
- .all() as DashboardStats['topEntities']
-
- // Entity type distribution
- const entityTypeCounts = db
- .prepare(
- `
- SELECT type, COUNT(*) as count
- FROM entities
- GROUP BY type
- ORDER BY count DESC
- LIMIT 8
- `
- )
- .all() as DashboardStats['entityTypeCounts']
-
- // App distribution
- const appDistribution = db
- .prepare(
- `
- SELECT
- COALESCE(c.app_name, 'Unknown') as app_name,
- COUNT(DISTINCT c.id) as chat_count,
- COUNT(DISTINCT m.id) as memory_count
- FROM conversations c
- LEFT JOIN memories m ON m.session_id = c.id
- GROUP BY c.app_name
- ORDER BY chat_count DESC
- `
- )
- .all() as DashboardStats['appDistribution']
-
- // Activity by day (last 14 days, using localtime)
- const activityByDay = db
- .prepare(
- `
- WITH RECURSIVE dates(date) AS (
- SELECT date('now', 'localtime', '-13 days')
- UNION ALL
- SELECT date(date, '+1 day')
- FROM dates
- WHERE date < date('now', 'localtime')
- )
- SELECT
- dates.date,
- (SELECT COUNT(*) FROM conversations WHERE date(updated_at, 'localtime') = dates.date) as chats,
- (SELECT COUNT(*) FROM memories WHERE date(created_at, 'localtime') = dates.date) as memories
- FROM dates
- ORDER BY dates.date ASC
- `
- )
- .all() as DashboardStats['activityByDay']
-
- return {
- totalChats,
- totalMemories,
- totalEntities,
- totalRelationships,
- totalMessages,
- totalFacts,
- todayChats,
- todayMemories,
- todayEntities,
- recentChats,
- recentMemories,
- topEntities,
- entityTypeCounts,
- appDistribution,
- activityByDay
- }
-}
-
// === USER PROFILE ===
export type UserProfile = UserProfileContract
@@ -1360,25 +1496,7 @@ export function setRagConversationProject(id: string, projectId: string | null):
}
/**
- * The FTS5 MATCH expression for a user's search box, or null when there is nothing to match.
- *
- * Pure and separately testable, because this is where a user's typing becomes query syntax. Terms
- * are extracted as letters and digits only and each is quoted, so a stray quote, a hyphen or an
- * FTS operator like NEAR or OR is data rather than syntax. The trailing `*` keeps the
- * search-as-you-type behaviour the old LIKE had for the last, partial word.
- */
-export function ragMessageMatchExpression(query: string): string | null {
- return (
- escapeFtsMatch(query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [], {
- join: 'AND',
- prefix: true,
- maxTokens: 6
- }) || null
- )
-}
-
-/**
- * Conversation ids whose MESSAGE CONTENT matches a query (all terms, AND) — so the chat-list
+ * Conversation ids whose MESSAGE CONTENT matches a query (all of its words) — so the chat-list
* search can match what was said, not just the title.
*
* This was `lower(content) LIKE '%term%'` per term, with no LIMIT, over a `rag_messages` table
@@ -1387,7 +1505,8 @@ export function ragMessageMatchExpression(query: string): string | null {
* `rag_message_fts` now, and it returns a bounded page - the caller renders a list, not a report.
*/
export function searchRagConversationIds(query: string, limit?: number): string[] {
- const match = ragMessageMatchExpression(query)
+ // Type-ahead: every word must match, each as a word start (the last may still be half typed).
+ const match = ftsTypeahead(query)
if (!match) return []
const requested = limit ?? DEFAULT_SEARCH_LIMIT
const bounded = Math.min(
@@ -1422,7 +1541,7 @@ export function searchRagConversationPage(
turn_count: number
fallback_title: string | null
}[] {
- const match = ragMessageMatchExpression(query)
+ const match = ftsTypeahead(query)
return getDB()
.prepare(
`
@@ -1494,62 +1613,41 @@ export function getProjectChatHistory(
return rows.map(({ role, content, title }) => ({ role, content, title })).reverse()
}
-/** Relevant messages from other conversations in one project. Never crosses projects. */
+/** A sibling project chat's message, with the conversation it belongs to and when it was said. */
+export interface ProjectConversationMessage {
+ role: string
+ content: string
+ title: string | null
+ conversation_id: string
+ created_at: string
+}
+
+/** Relevant messages from other conversations in one project, best match first (bm25 over
+ * rag_message_fts). Never crosses projects. */
export function searchProjectConversations(
projectId: string,
query: string,
excludeConversationId = '',
limit = 6
-): { role: string; content: string; title: string | null }[] {
- const stopWords = new Set([
- 'about',
- 'and',
- 'are',
- 'did',
- 'for',
- 'from',
- 'how',
- 'our',
- 'project',
- 'the',
- 'this',
- 'was',
- 'what',
- 'when',
- 'where',
- 'who',
- 'with',
- 'you'
- ])
- const terms = [...new Set(query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])]
- .filter((term) => term.length > 2 && !stopWords.has(term))
- .slice(0, 6)
- if (!terms.length) return []
- const score = terms
- .map(() => '(CASE WHEN lower(rm.content) LIKE ? THEN 1 ELSE 0 END)')
- .join(' + ')
- const patterns = terms.map((term) => `%${term}%`)
+): ProjectConversationMessage[] {
+ const match = ftsQuery(query)
+ if (!match) return []
return getDB()
.prepare(
- `SELECT rm.role, rm.content, rc.title
- FROM rag_messages rm
+ `SELECT rm.role, rm.content, rc.title, rm.conversation_id, rm.created_at
+ FROM rag_message_fts f
+ JOIN rag_messages rm ON rm.id = f.rowid
JOIN rag_conversations rc ON rc.id = rm.conversation_id
- WHERE rc.project_id = ? AND rm.conversation_id != ?
- AND (${score}) > 0
- ORDER BY (${score}) DESC, rm.created_at DESC, rm.id DESC
+ WHERE rag_message_fts MATCH ? AND rc.project_id = ? AND rm.conversation_id != ?
+ ORDER BY bm25(rag_message_fts), rm.created_at DESC, rm.id DESC
LIMIT ?`
)
.all(
+ match,
projectId,
excludeConversationId,
- ...patterns,
- ...patterns,
Math.min(12, Math.max(1, limit))
- ) as {
- role: string
- content: string
- title: string | null
- }[]
+ ) as ProjectConversationMessage[]
}
export function updateRagConversationTitle(id: string, title: string): RagConversation {
diff --git a/src/main/diagnostics-log.ts b/src/main/diagnostics-log.ts
index 066fde350..468430489 100644
--- a/src/main/diagnostics-log.ts
+++ b/src/main/diagnostics-log.ts
@@ -1,6 +1,6 @@
import path from 'node:path'
import { dataDir } from './runtime-env'
-import { createDiagnosticWriter, type DiagnosticWriterStats } from './diagnostics-writer'
+import { createDiagnosticWriter } from './diagnostics-writer'
export type DiagnosticLevel = 'info' | 'warn' | 'error'
export type DiagnosticValue = string | number | boolean | null | undefined
@@ -79,11 +79,6 @@ const writer = createDiagnosticWriter({
}
})
-/** What this log has buffered, dropped and failed to write. Observable, not hidden. */
-export function diagnosticWriterStats(): DiagnosticWriterStats {
- return writer.stats()
-}
-
/** Drain buffered diagnostics on a controlled shutdown. Bounded: quit is never held hostage. */
export function flushDiagnosticLog(timeoutMs?: number): Promise {
return writer.flush(timeoutMs)
@@ -115,7 +110,7 @@ export function formatConsoleMessage(values: unknown[]): string {
}
/** Set to `1` to print every diagnostic line, including the per-request IPC trace. */
-export const VERBOSE_DIAGNOSTICS_ENV = 'OFFGRID_DIAGNOSTIC_VERBOSE'
+const VERBOSE_DIAGNOSTICS_ENV = 'OFFGRID_DIAGNOSTIC_VERBOSE'
function verboseDiagnosticsEnabled(): boolean {
return process.env[VERBOSE_DIAGNOSTICS_ENV] === '1'
diff --git a/src/main/diagnostics-writer.ts b/src/main/diagnostics-writer.ts
index 36349bdb3..9e69b4592 100644
--- a/src/main/diagnostics-writer.ts
+++ b/src/main/diagnostics-writer.ts
@@ -16,7 +16,7 @@
import fs from 'node:fs'
import path from 'node:path'
-export type DiagnosticWriterStats = Readonly<{
+type DiagnosticWriterStats = Readonly<{
/** Lines accepted but not yet written. */
bufferedRecords: number
bufferedBytes: number
diff --git a/src/main/documents/chat-turn.ts b/src/main/documents/chat-turn.ts
index 87dfe4e15..ccd126d49 100644
--- a/src/main/documents/chat-turn.ts
+++ b/src/main/documents/chat-turn.ts
@@ -46,7 +46,7 @@ function plural(n: number, word: string): string {
}
/** A passage as a turn source: the same key whether the turn or a search tool cited it. */
-export function passageSource(passage: {
+function passageSource(passage: {
documentId: string
name: string
page: number
diff --git a/src/main/documents/extract-core.ts b/src/main/documents/extract-core.ts
index 9e50e3c10..a304ce90e 100644
--- a/src/main/documents/extract-core.ts
+++ b/src/main/documents/extract-core.ts
@@ -35,7 +35,7 @@ function pageText(items: PdfTextItem[]): string {
return text.replace(/\n{3,}/g, '\n\n')
}
-export async function extractPdfPages(
+async function extractPdfPages(
filePath: string,
onPages: (pages: DocumentPage[]) => void,
signal?: AbortSignal
@@ -83,7 +83,7 @@ export async function extractPdfPages(
}
}
-export async function readTextFile(filePath: string): Promise {
+async function readTextFile(filePath: string): Promise {
const bytes = await fs.promises.readFile(filePath)
if (bytes[0] === 0xff && bytes[1] === 0xfe) return bytes.subarray(2).toString('utf16le')
if (bytes[0] === 0xfe && bytes[1] === 0xff) {
@@ -94,7 +94,7 @@ export async function readTextFile(filePath: string): Promise {
return bytes.toString('utf8').replace(/^\uFEFF/, '')
}
-export async function extractDocxText(filePath: string): Promise {
+async function extractDocxText(filePath: string): Promise {
const { default: mammoth } = await import('mammoth')
try {
return (await mammoth.extractRawText({ path: filePath })).value
diff --git a/src/main/documents/index.ts b/src/main/documents/index.ts
index b709d7ae9..cc44eb777 100644
--- a/src/main/documents/index.ts
+++ b/src/main/documents/index.ts
@@ -16,6 +16,7 @@ import { documentEmbedder, documentReranker } from './models'
import { conversationDocuments, referencedDocumentIds } from './conversation-documents'
import { knowledgeBaseDocumentIds, projectDocuments } from './project-documents'
import { pageOcr, shutdownOcr } from './ocr'
+import { getDB, tableExists } from '../database'
/** Foreground work (a chat reply, an image, speech) is running or waiting to run. */
async function foregroundBusy(): Promise {
@@ -85,9 +86,63 @@ const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
async function documentsInUse(): Promise> {
const used = await referencedDocumentIds()
for (const id of await knowledgeBaseDocumentIds()) used.add(id)
+ for (const { document_id } of keptDocumentsTable().prepare('SELECT document_id FROM kept_documents').all() as {
+ document_id: string
+ }[])
+ used.add(document_id)
return used
}
+let keptReady: object | undefined
+
+/** Documents kept by an owner outside chats (a meeting transcript, a long dictation). */
+function keptDocumentsTable(): ReturnType {
+ const db = getDB()
+ if (keptReady !== db) {
+ db.exec(`CREATE TABLE IF NOT EXISTS kept_documents (
+ owner TEXT PRIMARY KEY,
+ document_id TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_kept_documents_document ON kept_documents(document_id);`)
+ keptReady = db
+ }
+ return db
+}
+
+/** `owner` (e.g. "meeting:12") keeps `documentId`: cache cleanup never drops it while kept. */
+export function keepDocument(owner: string, documentId: string): void {
+ keptDocumentsTable()
+ .prepare('INSERT INTO kept_documents (owner, document_id) VALUES (?, ?) ON CONFLICT(owner) DO UPDATE SET document_id = excluded.document_id')
+ .run(owner, documentId)
+}
+
+/** `owner` no longer keeps its document; cleanup may drop it when nothing else uses it. */
+export function releaseDocument(owner: string): void {
+ keptDocumentsTable().prepare('DELETE FROM kept_documents WHERE owner = ?').run(owner)
+}
+
+/**
+ * Release every document kept by an owner `${prefix}:` whose row in `table` is gone (a
+ * privacy clear or retention deleted the rows directly, not through their owner's delete). All of
+ * them when the table does not exist. Returns how many were released.
+ */
+export function releaseDocumentsOfDeletedOwners(prefix: string, table: string): number {
+ const db = keptDocumentsTable()
+ const owner = `${prefix}:`
+ const ownedHere = 'substr(owner, 1, ?) = ?'
+ if (!tableExists(db, table)) {
+ return db
+ .prepare(`DELETE FROM kept_documents WHERE ${ownedHere}`)
+ .run(owner.length, owner).changes
+ }
+ return db
+ .prepare(
+ `DELETE FROM kept_documents WHERE ${ownedHere}
+ AND CAST(substr(owner, ?) AS INTEGER) NOT IN (SELECT id FROM ${table})`
+ )
+ .run(owner.length, owner, owner.length + 1).changes
+}
+
/** Drop document caches nothing references and that went unused for 30 days. */
export async function collectDocumentGarbage(): Promise {
try {
@@ -97,10 +152,16 @@ export async function collectDocumentGarbage(): Promise {
}
}
+/** Drop documents nothing uses (no chat, knowledge base or owner keeps them), now. Throws on
+ * failure: a privacy delete must not report success while the text remains. */
+export async function removeUnreferencedDocuments(): Promise {
+ await documentIngest.collectGarbage(await documentsInUse(), 0)
+}
+
/** A conversation or knowledge-base file was deleted: drop documents nothing else uses, now. */
export async function forgetUnreferencedDocuments(): Promise {
try {
- await documentIngest.collectGarbage(await documentsInUse(), 0)
+ await removeUnreferencedDocuments()
} catch (error) {
warn('Removing documents of a deleted chat failed', error)
}
diff --git a/src/main/documents/models.ts b/src/main/documents/models.ts
index fe0cb175a..2f23c3703 100644
--- a/src/main/documents/models.ts
+++ b/src/main/documents/models.ts
@@ -1,9 +1,10 @@
-// EmbedPort and RerankPort adapters over the shared embedding worker: MiniLM for passages and
-// questions (the model memory search keeps loaded), and the ms-marco cross-encoder for reranking.
+// EmbedPort and RerankPort adapters over the shared embedding worker: the multilingual embedder for
+// passages and questions (the one memory search uses, with the prefixes it was trained on), and the
+// ms-marco cross-encoder for reranking.
import type { EmbedPort, RerankPort } from '@offgrid/rag/documents'
import { embeddings } from '../embeddings'
-import { DOCUMENT_EMBEDDER } from '../embeddings-core'
+import { DOCUMENT_EMBEDDER, DOCUMENT_EMBEDDER_SPEC } from '../embedding-models'
/** How long the model stays loaded after the last indexing batch (on-demand residency). */
const HOLD_AFTER_MS = 30_000
@@ -27,10 +28,17 @@ export const documentEmbedder: EmbedPort = {
embedder: DOCUMENT_EMBEDDER,
async embedPassages(texts) {
holdDuringIngest()
- return embeddings.embedDocumentTexts(texts)
+ return embeddings.embedDocumentTexts(
+ texts.map((text) => `${DOCUMENT_EMBEDDER_SPEC.passagePrefix}${text}`)
+ )
},
async embedQuery(text) {
- const [vector] = await embeddings.embedDocumentTexts([text], { interactive: true })
+ const [vector] = await embeddings.embedDocumentTexts(
+ [`${DOCUMENT_EMBEDDER_SPEC.queryPrefix}${text}`],
+ {
+ interactive: true
+ }
+ )
return vector ?? []
}
}
diff --git a/src/main/documents/sqlite-store.ts b/src/main/documents/sqlite-store.ts
index d48751a53..f821471b9 100644
--- a/src/main/documents/sqlite-store.ts
+++ b/src/main/documents/sqlite-store.ts
@@ -1,11 +1,30 @@
// DocumentIndexStore and KeywordSearchPort over the app's encrypted SQLite database (memories.db,
// SQLCipher). Documents attached to chat live here, never as plain files, so their text is
// protected like the rest of the user's data. Keyword search is SQLite FTS5 (GPT4All LocalDocs'
-// approach); vectors are Float32 BLOBs beside each passage.
+// approach); vectors are Float32 BLOBs beside each passage. Passages in scripts written without
+// spaces (Chinese, Japanese, Thai…) are also kept in a trigram index, attached_document_fts_tri.
-import { getDB } from '../database'
import {
- contentTerms,
+ clearRebuildCursor,
+ enableFtsSecureDelete,
+ ftsIndexIsCurrent,
+ getDB,
+ markFtsIndexCurrent,
+ queueIndexRebuild,
+ rebuildCursor,
+ setRebuildCursor,
+ tableExists
+} from '../database'
+import {
+ FTS_TOKENIZE,
+ FTS_TRIGRAM_TOKENIZE,
+ ftsQuery,
+ ftsTrigramQuery,
+ fuseLists,
+ needsTrigram,
+ normaliseForIndex
+} from '@offgrid/rag/records'
+import {
indexedText,
type DocumentChunk,
type DocumentIndexStore,
@@ -15,20 +34,6 @@ import {
type StoredDocument
} from '@offgrid/rag/documents'
-/**
- * FTS5's unicode61 tokenizer treats combining marks as separators, which cuts Hindi words at every
- * vowel sign ("बदलाव" → "बदल" + "ा" + "व"). The Indic blocks are declared token characters, with
- * zero-width joiners, so words stay whole; the danda (।, ॥) stays a separator. Verified against
- * the bundled SQLite 3.53 with Devanagari text.
- */
-function indicTokenChars(): string {
- const chars: number[] = []
- for (let code = 0x0900; code <= 0x0d7f; code++)
- if (code !== 0x0964 && code !== 0x0965) chars.push(code)
- chars.push(0x200c, 0x200d)
- return String.fromCodePoint(...chars).replace(/'/g, "''")
-}
-
let schemaFor: object | undefined
function db(): ReturnType {
@@ -83,17 +88,225 @@ export function ensureAttachedDocumentSchema(database: ReturnType)
PRIMARY KEY (conversation_id, document_id)
);
`)
- database.exec(
- `CREATE VIRTUAL TABLE IF NOT EXISTS attached_document_fts USING fts5(
+ prepareDocumentIndex(database)
+ prepareDocumentTrigramIndex(database)
+}
+
+const INDEX = 'attached_document_fts'
+const NEXT = 'attached_document_fts_next'
+/** The trigram twin: only passages whose text has a script written without spaces. */
+const TRIGRAM = 'attached_document_fts_tri'
+
+function createDocumentIndex(database: ReturnType, name: string): void {
+ database.exec(`
+ CREATE VIRTUAL TABLE ${name} USING fts5(
body, document_id UNINDEXED, position UNINDEXED,
- tokenize = "unicode61 remove_diacritics 0 tokenchars '${indicTokenChars()}'"
- )`
- )
+ tokenize = "${FTS_TOKENIZE}"
+ );
+ `)
+ enableFtsSecureDelete(database, name)
+}
+
+/**
+ * The passage index on the shared tokenizer (it keeps Hindi vowel signs inside words), without
+ * reading any passage here: a missing index is created (current when there are no passages yet);
+ * one built by an older schema keeps answering while it is rebuilt in the background after
+ * start-up (rebuildDocumentIndexSlice), so documents attached before a tokenizer change still match.
+ */
+function prepareDocumentIndex(database: ReturnType): void {
+ if (ftsIndexIsCurrent(database, INDEX)) return
+ if (!tableExists(database, INDEX)) {
+ createDocumentIndex(database, INDEX)
+ if (!database.prepare('SELECT 1 FROM attached_document_chunks LIMIT 1').get()) {
+ markFtsIndexCurrent(database, INDEX)
+ return
+ }
+ }
+ queueIndexRebuild(INDEX, rebuildDocumentIndexSlice())
+}
+
+type ChunkRow = {
+ rowid: number
+ document_id: string
+ position: number
+ page: number
+ label: string | null
+ heading: string | null
+ text: string
+ tokens: number
+ name: string
}
-/** Every table that holds attached-document data (for "Delete all my data"). */
+/** Passages after `cursor` in rowid order, with their document's name (`limit` -1: all). */
+function readChunkRows(
+ database: ReturnType,
+ cursor: number,
+ limit: number
+): ChunkRow[] {
+ return database
+ .prepare(
+ `SELECT c.rowid, c.document_id, c.position, c.page, c.label, c.heading, c.text, c.tokens,
+ COALESCE(d.name, '') AS name
+ FROM attached_document_chunks c
+ LEFT JOIN attached_documents d ON d.id = c.document_id
+ WHERE c.rowid > ? ORDER BY c.rowid LIMIT ?`
+ )
+ .all(cursor, limit) as ChunkRow[]
+}
+
+/** A passage's text exactly as both indexes hold it. */
+function chunkBody(row: ChunkRow): string {
+ const chunk: DocumentChunk = {
+ documentId: row.document_id,
+ position: row.position,
+ page: row.page,
+ ...(row.label ? { label: row.label } : {}),
+ ...(row.heading ? { heading: row.heading } : {}),
+ text: row.text,
+ tokens: row.tokens
+ }
+ return normaliseForIndex(indexedText(row.name, chunk))
+}
+
+/**
+ * One slice of the background rebuild: passages are copied into attached_document_fts_next in
+ * rowid order (fts_rebuild holds how far). Passages written later have higher rowids and are
+ * copied by a later slice; deletes reach both indexes (clearChunks, deleteDocument). The last
+ * slice replaces the old index in one transaction. Returns whether more remain.
+ */
+function rebuildDocumentIndexSlice(): () => boolean {
+ let batch = 200
+ return () => {
+ const database = db()
+ if (ftsIndexIsCurrent(database, INDEX)) return false
+ let cursor = rebuildCursor(database, INDEX)
+ if (cursor === null || !tableExists(database, NEXT)) {
+ database.transaction(() => {
+ database.exec(`DROP TABLE IF EXISTS ${NEXT}`)
+ createDocumentIndex(database, NEXT)
+ setRebuildCursor(database, INDEX, 0)
+ })()
+ cursor = 0
+ }
+ const started = Date.now()
+ const insert = database.prepare(
+ `INSERT INTO ${NEXT} (body, document_id, position) VALUES (?, ?, ?)`
+ )
+ const copy = (rows: ChunkRow[]): void => {
+ for (const row of rows) insert.run(chunkBody(row), row.document_id, row.position)
+ }
+ const rows = readChunkRows(database, cursor, batch)
+ if (!rows.length) {
+ database.transaction(() => {
+ copy(readChunkRows(database, cursor, -1))
+ database.exec(`DROP TABLE IF EXISTS ${INDEX}; ALTER TABLE ${NEXT} RENAME TO ${INDEX};`)
+ markFtsIndexCurrent(database, INDEX)
+ clearRebuildCursor(database, INDEX)
+ })()
+ return false
+ }
+ database.transaction(() => {
+ copy(rows)
+ setRebuildCursor(database, INDEX, rows[rows.length - 1]!.rowid)
+ })()
+ const elapsed = Date.now() - started
+ if (elapsed > 20) batch = Math.max(10, Math.floor(batch / 2))
+ else if (elapsed < 10) batch = Math.min(2_000, batch * 2)
+ return true
+ }
+}
+
+function createTrigramIndex(database: ReturnType): void {
+ database.exec(`
+ CREATE VIRTUAL TABLE ${TRIGRAM} USING fts5(
+ body, document_id UNINDEXED, position UNINDEXED,
+ tokenize = "${FTS_TRIGRAM_TOKENIZE}"
+ );
+ `)
+ enableFtsSecureDelete(database, TRIGRAM)
+}
+
+/**
+ * The trigram twin, without reading any passage here: a missing or outdated one is created empty
+ * (current when there are no passages) and filled in the background after start-up
+ * (fillTrigramIndexSlice); a fill under way resumes.
+ */
+function prepareDocumentTrigramIndex(database: ReturnType): void {
+ if (ftsIndexIsCurrent(database, TRIGRAM)) return
+ if (rebuildCursor(database, TRIGRAM) === null || !tableExists(database, TRIGRAM)) {
+ database.transaction(() => {
+ database.exec(`DROP TABLE IF EXISTS ${TRIGRAM}`)
+ createTrigramIndex(database)
+ if (database.prepare('SELECT 1 FROM attached_document_chunks LIMIT 1').get()) {
+ setRebuildCursor(database, TRIGRAM, 0)
+ } else {
+ clearRebuildCursor(database, TRIGRAM)
+ markFtsIndexCurrent(database, TRIGRAM)
+ }
+ })()
+ if (ftsIndexIsCurrent(database, TRIGRAM)) return
+ }
+ queueIndexRebuild(TRIGRAM, fillTrigramIndexSlice())
+}
+
+/** Whether new passages go straight into the trigram twin. While it is being filled they are left
+ * to the fill: they have higher rowids than any passage it has read. */
+function trigramIndexIsLive(database: ReturnType): boolean {
+ return tableExists(database, TRIGRAM) && rebuildCursor(database, TRIGRAM) === null
+}
+
+/** One slice of filling the trigram twin with the passages that need it, in rowid order. The last
+ * slice ends the fill in the same transaction. Returns whether more remain. */
+function fillTrigramIndexSlice(): () => boolean {
+ let batch = 200
+ return () => {
+ const database = db()
+ if (!tableExists(database, TRIGRAM)) return false
+ const cursor = rebuildCursor(database, TRIGRAM)
+ if (cursor === null) return false
+ const started = Date.now()
+ const rows = readChunkRows(database, cursor, batch)
+ if (!rows.length) {
+ database.transaction(() => {
+ clearRebuildCursor(database, TRIGRAM)
+ markFtsIndexCurrent(database, TRIGRAM)
+ })()
+ return false
+ }
+ const insert = database.prepare(
+ `INSERT INTO ${TRIGRAM} (body, document_id, position) VALUES (?, ?, ?)`
+ )
+ database.transaction(() => {
+ for (const row of rows) {
+ const body = chunkBody(row)
+ if (needsTrigram(body)) insert.run(body, row.document_id, row.position)
+ }
+ setRebuildCursor(database, TRIGRAM, rows[rows.length - 1]!.rowid)
+ })()
+ const elapsed = Date.now() - started
+ if (elapsed > 20) batch = Math.max(10, Math.floor(batch / 2))
+ else if (elapsed < 10) batch = Math.min(2_000, batch * 2)
+ return true
+ }
+}
+
+/** Delete passages from the index, from its rebuild while one is under way, and from the trigram
+ * twin. */
+function unindexDocument(database: ReturnType, id: string): void {
+ database.prepare(`DELETE FROM ${INDEX} WHERE document_id = ?`).run(id)
+ if (tableExists(database, NEXT))
+ database.prepare(`DELETE FROM ${NEXT} WHERE document_id = ?`).run(id)
+ if (tableExists(database, TRIGRAM))
+ database.prepare(`DELETE FROM ${TRIGRAM} WHERE document_id = ?`).run(id)
+}
+
+/** Every table that holds document data, and which owner keeps which document (for "Delete all
+ * my data"). */
export const ATTACHED_DOCUMENT_TABLES = [
+ 'kept_documents',
'attached_document_fts',
+ 'attached_document_fts_next',
+ 'attached_document_fts_tri',
'attached_document_chunks',
'attached_document_pages',
'conversation_document_exclusions',
@@ -227,7 +440,7 @@ export const documentStore: DocumentIndexStore = {
const database = db()
database.transaction(() => {
database.prepare('DELETE FROM attached_document_chunks WHERE document_id = ?').run(id)
- database.prepare('DELETE FROM attached_document_fts WHERE document_id = ?').run(id)
+ unindexDocument(database, id)
database.prepare('UPDATE attached_documents SET map = NULL WHERE id = ?').run(id)
})()
},
@@ -246,6 +459,9 @@ export const documentStore: DocumentIndexStore = {
const index = database.prepare(
'INSERT INTO attached_document_fts (body, document_id, position) VALUES (?, ?, ?)'
)
+ const trigram = trigramIndexIsLive(database)
+ ? database.prepare(`INSERT INTO ${TRIGRAM} (body, document_id, position) VALUES (?, ?, ?)`)
+ : null
database.transaction((rows: DocumentChunk[]) => {
for (const chunk of rows) {
insert.run(
@@ -257,7 +473,9 @@ export const documentStore: DocumentIndexStore = {
chunk.text,
chunk.tokens
)
- index.run(indexedText(name, chunk), id, chunk.position)
+ const body = normaliseForIndex(indexedText(name, chunk))
+ index.run(body, id, chunk.position)
+ if (trigram && needsTrigram(body)) trigram.run(body, id, chunk.position)
}
})(chunks)
},
@@ -329,7 +547,7 @@ export const documentStore: DocumentIndexStore = {
async deleteDocument(id) {
const database = db()
database.transaction(() => {
- database.prepare('DELETE FROM attached_document_fts WHERE document_id = ?').run(id)
+ unindexDocument(database, id)
database.prepare('DELETE FROM attached_document_chunks WHERE document_id = ?').run(id)
database.prepare('DELETE FROM attached_document_pages WHERE document_id = ?').run(id)
database.prepare('DELETE FROM conversation_document_exclusions WHERE document_id = ?').run(id)
@@ -347,33 +565,77 @@ export const documentStore: DocumentIndexStore = {
}
}
-/** An FTS5 MATCH expression from free text: quoted phrases kept, every other term OR-ed. */
-export function ftsQuery(text: string): string | null {
- const quote = (term: string): string => `"${term.replace(/"/g, '""')}"`
- const phrases = [...text.matchAll(/"([^"]{3,})"/g)].map((match) => quote(match[1]!.trim()))
- const terms = contentTerms(text.replace(/"[^"]*"/g, ' ')).map(quote)
- const all = [...phrases, ...terms]
- return all.length ? all.join(' OR ') : null
-}
+type PassageRow = { documentId: string; position: number; rank: number }
+
+const toKeywordHit = (row: PassageRow): KeywordHit => ({
+ documentId: row.documentId,
+ position: Number(row.position),
+ score: -row.rank
+})
+/**
+ * Keyword passages: bm25 over the passage index, and for a question in a script written without
+ * spaces also the trigram twin (runs of three characters or more by MATCH, shorter ones by
+ * substring among the twin's passages of these documents), fused by rank.
+ */
export const documentKeywordSearch: KeywordSearchPort = {
async search(documentIds, query, limit) {
- const match = ftsQuery(query)
- if (!match || !documentIds.length) return []
+ if (!documentIds.length) return []
+ const database = db()
const placeholders = documentIds.map(() => '?').join(', ')
- const rows = db()
- .prepare(
- `SELECT document_id AS documentId, position, bm25(attached_document_fts) AS rank
- FROM attached_document_fts
- WHERE attached_document_fts MATCH ? AND document_id IN (${placeholders})
- ORDER BY rank LIMIT ?`
+ const lists: KeywordHit[][] = []
+ const match = ftsQuery(query)
+ if (match) {
+ lists.push(
+ (
+ database
+ .prepare(
+ `SELECT document_id AS documentId, position, bm25(${INDEX}) AS rank
+ FROM ${INDEX}
+ WHERE ${INDEX} MATCH ? AND document_id IN (${placeholders})
+ ORDER BY rank LIMIT ?`
+ )
+ .all(match, ...documentIds, limit) as PassageRow[]
+ ).map(toKeywordHit)
)
- .all(match, ...documentIds, limit) as { documentId: string; position: number; rank: number }[]
- return rows.map((row): KeywordHit => ({
- documentId: row.documentId,
- position: Number(row.position),
- score: -row.rank
- }))
+ }
+ const trigram = needsTrigram(query) ? ftsTrigramQuery(query) : null
+ if (trigram && tableExists(database, TRIGRAM)) {
+ if (trigram.match)
+ lists.push(
+ (
+ database
+ .prepare(
+ `SELECT document_id AS documentId, position, bm25(${TRIGRAM}) AS rank
+ FROM ${TRIGRAM}
+ WHERE ${TRIGRAM} MATCH ? AND document_id IN (${placeholders})
+ ORDER BY rank LIMIT ?`
+ )
+ .all(trigram.match, ...documentIds, limit) as PassageRow[]
+ ).map(toKeywordHit)
+ )
+ if (trigram.short.length)
+ lists.push(
+ (
+ database
+ .prepare(
+ `SELECT document_id AS documentId, position, 0 AS rank
+ FROM ${TRIGRAM}
+ WHERE document_id IN (${placeholders})
+ AND (${trigram.short.map(() => 'instr(body, ?) > 0').join(' OR ')})
+ LIMIT ?`
+ )
+ .all(...documentIds, ...trigram.short, limit) as PassageRow[]
+ ).map(toKeywordHit)
+ )
+ }
+ if (lists.length <= 1) return lists[0] ?? []
+ return fuseLists(
+ lists.map((items) => ({ items })),
+ (hit) => `${hit.documentId}:${hit.position}`
+ )
+ .slice(0, limit)
+ .map(({ item, score }) => ({ ...item, score }))
}
}
diff --git a/src/main/embedding-models.ts b/src/main/embedding-models.ts
new file mode 100644
index 000000000..562ef958d
--- /dev/null
+++ b/src/main/embedding-models.ts
@@ -0,0 +1,43 @@
+// The embedding and reranking models, as data. Kept apart from embeddings-core so main-process
+// modules can name a model without loading @huggingface/transformers and ONNX Runtime: inference
+// runs in the helper process (embeddings-worker), never beside the window.
+
+/** A sentence embedder and the prefixes its training expects on questions and passages. */
+export interface EmbedderSpec {
+ model: string
+ queryPrefix: string
+ passagePrefix: string
+}
+
+const EMBEDDERS = {
+ /** English. Measured 3.3 ms a passage fp32 on CPU here; q8 was slower (30.6 ms). */
+ miniLM: { model: 'Xenova/all-MiniLM-L6-v2', queryPrefix: '', passagePrefix: '' },
+ /** Hindi and English in one space (a Hindi question finds the English passage). 7.9 ms a passage
+ * fp32; a ~470 MB download against MiniLM's ~90 MB. Trained with these prefixes. */
+ multilingualE5Small: {
+ model: 'Xenova/multilingual-e5-small',
+ queryPrefix: 'query: ',
+ passagePrefix: 'passage: '
+ }
+} as const satisfies Record
+
+/** The model behind embeddings that have no corpus of their own (/v1/embeddings, MCP, tool routing). */
+export const DEFAULT_EMBEDDER: EmbedderSpec = EMBEDDERS.miniLM
+
+/**
+ * The model memory search embeds records and questions with. Changing it is this one line: each
+ * stored vector records its model, so every record is embedded again with the new one.
+ */
+export const RECORD_EMBEDDER: EmbedderSpec = EMBEDDERS.multilingualE5Small
+
+/**
+ * Models for documents (chat attachments, knowledge bases, transcripts): the same multilingual
+ * embedder as memory search, so one model is loaded and a Hindi question finds an English passage.
+ * fp32 is 7.9 ms a passage on CPU here (the ~1.7 s once measured was its q8 build). Documents
+ * indexed with MiniLM re-embed on their next use: the ingest compares each document's embedder. Indian-language documents still match by
+ * keyword (FTS5 keeps their words whole). ms-marco-MiniLM-L-6-v2 is the cross-encoder AnythingLLM's
+ * native reranker uses.
+ */
+export const DOCUMENT_EMBEDDER_SPEC: EmbedderSpec = EMBEDDERS.multilingualE5Small
+export const DOCUMENT_EMBEDDER = DOCUMENT_EMBEDDER_SPEC.model
+export const DOCUMENT_RERANKER = 'Xenova/ms-marco-MiniLM-L-6-v2'
diff --git a/src/main/embeddings-core.ts b/src/main/embeddings-core.ts
index 4ab78f898..4b66edca9 100644
--- a/src/main/embeddings-core.ts
+++ b/src/main/embeddings-core.ts
@@ -10,8 +10,17 @@ import {
import { modelReadDirectories, resolveTransformersPackage } from './model-paths'
import { requireModelStorage } from './runtime-env'
import { configureTransformersEnv } from './embeddings-env'
-import { loadWithOnnxFallback, onnxDeviceCandidates } from './onnx-device'
-import type { BackendPreference } from '../shared/backend-preferences'
+import { loadWithOnnxFallback } from './onnx-device'
+import type { OnnxDevice } from '@offgrid/models'
+import { DOCUMENT_RERANKER } from './embedding-models'
+export {
+ DEFAULT_EMBEDDER,
+ DOCUMENT_EMBEDDER,
+ DOCUMENT_EMBEDDER_SPEC,
+ DOCUMENT_RERANKER,
+ RECORD_EMBEDDER,
+ type EmbedderSpec
+} from './embedding-models'
/**
* Text -> vector. The actual inference, owned in ONE place.
@@ -21,10 +30,9 @@ import type { BackendPreference } from '../shared/backend-preferences'
* in-process, where there is no built worker to spawn. Sharing the implementation is what keeps the
* second path honest: the alternative was a second copy that could drift from the one users run.
*
- * The pipeline is cached per process, because loading it is the expensive part.
+ * Pipelines are cached per process and per model, because loading one is the expensive part.
*/
-let pipe: FeatureExtractionPipeline | null = null
-let loading: Promise | null = null
+const pipes = new Map>()
let activeDevice: DeviceType | null = null
export function embeddingDevice(): DeviceType | null {
@@ -33,26 +41,15 @@ export function embeddingDevice(): DeviceType | null {
/** Called after serialized inference when the model is configured on demand. */
export async function disposeEmbeddingModel(): Promise {
- const previous = pipe
- pipe = null
- loading = null
+ const loaded = [...pipes.values()]
+ pipes.clear()
activeDevice = null
- await previous?.dispose()
+ for (const pipe of loaded) await (await pipe.catch(() => null))?.dispose()
const reranker = rerankerLoading
rerankerLoading = null
await (await reranker?.catch(() => null))?.model.dispose()
}
-/**
- * Models for documents attached to chat. Passages are embedded with MiniLM, the model memory search
- * already keeps loaded: measured ~15 ms a passage on CPU here, where multilingual-e5-small (q8) took
- * ~1.7 s, which made a long book index for over an hour. Indian-language documents still match by
- * keyword (FTS5 keeps their words whole). ms-marco-MiniLM-L-6-v2 is the cross-encoder AnythingLLM's
- * native reranker uses.
- */
-export const DOCUMENT_EMBEDDER = 'Xenova/all-MiniLM-L6-v2'
-export const DOCUMENT_RERANKER = 'Xenova/ms-marco-MiniLM-L-6-v2'
-
let rerankerLoading: Promise<{ tokenizer: PreTrainedTokenizer; model: PreTrainedModel }> | null =
null
@@ -65,28 +62,58 @@ function prepareModel(id: string, modelsDir: string, readDirectories: string[]):
}
/**
- * Vectors for several texts in one forward pass. Document passages are close in length, so padding
- * costs little: measured 81 ms a passage batched against 186 ms one by one (MiniLM fp32 on CPU,
- * 1,000-character passages, app under load), with the same vectors (max difference 1.5e-7).
+ * Vectors for several texts with one model, in one forward pass. Document passages are close in
+ * length, so padding costs little: measured 81 ms a passage batched against 186 ms one by one
+ * (MiniLM fp32 on CPU, 1,000-character passages, app under load), with the same vectors (max
+ * difference 1.5e-7). `onReady` hears the device once, when the model first loads.
*/
export async function embedTexts(
texts: string[],
+ model: string,
modelsDir: string,
- backendPreference: BackendPreference = 'auto',
+ onReady?: (device: string, reason?: string) => void,
+ devices: OnnxDevice[] = ['cpu'],
readDirectories = modelReadDirectories()
): Promise {
- const out: number[][] = []
- let rest = texts
- // The first text loads the model.
- if (!pipe && rest.length) {
- out.push(await embedText(rest[0]!, modelsDir, undefined, backendPreference, readDirectories))
- rest = rest.slice(1)
- }
- if (rest.length) {
- const output = await pipe!(rest, { pooling: 'mean', normalize: true })
- out.push(...(output.tolist() as number[][]))
- }
- return out
+ if (!texts.length) return []
+ const pipe = await loadPipeline(model, modelsDir, onReady, devices, readDirectories)
+ const output = await pipe(texts, { pooling: 'mean', normalize: true })
+ return output.tolist() as number[][]
+}
+
+/** The model's pipeline: full-precision weights when present, else quantized; downloads on first use. */
+function loadPipeline(
+ model: string,
+ modelsDir: string,
+ onReady: ((device: string, reason?: string) => void) | undefined,
+ devices: OnnxDevice[],
+ readDirectories: string[]
+): Promise {
+ const loaded = pipes.get(model)
+ if (loaded) return loaded
+ const fp32 = resolveTransformersPackage(model, 'model.onnx', readDirectories)
+ const cached = fp32 ?? resolveTransformersPackage(model, 'model_quantized.onnx', readDirectories)
+ // Local-only reads must not require writable storage. Guard only an explicit
+ // folder choice when the pipeline can download; default storage is created lazily.
+ if (!cached && process.env.OFFGRID_EMBEDDINGS_OFFLINE !== '1') requireModelStorage()
+ configureTransformersEnv(modelsDir, !!cached)
+ const loading = loadWithOnnxFallback(
+ (device) =>
+ pipeline('feature-extraction', cached ?? model, {
+ device,
+ ...(cached ? { local_files_only: true, ...(fp32 ? {} : { dtype: 'q8' as const }) } : {})
+ }),
+ embeddingDevices(devices)
+ ).then(({ runtime, device, fallbackReason }) => {
+ onReady?.(device, fallbackReason)
+ activeDevice = device
+ return runtime
+ })
+ loading.catch(() => {
+ if (pipes.get(model) === loading) pipes.delete(model)
+ })
+ pipes.set(model, loading)
+ return loading
}
/** Relevance of each passage to the query (sigmoid of the cross-encoder logit), same order. */
@@ -94,7 +121,7 @@ export async function rerankPassages(
query: string,
passages: string[],
modelsDir: string,
- backendPreference: BackendPreference = 'auto',
+ devices: OnnxDevice[] = ['cpu'],
readDirectories = modelReadDirectories()
): Promise {
if (!passages.length) return []
@@ -110,7 +137,7 @@ export async function rerankPassages(
dtype: 'q8',
...options
}),
- embeddingDevices(backendPreference)
+ embeddingDevices(devices)
).then(({ runtime }) => runtime)
]).then(([tokenizer, model]) => ({ tokenizer, model }))
rerankerLoading.catch(() => {
@@ -134,52 +161,7 @@ export async function rerankPassages(
return scores
}
-/**
- * Devices for the MiniLM embedder and the reranker. On macOS, CoreML ran MiniLM at about 450 ms a
- * passage against 50 ms on the CPU (measured 2026-10-15, app under load), so CPU is used unless the
- * user chose a backend.
- */
-function embeddingDevices(backendPreference: BackendPreference): ReturnType {
- const devices = onnxDeviceCandidates(process.platform, backendPreference)
- return backendPreference === 'auto' && process.platform === 'darwin'
- ? devices.filter((device) => device !== 'coreml')
- : devices
-}
-
-export async function embedText(
- text: string,
- modelsDir: string,
- onReady?: (device: string, reason?: string) => void,
- backendPreference: BackendPreference = 'auto',
- readDirectories = modelReadDirectories()
-): Promise {
- if (!pipe) {
- const id = 'Xenova/all-MiniLM-L6-v2'
- const fp32 = resolveTransformersPackage(id, 'model.onnx', readDirectories)
- const cached = fp32 ?? resolveTransformersPackage(id, 'model_quantized.onnx', readDirectories)
- // Local-only reads must not require writable storage. Guard only an explicit
- // folder choice when the pipeline can download; default storage is created lazily.
- if (!cached && process.env.OFFGRID_EMBEDDINGS_OFFLINE !== '1') requireModelStorage()
- configureTransformersEnv(modelsDir, !!cached)
- loading ??= loadWithOnnxFallback(
- (device) =>
- pipeline('feature-extraction', cached ?? 'Xenova/all-MiniLM-L6-v2', {
- device,
- ...(cached ? { local_files_only: true, ...(fp32 ? {} : { dtype: 'q8' as const }) } : {})
- }),
- embeddingDevices(backendPreference)
- )
- .then(({ runtime, device, fallbackReason }) => {
- onReady?.(device, fallbackReason)
- activeDevice = device
- return runtime
- })
- .catch((error) => {
- loading = null
- throw error
- })
- pipe = await loading
- }
- const output = await pipe(text, { pooling: 'mean', normalize: true })
- return Array.from(output.data)
+/** The host supplies the shared policy result to both execution paths. */
+function embeddingDevices(devices: OnnxDevice[]): OnnxDevice[] {
+ return devices
}
diff --git a/src/main/embeddings-worker.ts b/src/main/embeddings-worker.ts
index 7fdc76140..8af0921c9 100644
--- a/src/main/embeddings-worker.ts
+++ b/src/main/embeddings-worker.ts
@@ -1,5 +1,5 @@
/**
- * Embedding inference, off the main thread.
+ * Embedding inference, in its own utility process (onnx-host/utility-host.ts).
*
* all-MiniLM-L6-v2 runs on the ONNX/WASM runtime, and profiling the main process showed why that
* cannot live beside the window: tokenization and mean pooling are JS (they block the event loop),
@@ -7,46 +7,40 @@
* for cores. A startup backfill of a few dozen rows held the main process at 110% CPU with no chat
* open and no task running.
*
- * This worker owns the pipeline and answers one request at a time. The host (embeddings.ts) keeps
+ * This helper process owns the pipeline and answers one request at a time. The host (embeddings.ts) keeps
* the queue, so back-pressure and cancellation stay a host concern and this file stays a pure
* text-in / vector-out service.
*/
-import { parentPort, workerData } from 'worker_threads'
-import { embedText, embedTexts, embeddingDevice, rerankPassages } from './embeddings-core'
+import { DEFAULT_EMBEDDER, embedTexts, embeddingDevice, rerankPassages } from './embeddings-core'
import { configureRuntime } from './runtime-env'
-import type { BackendPreference } from '../shared/backend-preferences'
+import type { OnnxDevice } from '@offgrid/models'
+import { connectHelper, type HelperPort } from './onnx-host/helper-port'
-if (!parentPort) throw new Error('embeddings-worker must be started as a worker thread')
-const port = parentPort
-
-// modelsDir() reads Electron's app paths, which do not exist in a worker thread — the host
-// resolves the directory and passes it in.
-const {
- modelsDir,
- chosenModelsDir,
- modelReadDirectories = [modelsDir],
- backendPreference = 'auto'
-} = workerData as {
+// modelsDir() reads Electron's app paths, which a helper process does not have — the host
+// resolves the directories and sends them as the first message.
+export interface EmbeddingWorkerInit {
modelsDir: string
chosenModelsDir?: string
modelReadDirectories?: string[]
- backendPreference?: BackendPreference
+ devices: OnnxDevice[]
}
-// Preserve the host's explicit choice without treating its default path as chosen.
-configureRuntime({ modelsDir: chosenModelsDir })
+let port: HelperPort
+let modelsDir = ''
+let modelReadDirectories: string[] = []
+let devices: OnnxDevice[] = ['cpu']
export interface EmbeddingRequest {
id: number
- text: string
- /** Several texts for one model (documents attached to chat). */
- texts?: string[]
+ /** The texts to embed, or to score against `rerankQuery`. */
+ texts: string[]
+ /** The sentence embedder (embeddings-core EMBEDDERS); DEFAULT_EMBEDDER when absent. */
+ model?: string
/** Score `texts` against this query with the cross-encoder instead of embedding them. */
rerankQuery?: string
}
export interface EmbeddingResponse {
id: number
- vector?: number[]
vectors?: number[][]
scores?: number[]
ready?: boolean
@@ -55,54 +49,55 @@ export interface EmbeddingResponse {
error?: string
}
-port.on('message', (request: EmbeddingRequest) => {
+function handle(request: EmbeddingRequest): void {
void (async () => {
try {
if (request.rerankQuery !== undefined) {
const scores = await rerankPassages(
request.rerankQuery,
- request.texts ?? [],
- modelsDir,
- backendPreference,
- modelReadDirectories
- )
- port.postMessage({ id: request.id, scores } satisfies EmbeddingResponse)
- return
- }
- if (request.texts) {
- const vectors = await embedTexts(
request.texts,
modelsDir,
- backendPreference,
+ devices,
modelReadDirectories
)
- port.postMessage({ id: request.id, vectors } satisfies EmbeddingResponse)
+ port.post({ id: request.id, scores } satisfies EmbeddingResponse)
return
}
- const vector = await embedText(
- request.text,
+ const vectors = await embedTexts(
+ request.texts,
+ request.model ?? DEFAULT_EMBEDDER.model,
modelsDir,
(device, fallbackReason) => {
- port.postMessage({
+ port.post({
id: request.id,
ready: true,
device,
fallbackReason
} satisfies EmbeddingResponse)
},
- backendPreference,
+ devices,
modelReadDirectories
)
- port.postMessage({
+ port.post({
id: request.id,
- vector,
+ vectors,
device: embeddingDevice() ?? undefined
- } as EmbeddingResponse)
+ } satisfies EmbeddingResponse)
} catch (error) {
- port.postMessage({
+ port.post({
id: request.id,
error: error instanceof Error ? error.message : String(error)
- } as EmbeddingResponse)
+ } satisfies EmbeddingResponse)
}
})()
+}
+
+void connectHelper().then(({ init, port: connected }) => {
+ port = connected
+ modelsDir = init.modelsDir
+ modelReadDirectories = init.modelReadDirectories ?? [init.modelsDir]
+ devices = init.devices
+ // Preserve the host's explicit choice without treating its default path as chosen.
+ configureRuntime({ modelsDir: init.chosenModelsDir })
+ port.onRequest(handle)
})
diff --git a/src/main/embeddings.ts b/src/main/embeddings.ts
index 2d05c11ad..b5cf0df8f 100644
--- a/src/main/embeddings.ts
+++ b/src/main/embeddings.ts
@@ -2,23 +2,15 @@ import { withStorageDownload } from './model-storage-guard'
import { beginRuntimeBackend, providerLabel } from './runtime-backends'
import path from 'path'
import { existsSync } from 'fs'
-import { Worker } from 'worker_threads'
+import { clearHelperCrashes, forkHelper, type HelperProcess } from './onnx-host/utility-host'
import { chosenModelsDir, modelsDir } from './runtime-env'
import { modelReadDirectories } from './model-paths'
-import {
- DOCUMENT_EMBEDDER,
- DOCUMENT_RERANKER,
- disposeEmbeddingModel,
- embedText,
- embedTexts,
- embeddingDevice,
- rerankPassages
-} from './embeddings-core'
+import { DEFAULT_EMBEDDER, DOCUMENT_EMBEDDER, DOCUMENT_RERANKER } from './embedding-models'
import { getResidencyMode } from './runtime-residency'
import { recordAIRequest, type AIRequestHandle } from './ai-request-log'
-import type { EmbeddingRequest, EmbeddingResponse } from './embeddings-worker'
+import type { EmbeddingRequest, EmbeddingResponse, EmbeddingWorkerInit } from './embeddings-worker'
import { writeDiagnosticLog } from './diagnostics-log'
-import { getBackendPreference } from './backend-preferences'
+import { onnxLadder } from './backend-facts'
/**
* The built worker, when there is one.
@@ -28,6 +20,11 @@ import { getBackendPreference } from './backend-preferences'
* no TypeScript transform, so its own imports fail to resolve. Returning null there is deliberate:
* the caller embeds in-process instead, using the same implementation.
*/
+const SERVICE_NAME = 'offgrid-search'
+
+/** The in-process path loads transformers.js only when there is no helper to run it. */
+const core = (): Promise => import('./embeddings-core')
+
function builtWorkerEntry(): string | null {
const built = path.join(__dirname, 'embeddings-worker.js')
return existsSync(built) ? built : null
@@ -36,14 +33,13 @@ function builtWorkerEntry(): string | null {
/**
* Text -> vector, executed in a worker thread (see embeddings-worker.ts for why).
*
- * The public surface is unchanged: callers still `await embeddings.generateEmbedding(text)`. What
- * changed is where that runs. Requests are serialized onto one worker rather than issued
- * concurrently, because the ONNX runtime is already multi-threaded internally — firing several at
- * once multiplies its WASM threads and starves the UI, which is the behaviour this move exists to
- * stop.
+ * Every embedding goes through embed(): memory records, document passages, /v1/embeddings, MCP and
+ * tool routing. Requests are serialized onto one worker rather than issued concurrently, because
+ * the ONNX runtime is already multi-threaded internally — firing several at once multiplies its
+ * WASM threads and starves the UI, which is the behaviour this move exists to stop.
*/
class EmbeddingService {
- private worker: Worker | null = null
+ private worker: HelperProcess | null = null
private nextId = 1
private readonly waiting = new Map<
number,
@@ -59,6 +55,8 @@ class EmbeddingService {
* on) go ahead of background indexing, which can queue hundreds. */
private readonly pending: { interactive: boolean; start: () => void }[] = []
private running = false
+ /** When the last interactive request was scheduled or finished (ms epoch). */
+ private lastInteractiveAt = 0
private reportedDevice: string | null = null
private shuttingDown = false
@@ -77,25 +75,33 @@ class EmbeddingService {
// Queued requests see shuttingDown and cannot start another worker. The source-only
// fallback has no worker to terminate, so let its active inference finish before disposal.
await this.queue
- if (!worker) await disposeEmbeddingModel()
+ if (!worker) await (await core()).disposeEmbeddingModel()
this.reportedDevice = null
}
- private spawn(entry: string): Worker {
+ private spawn(entry: string): HelperProcess {
if (this.worker) return this.worker
// Built entries sit side by side in out/main (electron.vite.config.ts), but tests and any
// run-from-source context have only the .ts next to this file. Resolve whichever EXISTS rather
// than assuming the built layout: assuming it made every embedding fail outside a packaged
// build, which silently demoted vector search to the FTS fallback instead of erroring.
- const backendState = beginRuntimeBackend('embeddings', 'Xenova/all-MiniLM-L6-v2')
- const worker = new Worker(entry, {
- workerData: {
- modelsDir: modelsDir(),
- chosenModelsDir: chosenModelsDir(),
- modelReadDirectories: modelReadDirectories(),
- backendPreference: getBackendPreference('embeddings')
- }
- })
+ const backendState = beginRuntimeBackend('embeddings', DEFAULT_EMBEDDER.model)
+ let worker: HelperProcess
+ try {
+ worker = forkHelper(
+ entry,
+ {
+ modelsDir: modelsDir(),
+ chosenModelsDir: chosenModelsDir(),
+ modelReadDirectories: modelReadDirectories(),
+ devices: onnxLadder('embeddings')
+ } satisfies EmbeddingWorkerInit,
+ { serviceName: SERVICE_NAME, label: 'Search' }
+ )
+ } catch (error) {
+ backendState.fail(error)
+ throw error
+ }
worker.on('message', (response: EmbeddingResponse) => {
if (response.ready && response.device) {
backendState.ready(providerLabel(response.device), undefined, response.fallbackReason)
@@ -125,38 +131,111 @@ class EmbeddingService {
for (const [, pending] of this.waiting) pending.reject(error)
this.waiting.clear()
}
- worker.on('error', fail)
worker.on('exit', (code) => {
- backendState.stop()
- if (this.worker !== worker) return
+ if (this.worker !== worker) {
+ backendState.stop()
+ return
+ }
this.reportedDevice = null
- if (code !== 0) fail(new Error(`Embedding worker exited with code ${code}`))
- else this.worker = null
+ if (code !== 0) fail(new Error(`The search helper stopped (exit code ${code}).`))
+ else {
+ backendState.stop()
+ this.worker = null
+ }
})
- worker.unref() // never hold the app open just for this
this.worker = worker
return worker
}
+ /** True while a search helper process is running. */
+ get isRunning(): boolean {
+ return this.worker !== null
+ }
+
+ /** Start the next request on a fresh helper with the current backend choice and environment,
+ * after the requests already scheduled finish. */
+ async restart(): Promise {
+ clearHelperCrashes(SERVICE_NAME)
+ await this.queue.catch(() => {})
+ const worker = this.worker
+ this.worker = null
+ this.reportedDevice = null
+ if (worker) await worker.terminate()
+ }
+
/** Kept for callers that want to pay the model load cost up front. */
async init(): Promise {
await this.generateEmbedding('')
}
- /** `interactive`: someone is waiting on this vector (a search), so it runs before background
- * indexing that is already queued. */
+ /**
+ * Vectors for `texts`: the one embedding path. `model` is a sentence embedder from
+ * embeddings-core's EMBEDDERS (DEFAULT_EMBEDDER when omitted). `interactive`: someone is waiting
+ * on it (a search), so it runs before background indexing that is already queued.
+ */
+ async embed(
+ texts: string[],
+ opts: { model?: string; interactive?: boolean; source?: string } = {}
+ ): Promise {
+ if (!texts.length) return []
+ const model = opts.model ?? DEFAULT_EMBEDDER.model
+ const source =
+ opts.source ??
+ (texts.length > 1 ? 'Embedding batch' : texts[0] ? 'Embedding' : 'Embedding warm-up')
+ // A cold worker may download its model while handling its first request.
+ return withStorageDownload(() =>
+ this.runModel(
+ texts.length === 1
+ ? { text: texts[0], pooling: 'mean', normalize: true }
+ : { passages: texts.length, characters: texts.reduce((sum, t) => sum + t.length, 0) },
+ (entry, log) =>
+ // No built worker means we are running from source. Embed here rather than failing: a
+ // failed embedding silently demotes every search to keyword only, which is a far worse
+ // outcome than briefly holding this thread in a context that has no UI to block.
+ entry
+ ? this.post({ id: this.nextId++, texts, model }, entry, log).then(
+ (r) => r.vectors ?? []
+ )
+ : core().then(({ embedTexts }) =>
+ embedTexts(
+ texts,
+ model,
+ modelsDir(),
+ (device, reason) => {
+ this.reportedDevice = device
+ beginRuntimeBackend('embeddings', model).ready(
+ providerLabel(device),
+ undefined,
+ reason
+ )
+ },
+ onnxLadder('embeddings'),
+ modelReadDirectories()
+ )
+ ),
+ opts.interactive === true,
+ model,
+ source,
+ (vectors) => ({ dimensions: vectors[0]?.length ?? 0 })
+ )
+ )
+ }
+
+ /** One text's vector with DEFAULT_EMBEDDER: embed() for a single text. */
async generateEmbedding(text: string, opts: { interactive?: boolean } = {}): Promise {
- // A cold worker may download its model while handling its first embedding.
- return withStorageDownload(() => this.generateEmbeddingInner(text, opts.interactive === true))
+ const [vector] = await this.embed([text], opts)
+ return vector ?? []
}
/** Run `task` when the worker is free, interactive requests first, then in arrival order. */
private schedule(task: () => Promise, interactive: boolean): Promise {
+ if (interactive) this.lastInteractiveAt = Date.now()
const result = new Promise((resolve, reject) => {
const start = (): void => {
void task()
.then(resolve, reject)
.finally(() => {
+ if (interactive) this.lastInteractiveAt = Date.now()
this.running = false
this.pump()
})
@@ -179,48 +258,6 @@ class EmbeddingService {
next.start()
}
- private async generateEmbeddingInner(text: string, interactive: boolean): Promise {
- if (this.shuttingDown) throw new Error('Embedding runtime is shutting down.')
- return recordAIRequest(
- {
- modality: 'embedding',
- source: text ? 'Embedding' : 'Embedding warm-up',
- model: 'Xenova/all-MiniLM-L6-v2',
- request: { text, pooling: 'mean', normalize: true }
- },
- async (log) => {
- const run = (): Promise => {
- if (this.shuttingDown) throw new Error('Embedding runtime is shutting down.')
- const entry = builtWorkerEntry()
- // No built worker means we are running from source. Embed here rather than failing: a failed
- // embedding silently demotes every search to the FTS fallback, which is a far worse outcome
- // than briefly holding this thread in a context that has no UI to block.
- if (!entry)
- return embedText(
- text,
- modelsDir(),
- (device, reason) => {
- beginRuntimeBackend('embeddings', 'Xenova/all-MiniLM-L6-v2').ready(
- providerLabel(device),
- undefined,
- reason
- )
- },
- getBackendPreference('embeddings'),
- modelReadDirectories()
- )
- return this.post({ id: this.nextId++, text }, entry, log).then((r) => r.vector ?? [])
- }
- const vector = await this.schedule(() => this.withResidency(run), interactive)
- log.update({
- backend: this.reportedDevice ?? embeddingDevice() ?? 'Unknown',
- metrics: { dimensions: vector.length }
- })
- return vector
- }
- )
- }
-
private post(
request: EmbeddingRequest,
entry: string,
@@ -237,6 +274,19 @@ class EmbeddingService {
private holds = 0
/** Keep the worker and models loaded until the returned release is called, even on demand. */
+ /**
+ * Resolve once no interactive request has run for `quietMs`. Background indexing waits on this
+ * before each batch, so a search never queues behind a batch that started between its query
+ * vector and its rerank (a search makes both requests, a moment apart).
+ */
+ async whenQuiet(quietMs: number): Promise {
+ for (;;) {
+ const wait = this.lastInteractiveAt + quietMs - Date.now()
+ if (wait <= 0 && !this.pending.some((p) => p.interactive)) return
+ await new Promise((resolve) => setTimeout(resolve, Math.max(wait, 100)))
+ }
+ }
+
hold(): () => void {
this.holds++
let released = false
@@ -258,44 +308,25 @@ class EmbeddingService {
const worker = this.worker
this.worker = null
if (worker) await worker.terminate()
- else await disposeEmbeddingModel()
+ else await (await core()).disposeEmbeddingModel()
this.reportedDevice = null
}
}
}
/**
- * Vectors for passages of documents attached to chat, with the multilingual document model.
- * Background work unless `interactive` (a question is waiting on a query vector).
+ * Vectors for passages of documents attached to chat, with the document model. Background work
+ * unless `interactive` (a question is waiting on a query vector).
*/
async embedDocumentTexts(
texts: string[],
opts: { interactive?: boolean; source?: string } = {}
): Promise {
- if (!texts.length) return []
- return withStorageDownload(() =>
- this.runModel(
- texts.length === 1
- ? { text: texts[0], model: DOCUMENT_EMBEDDER }
- : { passages: texts.length, characters: texts.reduce((sum, t) => sum + t.length, 0) },
- (entry, log) =>
- entry
- ? this.post(
- { id: this.nextId++, text: '', texts },
- entry,
- log
- ).then((r) => r.vectors ?? [])
- : embedTexts(
- texts,
- modelsDir(),
- getBackendPreference('embeddings'),
- modelReadDirectories()
- ),
- opts.interactive === true,
- DOCUMENT_EMBEDDER,
- opts.source ?? (texts.length === 1 ? 'Document question embedding' : 'Document indexing')
- )
- )
+ return this.embed(texts, {
+ ...opts,
+ model: DOCUMENT_EMBEDDER,
+ source: opts.source ?? (texts.length === 1 ? 'Document question embedding' : 'Document indexing')
+ })
}
/** Cross-encoder relevance of each passage to the query, same order. Someone is waiting on it. */
@@ -307,16 +338,18 @@ class EmbeddingService {
(entry, log) =>
entry
? this.post(
- { id: this.nextId++, text: '', texts: passages, rerankQuery: query },
+ { id: this.nextId++, texts: passages, rerankQuery: query },
entry,
log
).then((r) => r.scores ?? [])
- : rerankPassages(
- query,
- passages,
- modelsDir(),
- getBackendPreference('embeddings'),
- modelReadDirectories()
+ : core().then(({ rerankPassages }) =>
+ rerankPassages(
+ query,
+ passages,
+ modelsDir(),
+ onnxLadder('embeddings'),
+ modelReadDirectories()
+ )
),
true,
DOCUMENT_RERANKER,
@@ -330,7 +363,8 @@ class EmbeddingService {
run: (entry: string | null, log: AIRequestHandle) => Promise,
interactive: boolean,
model: string,
- source: string
+ source: string,
+ metrics?: (result: T) => Record
): Promise {
if (this.shuttingDown) throw new Error('Embedding runtime is shutting down.')
return recordAIRequest({ modality: 'embedding', source, model, request }, async (log) => {
@@ -342,7 +376,10 @@ class EmbeddingService {
}),
interactive
)
- log.update({ backend: this.reportedDevice ?? embeddingDevice() ?? 'Unknown' })
+ log.update({
+ backend: this.reportedDevice ?? 'Unknown',
+ ...(metrics ? { metrics: metrics(result) } : {})
+ })
return result
})
}
diff --git a/src/main/engine-resume-health.ts b/src/main/engine-resume-health.ts
new file mode 100644
index 000000000..d5b4e5788
--- /dev/null
+++ b/src/main/engine-resume-health.ts
@@ -0,0 +1,93 @@
+import { powerMonitor } from 'electron'
+import { invalidateBackendFacts } from './backend-facts'
+import { refreshNvidiaDriverVersion } from './nvidia-driver'
+
+/** A driver update while asleep changes which GPU engines may run. */
+function refreshBackendFacts(): void {
+ invalidateBackendFacts()
+ void refreshNvidiaDriverVersion()
+ .then(invalidateBackendFacts)
+ .catch(() => {})
+}
+
+interface ResumeHealthTarget {
+ id: string
+ shouldBeRunning(): boolean
+ healthCheck(): Promise
+ restart(): Promise
+}
+
+const targets = new Map()
+let pending: Promise | null = null
+
+export function registerResumeHealthTarget(target: ResumeHealthTarget): () => void {
+ targets.set(target.id, target)
+ return () => {
+ if (targets.get(target.id) === target) targets.delete(target.id)
+ }
+}
+
+/** Requests arriving during the wake debounce wait for the engines to be checked. */
+export async function waitForEngineResumeHealth(): Promise {
+ while (pending) await pending
+}
+
+export function installEngineResumeHealth(
+ deps: {
+ powerMonitor?: Pick
+ invalidateBackendFacts?: () => void
+ } = {}
+): () => void {
+ const monitor = deps.powerMonitor ?? powerMonitor
+ let timer: ReturnType | undefined
+ let finish: (() => void) | undefined
+ let checking = false
+ let disposed = false
+ const check = async (): Promise => {
+ timer = undefined
+ checking = true
+ try {
+ await Promise.all(
+ [...targets.values()].map(async (target) => {
+ try {
+ if (!target.shouldBeRunning()) return
+ const healthy = await target.healthCheck().catch(() => false)
+ if (!disposed && !healthy && target.shouldBeRunning()) await target.restart()
+ } catch (error) {
+ console.error(`[engine-resume-health] ${target.id} recovery failed:`, error)
+ }
+ })
+ )
+ } finally {
+ checking = false
+ pending = null
+ finish?.()
+ finish = undefined
+ }
+ }
+ const onResume = (): void => {
+ ;(deps.invalidateBackendFacts ?? refreshBackendFacts)()
+ if (checking || disposed) return
+ if (!pending)
+ pending = new Promise((resolve) => {
+ finish = resolve
+ })
+ clearTimeout(timer)
+ timer = setTimeout(() => {
+ void check()
+ }, 2000)
+ }
+ monitor.on('resume', onResume)
+ monitor.on('unlock-screen', onResume)
+ return () => {
+ disposed = true
+ monitor.removeListener('resume', onResume)
+ monitor.removeListener('unlock-screen', onResume)
+ clearTimeout(timer)
+ if (!checking) {
+ pending = null
+ finish?.()
+ finish = undefined
+ }
+ }
+}
diff --git a/src/main/engine-updates-service.ts b/src/main/engine-updates-service.ts
index fed156b72..9d2071fe8 100644
--- a/src/main/engine-updates-service.ts
+++ b/src/main/engine-updates-service.ts
@@ -6,7 +6,7 @@ import { dataDir } from './runtime-env'
import { hasLinuxNvidiaDriver, hasWindowsNvidiaDriver } from './transcription/whisper-runtime'
/** Setting key; engines stay current unless the user turns this off. */
-export const KEEP_ENGINES_UPDATED = 'keepEnginesUpdated'
+const KEEP_ENGINES_UPDATED = 'keepEnginesUpdated'
export const engineUpdater = createEngineUpdater({
platform: process.platform,
diff --git a/src/main/extension-bridge/bridge-conversations.ts b/src/main/extension-bridge/bridge-conversations.ts
index 872ed09dc..39c24d2e2 100644
--- a/src/main/extension-bridge/bridge-conversations.ts
+++ b/src/main/extension-bridge/bridge-conversations.ts
@@ -55,7 +55,7 @@ export function setBrowserConversationOrigin(
export const MAX_LISTED = 100
export const MAX_TURNS = 40
-export const MAX_TURN_CHARS = 20_000
+const MAX_TURN_CHARS = 20_000
/** SQLite CURRENT_TIMESTAMP ('YYYY-MM-DD HH:MM:SS', UTC) to epoch ms; 0 when unreadable. */
export function sqlTime(value: string | null | undefined): number {
@@ -183,7 +183,7 @@ export const HISTORY_CONFLICT =
export type BridgeListedConversation = Omit & {
readonly turns: (BridgeTurn & { readonly image?: string })[]
}
-export type BridgeConversationSearchHit = Omit & {
+type BridgeConversationSearchHit = Omit & {
readonly snippet: string
readonly turnCount: number
}
diff --git a/src/main/extension-bridge/bridge-settings.ts b/src/main/extension-bridge/bridge-settings.ts
index b95ac5e44..b0a725723 100644
--- a/src/main/extension-bridge/bridge-settings.ts
+++ b/src/main/extension-bridge/bridge-settings.ts
@@ -50,7 +50,7 @@ export interface TextSettings {
readonly systemPrompt?: string
readonly kvCacheType?: 'f16' | 'q8_0' | 'q4_0'
readonly flashAttn?: boolean
- readonly gpuLayers?: number
+ readonly gpuLayers?: number | null
readonly threads?: number
readonly batchSize?: number
readonly speculativeDecoding?: 'off' | 'ngram' | 'mtp' | 'draft' | 'dflash'
@@ -294,7 +294,7 @@ const SECTION_RULES: Record> = {
reasoningBudget: int(0, 1048576),
kvCacheType: oneOf(['f16', 'q8_0', 'q4_0']),
flashAttn: bool,
- gpuLayers: int(-1, 1000),
+ gpuLayers: (value) => (value === null ? null : int(-1, 1000)(value)),
threads: int(0, 1024),
batchSize: int(1, 65536),
speculativeDecoding: oneOf(['off', 'ngram', 'mtp', 'draft', 'dflash']),
diff --git a/src/main/extension-bridge/bridge-task-progress.ts b/src/main/extension-bridge/bridge-task-progress.ts
index 62d5b4019..d0bdaa68f 100644
--- a/src/main/extension-bridge/bridge-task-progress.ts
+++ b/src/main/extension-bridge/bridge-task-progress.ts
@@ -41,7 +41,7 @@ export function latestBrowserTask(
/** A task this browser's chat was given an id for, that has not started yet: queued behind
* another task, or waiting for approval. Its own id, so the chat follows and stops only it. */
-export function queuedBrowserTask(taskId: string): BrowserTaskProgress {
+function queuedBrowserTask(taskId: string): BrowserTaskProgress {
return {
taskId,
status: 'queued',
diff --git a/src/main/files-classify.ts b/src/main/files-classify.ts
index 1981473c6..c87a61b09 100644
--- a/src/main/files-classify.ts
+++ b/src/main/files-classify.ts
@@ -42,7 +42,7 @@ export function sanitizeUploadName(name: string): string {
}
/** Formats we cannot read yet. Read as text, their bytes would reach the model as garbage. */
-export const BINARY_EXT = [
+const BINARY_EXT = [
'xlsx',
'xls',
'xlsm',
diff --git a/src/main/files-image-probe.ts b/src/main/files-image-probe.ts
index 858fd2fb8..38ab4be00 100644
--- a/src/main/files-image-probe.ts
+++ b/src/main/files-image-probe.ts
@@ -31,7 +31,7 @@ export type ImageDecodeVerdict =
/** Load sharp on demand. Returns null when the module cannot load at all, so the
* caller can distinguish a missing checker from a bad file. */
-export async function loadImageProbe(): Promise {
+async function loadImageProbe(): Promise {
try {
const mod = (await import('sharp')) as unknown as {
default?: (p: string, o?: unknown) => { metadata(): Promise }
diff --git a/src/main/frame-text-index.ts b/src/main/frame-text-index.ts
index 57cc12645..c98f874c8 100644
--- a/src/main/frame-text-index.ts
+++ b/src/main/frame-text-index.ts
@@ -3,8 +3,23 @@
// is contentless (the text is not stored twice) with contentless_delete, so a frame that changes
// or goes away is simply dropped from it. New and edited frames are indexed by triggers; frames
// captured before the index existed are indexed in the background, newest first, in small batches.
+//
+// frame_fts keeps the tokenizer it was created with. It is the largest index by far (every word
+// ever on screen), and rebuilding it for the shared tokenizer (FTS_TOKENIZE, which keeps Devanagari
+// vowel signs inside words) would mean indexing everything twice on the main thread. Frame text is
+// mostly Latin, and a Hindi whole word still matches the old tokenizer through the phrase query
+// ftsQuery builds. A new index is created on the shared tokenizer; an existing one is rebuilt only
+// once a chunked, off-main-thread rebuild exists. Text is normalised (normaliseForIndex) on the way
+// in either way, so queries and indexed text agree.
-import { getDB } from './database'
+import {
+ enableFtsSecureDelete,
+ getDB,
+ markFtsIndexCurrent,
+ registerFtsFunctions,
+ tableExists
+} from './database'
+import { FTS_TOKENIZE, normaliseForIndex } from '@offgrid/rag/records'
/** The backfill runs on the main thread in short slices: an average frame carries ~6KB of OCR, and
* 200-frame batches held the thread over half a second each, which made the whole app lag. Each
@@ -14,38 +29,68 @@ const BACKFILL_PAUSE_MS = 80
/** Frames read per slice; a slice usually stops on its time budget first. */
const BACKFILL_BATCH = 50
-interface IndexDatabase {
- exec(sql: string): void
- prepare(sql: string): { get(...args: unknown[]): unknown }
+type IndexDatabase = ReturnType
+
+/** Below this frame id, frames still wait for the backfill; it works down from here. */
+function startCursor(db: IndexDatabase): number {
+ return (db.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS id FROM frames').get() as { id: number })
+ .id
+}
+
+function writeState(db: IndexDatabase, cursor: number): void {
+ // The cursor lives beside the index, so the index needs nothing but this database.
+ db.exec(
+ `CREATE TABLE IF NOT EXISTS frame_fts_state (id INTEGER PRIMARY KEY CHECK (id = 1), cursor INTEGER NOT NULL);
+ INSERT OR REPLACE INTO frame_fts_state (id, cursor) VALUES (1, ${Math.floor(cursor)});`
+ )
}
+/** Synced mail and chat frames stay out, as search already left them out. */
+const TRIGGERS = `
+ CREATE TRIGGER IF NOT EXISTS frames_fts_ai AFTER INSERT ON frames
+ WHEN new.text IS NOT NULL AND COALESCE(new.source, '') != 'connector' BEGIN
+ INSERT INTO frame_fts(rowid, text) VALUES (new.id, offgrid_fts_text(new.text));
+ END;
+ CREATE TRIGGER IF NOT EXISTS frames_fts_ad AFTER DELETE ON frames BEGIN
+ DELETE FROM frame_fts WHERE rowid = old.id;
+ END;
+ CREATE TRIGGER IF NOT EXISTS frames_fts_au AFTER UPDATE OF text, source ON frames BEGIN
+ DELETE FROM frame_fts WHERE rowid = old.id;
+ INSERT INTO frame_fts(rowid, text)
+ SELECT new.id, offgrid_fts_text(new.text)
+ WHERE new.text IS NOT NULL AND COALESCE(new.source, '') != 'connector';
+ END;
+`
+
/** Create the index and its triggers on a database that has the frames table. Idempotent. */
export function ensureFrameTextIndex(db: IndexDatabase): void {
- const exists = db
- .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'frame_fts'")
- .get()
- if (!exists) {
+ // The app's connection has the triggers' function already; another handle may not.
+ try {
+ db.prepare("SELECT offgrid_fts_text('')").get()
+ } catch {
+ registerFtsFunctions(db)
+ }
+ // An earlier build of this branch rebuilt the index beside itself; that copy goes.
+ db.exec(`
+ DROP TRIGGER IF EXISTS frames_fts_next_ai;
+ DROP TRIGGER IF EXISTS frames_fts_next_ad;
+ DROP TRIGGER IF EXISTS frames_fts_next_au;
+ DROP TABLE IF EXISTS frame_fts_next;
+ DROP TABLE IF EXISTS frame_fts_next_state;
+ `)
+ if (!tableExists(db, 'frame_fts')) {
db.exec(
`CREATE VIRTUAL TABLE frame_fts USING fts5(
- text, content='', contentless_delete=1, tokenize='unicode61 remove_diacritics 2'
+ text, content='', contentless_delete=1, tokenize = "${FTS_TOKENIZE}"
)`
)
- // Everything below this id predates the index; the backfill works down from here. The
- // cursor lives beside the index, so the index needs nothing but this database.
- const top = db.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS id FROM frames').get() as {
- id: number
- }
- db.exec(
- `CREATE TABLE IF NOT EXISTS frame_fts_state (id INTEGER PRIMARY KEY CHECK (id = 1), cursor INTEGER NOT NULL);
- INSERT OR REPLACE INTO frame_fts_state (id, cursor) VALUES (1, ${Math.floor(Number(top.id))});`
- )
+ enableFtsSecureDelete(db, 'frame_fts')
+ markFtsIndexCurrent(db, 'frame_fts')
+ writeState(db, startCursor(db))
}
- // An index made before the cursor moved here kept it in app settings; carry it over once, or
- // start again from the top (re-indexing a frame is safe).
- const hasState = db
- .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'frame_fts_state'")
- .get()
- if (!hasState) {
+ // An index made before the cursor moved beside it kept it in app settings; carry it over once,
+ // or start again from the top (re-indexing a frame is safe).
+ if (!tableExists(db, 'frame_fts_state')) {
let cursor: number | null = null
try {
const row = db
@@ -55,32 +100,20 @@ export function ensureFrameTextIndex(db: IndexDatabase): void {
} catch {
/* no settings table here */
}
- if (cursor === null || !Number.isFinite(cursor)) {
- cursor = (db.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS id FROM frames').get() as { id: number })
- .id
- }
- db.exec(
- `CREATE TABLE IF NOT EXISTS frame_fts_state (id INTEGER PRIMARY KEY CHECK (id = 1), cursor INTEGER NOT NULL);
- INSERT OR REPLACE INTO frame_fts_state (id, cursor) VALUES (1, ${Math.floor(cursor)});`
- )
+ writeState(db, cursor === null || !Number.isFinite(cursor) ? startCursor(db) : cursor)
}
-
- // Synced mail and chat frames stay out, as search already left them out.
- db.exec(`
- CREATE TRIGGER IF NOT EXISTS frames_fts_ai AFTER INSERT ON frames
- WHEN new.text IS NOT NULL AND COALESCE(new.source, '') != 'connector' BEGIN
- INSERT INTO frame_fts(rowid, text) VALUES (new.id, new.text);
- END;
- CREATE TRIGGER IF NOT EXISTS frames_fts_ad AFTER DELETE ON frames BEGIN
- DELETE FROM frame_fts WHERE rowid = old.id;
- END;
- CREATE TRIGGER IF NOT EXISTS frames_fts_au AFTER UPDATE OF text, source ON frames BEGIN
- DELETE FROM frame_fts WHERE rowid = old.id;
- INSERT INTO frame_fts(rowid, text)
- SELECT new.id, new.text
- WHERE new.text IS NOT NULL AND COALESCE(new.source, '') != 'connector';
- END;
- `)
+ // Older triggers indexed the raw text; these normalise it. Deletes are by rowid (contentless),
+ // so replacing them is safe on an index of either kind.
+ const insertTrigger = db
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'frames_fts_ai'")
+ .get() as { sql: string } | undefined
+ if (insertTrigger && !insertTrigger.sql.includes('offgrid_fts_text')) {
+ db.exec(`
+ DROP TRIGGER IF EXISTS frames_fts_ai;
+ DROP TRIGGER IF EXISTS frames_fts_au;
+ `)
+ }
+ db.exec(TRIGGERS)
}
/** The lowest frame id the backfill has reached; 0 once every older frame is indexed. */
@@ -106,10 +139,7 @@ export function frameTextIndexBackfilling(): boolean {
/** Index a slice of older frames, newest first: up to `batch` frames, stopping early once
* `budgetMs` is spent. Returns whether more remain. */
-export function indexFrameTextBatch(
- batch = BACKFILL_BATCH,
- budgetMs = SLICE_BUDGET_MS
-): boolean {
+export function indexFrameTextBatch(batch = BACKFILL_BATCH, budgetMs = SLICE_BUDGET_MS): boolean {
const cursor = readCursor()
if (cursor <= 1) return false
const db = getDB()
@@ -128,7 +158,7 @@ export function indexFrameTextBatch(
for (const row of rows) {
// An edit since the index existed may already have indexed this frame.
drop.run(row.id)
- if (row.text && row.source !== 'connector') add.run(row.id, row.text)
+ if (row.text && row.source !== 'connector') add.run(row.id, normaliseForIndex(row.text))
lowest = row.id
if (Date.now() - started >= budgetMs) break
}
diff --git a/src/main/fts-match.ts b/src/main/fts-match.ts
deleted file mode 100644
index b6522810a..000000000
--- a/src/main/fts-match.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/** Build FTS5 syntax from text. Only the caller supplies the join and prefix operators.
- * Quoted words make user-supplied AND/OR/NOT/NEAR ordinary search terms.
- * Empty input returns an empty string: callers must skip MATCH in that case.
- */
-export function escapeFtsMatch(
- input: string | readonly string[],
- options: { join?: 'AND' | 'OR' | ' '; prefix?: boolean; maxTokens?: number } = {}
-): string {
- const terms =
- typeof input === 'string' ? (input.match(/[\p{L}\p{N}][\p{L}\p{N}\p{M}_-]*/gu) ?? []) : input
- return terms
- .filter((term) => /[\p{L}\p{N}]/u.test(term))
- .slice(0, options.maxTokens ?? 12)
- .map((term) => `"${term.replace(/"/g, '""')}"${options.prefix ? '*' : ''}`)
- .join(options.join === ' ' ? ' ' : ` ${options.join ?? 'OR'} `)
-}
diff --git a/src/main/imagegen.ts b/src/main/imagegen.ts
index ce2f3da97..a4431a8fc 100644
--- a/src/main/imagegen.ts
+++ b/src/main/imagegen.ts
@@ -1,3 +1,4 @@
+import { nativeBinaryInstalled } from './backend-facts'
import { recordManagedStorageFile } from './managed-storage-files'
import { withStorageDownload } from './model-storage-guard'
import { beginRuntimeBackend } from './runtime-backends'
@@ -47,7 +48,6 @@ import {
imageBackendForRuntime,
sdRuntimeLibraryEnv
} from './imagegen/sd-runtime'
-import { getBackendPreference } from './backend-preferences'
import { standardModelDefaults, taesdFilename } from '../shared/image-defaults'
import { defaultImageModelFilename } from './image-default'
import {
@@ -85,7 +85,7 @@ import {
type ImageGenerationRequestContract
} from '../shared/image-generation-contract'
-function findSdCli(): string | null {
+function findSdCli(): Promise {
return findSdBinary('sd-cli')
}
@@ -430,7 +430,7 @@ export function imageGenStatus(): {
// used to land on the parked Core ML model).
const active = activeImageModel()
// Available if EITHER runtime is usable: sd-cli (with a model) or MLX/mflux.
- if (!findSdCli() && !mfluxAvailable())
+ if (!nativeBinaryInstalled('image', 'sd-cli') && !mfluxAvailable())
return { available: false, models, active, reason: 'no image runtime found' }
if (!models.length)
return { available: false, models, active, reason: 'no image model installed' }
@@ -444,7 +444,7 @@ export type ImageGenOutput = ImageGenerationOutputContract
/** Native runtimes receive the final prompt as input. The wrapper adds it to their output once. */
type NativeImageGenOutput = Omit
-export interface ImageGenProgress {
+interface ImageGenProgress {
step: number
total: number
secPerStep: number
@@ -650,7 +650,8 @@ export async function generateImage(
throw new Error('An image is already generating — please wait for it to finish.')
const stored = getSetting('imageParams', {})
const remoteParameters = resolveImageParameters(
- { id: remoteId!, name: remote.selectedModel }, stored
+ { id: remoteId!, name: remote.selectedModel },
+ stored
)
const effectiveRemote = {
...params,
@@ -886,7 +887,7 @@ async function runImageGen(
`${coreml ? 'The selected Core ML image model' : 'The selected Z-Image model'} does not support image editing. Select a local image model that supports an init image.`
)
}
- const cli = coreml ? findCoreMLBin() : findSdCli()
+ const cli = coreml ? findCoreMLBin() : await findSdCli()
if (!cli) {
throw new Error(
coreml
@@ -945,13 +946,16 @@ async function runImageGen(
// Progress observers must not force a fresh CLI process for every API request.
const eligibleForServer =
getResidencyMode('image') === 'resident' &&
- !coreml && !isZImage && !loras.length &&
+ !coreml &&
+ !isZImage &&
+ !loras.length &&
(isQwenImage21 || (!params.initImage && ggufIsFullCheckpoint(model)))
if (eligibleForServer) {
const defaults = standardModelDefaults(base)
const context: SdServerContext = {
- modelPath: model, diffusionFa: true,
- taesdPath: !isQwenImage21 && params.fastVae ? resolveTaesd(base) ?? undefined : undefined
+ modelPath: model,
+ diffusionFa: true,
+ taesdPath: !isQwenImage21 && params.fastVae ? (resolveTaesd(base) ?? undefined) : undefined
}
if (isQwenImage21) {
context.diffusionModel = true
@@ -959,7 +963,9 @@ async function runImageGen(
context.llmVisionPath = findInModels(/^mmproj-Qwen3VL-8B-Instruct-.*\.gguf$/i) ?? undefined
context.vaePath = findInModels(/^qwen_image_2\.1_vae.*\.(safetensors|sft)$/i) ?? undefined
if (!context.llmPath || !context.vaePath || (params.initImage && !context.llmVisionPath)) {
- throw new Error('Qwen-Image 2.1 companion files are missing. Download the complete model from Models.')
+ throw new Error(
+ 'Qwen-Image 2.1 companion files are missing. Download the complete model from Models.'
+ )
}
}
generationLifecycle.start(signal, cancelRuntime)
@@ -967,23 +973,30 @@ async function runImageGen(
await sdServer.ensureUp(context)
const residentBinary = sdServer.getBinaryPath() ?? cli
generationLifecycle.throwIfCancelled()
- const { png, seed: usedSeed } = await sdServer.generate({
- prompt: params.prompt,
- negativePrompt: params.negativePrompt?.trim() || (isQwenImage21 ? '' : DEFAULT_NEGATIVE),
- width: params.width ?? defaults.defaultSize,
- height: params.height ?? defaults.defaultSize,
- steps: params.steps ?? defaults.defaultSteps,
- cfgScale: params.cfgScale ?? defaults.defaultCfg,
- sampleMethod: isQwenImage21 ? 'euler' : defaults.sampler,
- scheduler: isQwenImage21 ? 'default' : defaults.scheduler,
- seed,
- refImages: params.initImage ? [(await fs.promises.readFile(params.initImage)).toString('base64')] : undefined
- }, onProgress ? (progress) => onProgress({ ...progress, secPerStep: 0 }) : undefined)
+ const { png, seed: usedSeed } = await sdServer.generate(
+ {
+ prompt: params.prompt,
+ negativePrompt: params.negativePrompt?.trim() || (isQwenImage21 ? '' : DEFAULT_NEGATIVE),
+ width: params.width ?? defaults.defaultSize,
+ height: params.height ?? defaults.defaultSize,
+ steps: params.steps ?? defaults.defaultSteps,
+ cfgScale: params.cfgScale ?? defaults.defaultCfg,
+ sampleMethod: isQwenImage21 ? 'euler' : defaults.sampler,
+ scheduler: isQwenImage21 ? 'default' : defaults.scheduler,
+ seed,
+ refImages: params.initImage
+ ? [(await fs.promises.readFile(params.initImage)).toString('base64')]
+ : undefined
+ },
+ onProgress ? (progress) => onProgress({ ...progress, secPerStep: 0 }) : undefined
+ )
generationLifecycle.throwIfCancelled()
await fs.promises.writeFile(outPath, png)
return {
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
- path: outPath, seed: usedSeed, model: base,
+ path: outPath,
+ seed: usedSeed,
+ model: base,
computeBackend: imageBackendForRuntime(process.platform, residentBinary)
}
} finally {
@@ -1147,15 +1160,18 @@ async function runImageGen(
// model's load spike — otherwise the brief overlap causes a short stutter.
try {
await generationLifecycle.waitForMemoryReclaim()
- const runNativeCli = (runtime: string): Promise =>
+ const runNativeCli = (runtime: string, cpuOnly = false, backend = 'cpu'): Promise =>
new Promise((resolve, reject) => {
// cwd at the binary dir so @executable_path rpath resolves libstable-diffusion.dylib.
const binDir = path.dirname(runtime)
- // Qwen needs the model on the L40S for CUDA inference. Keep CPU offload
- // for other runtimes, including a fallback after CUDA startup fails.
+ // Qwen keeps CPU offload unless the GPU is known to hold it (see qwenImageArgsForBackend).
const runtimeArgs = isQwenImage21
- ? qwenImageArgsForBackend(args, imageBackendForRuntime(process.platform, runtime))
- : args
+ ? qwenImageArgsForBackend(
+ [...args],
+ cpuOnly ? 'CPU' : imageBackendForRuntime(process.platform, runtime)
+ )
+ : [...args]
+ if (cpuOnly && backend !== 'cpu') runtimeArgs.push('--backend', 'cpu')
const child = spawn(runtime, runtimeArgs, {
cwd: binDir,
env: { ...process.env, ...sdRuntimeLibraryEnv(process.platform, runtime, process.env) }
@@ -1240,34 +1256,40 @@ async function runImageGen(
})
})
- const runtimes = coreml ? [cli] : findSdBinaries('sd-cli', getBackendPreference('image'))
+ const runtimes = coreml
+ ? [{ path: cli, backend: 'metal', cpuOnly: false }]
+ : await findSdBinaries('sd-cli')
let completedRuntime: string | undefined
+ let completedCpuOnly = false
let lastError: unknown
- for (const [index, runtime] of runtimes.entries()) {
+ for (const [index, step] of runtimes.entries()) {
+ const runtime = step.path
try {
await recordAIRequest(
{
modality: 'image',
source: 'Image runtime attempt',
model: path.basename(model),
- backend: imageBackendForRuntime(process.platform, runtime),
+ backend: step.cpuOnly ? 'CPU' : imageBackendForRuntime(process.platform, runtime),
request: params
},
- async () => runNativeCli(runtime)
+ async () => runNativeCli(runtime, step.cpuOnly, step.backend)
)
completedRuntime = runtime
+ completedCpuOnly = step.cpuOnly
break
} catch (error) {
lastError = error
const next = runtimes[index + 1]
if (!next || generationLifecycle.isCancelled()) throw error
console.warn(
- `[imagegen] ${imageBackendForRuntime(process.platform, runtime)} runtime failed; retrying with ${imageBackendForRuntime(process.platform, next)}`,
+ `[imagegen] ${imageBackendForRuntime(process.platform, runtime)} runtime failed; retrying with ${imageBackendForRuntime(process.platform, next.path)}`,
error
)
}
}
- if (!completedRuntime) throw lastError
+ if (!completedRuntime)
+ throw lastError ?? new Error('No image engine is installed for this choice.')
if (!fs.existsSync(outPath)) throw new Error('Image generation produced no output file.')
const b64 = fs.readFileSync(outPath).toString('base64')
@@ -1279,7 +1301,9 @@ async function runImageGen(
model: path.basename(model),
computeBackend: coreml
? 'Core ML (ANE)'
- : imageBackendForRuntime(process.platform, completedRuntime)
+ : completedCpuOnly
+ ? 'CPU'
+ : imageBackendForRuntime(process.platform, completedRuntime)
}
} finally {
generationLifecycle.finish()
diff --git a/src/main/imagegen/__tests__/args.test.ts b/src/main/imagegen/__tests__/args.test.ts
index cb5d613e2..d58e31a3b 100644
--- a/src/main/imagegen/__tests__/args.test.ts
+++ b/src/main/imagegen/__tests__/args.test.ts
@@ -139,11 +139,19 @@ describe('buildQwenImage21Args', () => {
expect(flagVal(args, '-r')).toBe('/in.png')
})
- it('keeps Qwen weights on CUDA and retains CPU offload for a fallback runtime', () => {
+ it('keeps Qwen CPU offload unless the CUDA device is known to hold the model', () => {
const args = buildQwenImage21Args(base)
- expect(qwenImageArgsForBackend(args, 'CUDA')).not.toContain('--offload-to-cpu')
+ expect(qwenImageArgsForBackend(args, 'CUDA')).toContain('--offload-to-cpu')
+ expect(
+ qwenImageArgsForBackend(args, 'CUDA', { freeVramBytes: 8 * 2 ** 30, needBytes: 20 * 2 ** 30 })
+ ).toContain('--offload-to-cpu')
+ expect(
+ qwenImageArgsForBackend(args, 'CUDA', {
+ freeVramBytes: 40 * 2 ** 30,
+ needBytes: 20 * 2 ** 30
+ })
+ ).not.toContain('--offload-to-cpu')
expect(qwenImageArgsForBackend(args, 'Vulkan')).toContain('--offload-to-cpu')
- expect(args).toContain('--offload-to-cpu')
})
})
diff --git a/src/main/imagegen/__tests__/sd-runtime.test.ts b/src/main/imagegen/__tests__/sd-runtime.test.ts
index b28694220..554a619b4 100644
--- a/src/main/imagegen/__tests__/sd-runtime.test.ts
+++ b/src/main/imagegen/__tests__/sd-runtime.test.ts
@@ -17,7 +17,10 @@ describe('sdRuntimeLibraryEnv', () => {
it('adds the shared CUDA runtime for the Linux CUDA image engine', () => {
expect(
sdRuntimeLibraryEnv('linux', '/app/bin/sd-cuda/sd-cli', { LD_LIBRARY_PATH: '/host/lib' })
- ).toEqual({ LD_LIBRARY_PATH: '/app/bin/sd-cuda:/app/bin/cuda-runtime:/host/lib' })
+ ).toEqual({
+ CUDA_CACHE_MAXSIZE: '4294967296',
+ LD_LIBRARY_PATH: '/app/bin/sd-cuda:/app/bin/cuda-runtime:/host/lib'
+ })
})
it('adds the shared CUDA runtime for the Windows CUDA image engine', () => {
@@ -26,6 +29,8 @@ describe('sdRuntimeLibraryEnv', () => {
PATH: 'C:\\Windows'
})
).toEqual({
+ CUDA_CACHE_MAXSIZE: '4294967296',
+ GGML_VK_DISABLE_BFLOAT16: '1',
PATH: 'C:\\app\\bin\\sd-cuda;C:\\app\\bin\\cuda-runtime;C:\\Windows'
})
})
@@ -35,6 +40,6 @@ describe('sdRuntimeLibraryEnv', () => {
sdRuntimeLibraryEnv('win32', 'C:\\app\\bin\\sd\\sd-cli.exe', {
PATH: 'C:\\Windows'
})
- ).toEqual({ PATH: 'C:\\app\\bin\\sd;C:\\Windows' })
+ ).toEqual({ GGML_VK_DISABLE_BFLOAT16: '1', PATH: 'C:\\app\\bin\\sd;C:\\Windows' })
})
})
diff --git a/src/main/imagegen/args.ts b/src/main/imagegen/args.ts
index 216fcb4ba..1e812e1c7 100644
--- a/src/main/imagegen/args.ts
+++ b/src/main/imagegen/args.ts
@@ -160,9 +160,20 @@ export function buildQwenImage21Args(i: QwenImage21ArgsInput): string[] {
return args
}
-/** Keep Qwen weights on the CUDA device; other runtimes can still offload. */
-export function qwenImageArgsForBackend(args: string[], backend: string): string[] {
- return backend === 'CUDA' ? args.filter((arg) => arg !== '--offload-to-cpu') : args
+/**
+ * Qwen-Image keeps `--offload-to-cpu` on every backend: without a VRAM reading per device, dropping
+ * it on CUDA only fits data-centre cards (it was tuned on an L40S) and runs a consumer card out of
+ * memory mid-image. `freeVramBytes`/`needBytes`, when both are known, allow the weights to stay on
+ * the GPU.
+ */
+export function qwenImageArgsForBackend(
+ args: string[],
+ backend: string,
+ vram?: { freeVramBytes: number; needBytes: number }
+): string[] {
+ return backend === 'CUDA' && vram && vram.freeVramBytes >= vram.needBytes
+ ? args.filter((arg) => arg !== '--offload-to-cpu')
+ : args
}
export interface StandardArgsInput {
diff --git a/src/main/imagegen/generated-image-share.ts b/src/main/imagegen/generated-image-share.ts
index f015e9097..17e038589 100644
--- a/src/main/imagegen/generated-image-share.ts
+++ b/src/main/imagegen/generated-image-share.ts
@@ -13,7 +13,7 @@ import { readGeneratedImageSidecar, writeGeneratedImageSidecar } from './gallery
* a generated image looks like on the wire is not. That decision lived here AND on the phone, and the
* two copies had already disagreed about the name of the model field.
*/
-export function describeOwnGeneratedImage(
+function describeOwnGeneratedImage(
imagePath: string,
shownIn?: ChatHome
): SharedFileDescriptor | null {
diff --git a/src/main/imagegen/sd-runtime.ts b/src/main/imagegen/sd-runtime.ts
index aab3b8bbc..160e8834c 100644
--- a/src/main/imagegen/sd-runtime.ts
+++ b/src/main/imagegen/sd-runtime.ts
@@ -1,9 +1,6 @@
-import fs from 'fs'
import path from 'path'
-import { binRoots, exe } from '../runtime-env'
import { nativeLibraryEnv } from '../native-library-env'
-import { hasLinuxNvidiaDriver } from '../transcription/whisper-runtime'
-import { prioritizeBackend, type BackendPreference } from '../../shared/backend-preferences'
+import { nativeLadder, type NativeStep } from '../backend-facts'
/** The backend selected by the native image binary that completed the run. */
export function imageBackendForRuntime(
@@ -16,43 +13,14 @@ export function imageBackendForRuntime(
return platform === 'darwin' ? 'Metal' : 'Vulkan'
}
-/** Windows needs the system Vulkan loader before the GPU image binary can start. */
-export function hasWindowsVulkanLoader(
- windowsDir = process.env.WINDIR ?? process.env.SystemRoot ?? 'C:\\Windows'
-): boolean {
- return fs.existsSync(path.join(windowsDir, 'System32', 'vulkan-1.dll'))
+export async function findSdBinaries(name: 'sd-cli' | 'sd-server'): Promise {
+ const steps = await nativeLadder('image', name)
+ if (!steps.length) throw new Error('No image engine is installed for this choice.')
+ return steps
}
-/**
- * Resolve an image runtime. Windows uses the compact Vulkan build when its loader
- * is installed, then uses the separately packaged CPU build. Linux and macOS keep
- * their existing `sd` runtime; the Linux Vulkan build contains CPU kernels too.
- */
-export function findSdBinaries(name: 'sd-cli' | 'sd-server', preference: BackendPreference = 'auto'): string[] {
- let directories =
- process.platform === 'linux' && hasLinuxNvidiaDriver()
- ? ['sd-cuda', 'sd', 'sd-cpu']
- : ['sd', 'sd-cpu']
- if (process.platform === 'win32') {
- directories = hasWindowsVulkanLoader()
- ? ['sd-cuda', 'sd', 'sd-cpu']
- : ['sd-cuda', 'sd-cpu', 'sd']
- }
- const matches: string[] = []
- for (const directory of directories) {
- for (const root of binRoots()) {
- const candidate = path.join(root, directory, exe(name))
- if (fs.existsSync(candidate) && !matches.includes(candidate)) matches.push(candidate)
- }
- }
- return prioritizeBackend(matches, preference, (binary) => {
- const backend = imageBackendForRuntime(process.platform, binary)
- return backend.toLowerCase() as BackendPreference
- })
-}
-
-export function findSdBinary(name: 'sd-cli' | 'sd-server'): string | null {
- return findSdBinaries(name)[0] ?? null
+export async function findSdBinary(name: 'sd-cli' | 'sd-server'): Promise {
+ return (await findSdBinaries(name))[0]?.path ?? null
}
/** CUDA image binaries reuse the CUDA libraries already packaged for llama.cpp. */
diff --git a/src/main/index.ts b/src/main/index.ts
index 597412e41..ff5b7c0d4 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -70,7 +70,14 @@ import {
import { PERSONAL_MESH_ENTITLEMENT_REVALIDATION_INTERVAL_MS } from '@offgrid/sync'
import { setupLicenseIpc } from './license-ipc'
import { nativeImage } from 'electron'
-import { purgeLegacyChatImports, getSetting, saveSetting, optimizeDB } from './database'
+import {
+ purgeLegacyChatImports,
+ getSetting,
+ saveSetting,
+ optimizeDB,
+ startIndexRebuilds,
+ stopIndexRebuilds
+} from './database'
import { modalityQueue } from './modality-queue/queue'
import { applyQueueConfig, readQueueConfig } from './modality-queue/config'
import { liveMemory, registerRuntime } from './runtime-manager'
@@ -91,12 +98,10 @@ import {
applicationShutdown,
commitApplicationRelaunch,
installApplicationShutdown,
- requestApplicationRelaunch,
registerCoreShutdownOwners
} from './shutdown'
import { shutdownRuntimes } from './runtime-manager'
import { shutdownModelDownloads } from './models/download-queue'
-import { prepareInstalledOnnxCudaLibraries } from './performance-pack'
// Before anything logs: a broken stdout/stderr pipe (parent/e2e-harness exited, closed pipe)
// must never crash main via an uncaught EPIPE. See stream-guards.ts.
@@ -367,12 +372,13 @@ function startServerOnlyMode(): void {
app.whenReady().then(async () => {
// A Team-mode gateway keeps the machine awake while it serves clients.
setGatewayPowerBlocker(powerSaveBlocker)
- // glibc reads LD_LIBRARY_PATH when Electron starts, before an ONNX worker runs.
- // Restart once with the verified optional libraries if this launch lacks them.
- if (app.isPackaged && prepareInstalledOnnxCudaLibraries()) {
- requestApplicationRelaunch(app)
- return
- }
+ // The NVIDIA driver version decides which CUDA engines may run; read it off the startup path.
+ void import('./nvidia-driver')
+ .then(async ({ refreshNvidiaDriverVersion }) => {
+ await refreshNvidiaDriverVersion()
+ ;(await import('./backend-facts')).invalidateBackendFacts()
+ })
+ .catch((error: unknown) => console.warn('[nvidia-driver] read failed', error))
const { cleanupMovedModelStorage } = await import('./model-storage-move')
const { modelsDir } = await import('./runtime-env')
restoreCanonicalProductName()
@@ -662,6 +668,15 @@ app.whenReady().then(async () => {
registerRuntime(ttsRuntime)
registerRuntime(imageRuntime)
registerRuntime(sttRuntime)
+ // An image job frees the task engines' memory; they start again on their next use.
+ modalityQueue.registerEvictable('grounding', {
+ evict: async () =>
+ (await import('./vision/grounder-runtime')).grounderRuntime.shutdown()
+ })
+ modalityQueue.registerEvictable('decision', {
+ evict: async () =>
+ (await import('./accessibility/decision-runtime')).decisionRuntime.shutdown()
+ })
})
}
},
@@ -697,7 +712,56 @@ app.whenReady().then(async () => {
lateEffect: 'keep',
run: async () => {
const { llm } = await import('./llm')
- registerRuntime(llm.runtime, { readChatBackend: () => llm.runtimeBackend() })
+ registerRuntime(llm.runtime)
+ const { installEngineResumeHealth, registerResumeHealthTarget } =
+ await import('./engine-resume-health')
+ registerResumeHealthTarget({
+ id: 'chat',
+ shouldBeRunning: () => llm.shouldBeRunning(),
+ healthCheck: () => llm.healthCheck(),
+ restart: () => llm.restartSameModel()
+ })
+ const [{ embeddings }, { ttsOnnxRuntime }, { grounderRuntime }, { decisionRuntime }] =
+ await Promise.all([
+ import('./embeddings'),
+ import('./tts'),
+ import('./vision/grounder-runtime'),
+ import('./accessibility/decision-runtime')
+ ])
+ for (const [id, runtime] of [
+ ['grounding', grounderRuntime],
+ ['decision', decisionRuntime]
+ ] as const) {
+ registerResumeHealthTarget({
+ id,
+ shouldBeRunning: () => runtime.shouldBeRunning(),
+ healthCheck: () => runtime.health(),
+ restart: () => runtime.restart()
+ })
+ }
+ // A helper that died while asleep restarts on its next request; restart a running one
+ // so it does not hold a lost GPU context.
+ for (const [id, helper] of [
+ ['search', embeddings],
+ ['voice', ttsOnnxRuntime]
+ ] as const) {
+ registerResumeHealthTarget({
+ id,
+ shouldBeRunning: () => helper.isRunning,
+ healthCheck: async () => !helper.isRunning,
+ restart: () => helper.restart()
+ })
+ }
+ const disposeResumeHealth = installEngineResumeHealth()
+ // Windows: test the pack's ONNX CUDA addon once per pack and driver, in a helper.
+ void import('./onnx-host/onnx-cuda-probe')
+ .then(async ({ probeOnnxCuda }) => {
+ if (!(await probeOnnxCuda())) return
+ ;(await import('./backend-facts')).invalidateBackendFacts()
+ await Promise.all([embeddings.restart(), ttsOnnxRuntime.restart()])
+ })
+ .catch((error: unknown) => console.warn('[ONNX] CUDA check failed to run', error))
+ app.once('will-quit', disposeResumeHealth)
applyQueueConfig(modalityQueue, readQueueConfig(getSetting))
// Models stay loaded while they fit live memory; the queue frees only what it must.
modalityQueue.setMemoryProbe(liveMemory)
@@ -731,6 +795,43 @@ app.whenReady().then(async () => {
void import('./documents').then((documents) => documents.collectDocumentGarbage())
}, 120_000).unref()
+ // Background index work, once start-up has settled, a short slice at a time: full-text indexes
+ // built by an older schema are rebuilt beside the old ones (which keep answering until each
+ // rebuild finishes), and rows that predate the memory-search triggers are queued for embedding.
+ const indexWork = setTimeout(() => {
+ void import('./records')
+ .then((records) => records.ensureRecordSchema())
+ .catch((error: unknown) => console.error('[search index] schema check failed', error))
+ .finally(startIndexRebuilds)
+ }, 15_000)
+ indexWork.unref()
+ // Embed the memory-search backlog (records queued by their tables' triggers): once shortly after
+ // launch, then every few minutes for what was added since. Core owns this, so builds without
+ // Pro index chats and people too.
+ const embedBacklog = (): void => {
+ void import('./records')
+ .then((records) =>
+ records.embedRecordBacklog((progress) => {
+ if (progress.remaining % 200 === 0)
+ console.log(`[search index] ${progress.done} embedded, ${progress.remaining} left`)
+ })
+ )
+ .catch((error: unknown) => console.error('[search index]', error))
+ }
+ const firstEmbed = setTimeout(embedBacklog, 20_000)
+ firstEmbed.unref()
+ const embedTimer = setInterval(embedBacklog, 5 * 60_000)
+ embedTimer.unref()
+ applicationShutdown.register({
+ name: 'core:background-indexing',
+ shutdown: () => {
+ clearTimeout(indexWork)
+ clearTimeout(firstEmbed)
+ clearInterval(embedTimer)
+ stopIndexRebuilds()
+ }
+ })
+
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) void createWindow()
})
diff --git a/src/main/input/actuation.ts b/src/main/input/actuation.ts
index be8a51ee7..22fdd893f 100644
--- a/src/main/input/actuation.ts
+++ b/src/main/input/actuation.ts
@@ -171,7 +171,3 @@ function keyCodes(keys: readonly string[], keyMap: Record): numb
return code
})
}
-
-export function actuationAvailable(): boolean {
- return loadActuation() !== null
-}
diff --git a/src/main/ipc-query-logic.ts b/src/main/ipc-query-logic.ts
deleted file mode 100644
index 190134194..000000000
--- a/src/main/ipc-query-logic.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { escapeFtsMatch } from './fts-match'
-
-// Pure query/message helpers extracted from ipc.ts so the retrieval-gating logic
-// is unit-testable without Electron / the DB (mirrors search-ranking.ts,
-// model-sizing.ts). No side effects. ipc.ts re-imports these; the
-// ipcMain.handle registrations stay in ipc.ts. Behaviour-neutral move.
-
-/** Parse a model/LLM JSON reply into T, tolerating ```json fences; falls back on
- * any parse error so a malformed reply never throws into the caller. */
-export function safeParseJson(input: string, fallback: T): T {
- try {
- const clean = input.replace(/```json\n?|\n?```/g, '').trim()
- return JSON.parse(clean) as T
- } catch {
- return fallback
- }
-}
-
-/** Stopwords dropped from a tokenised query (single source of truth). */
-export const STOPWORDS = new Set([
- 'the',
- 'and',
- 'for',
- 'with',
- 'from',
- 'that',
- 'this',
- 'what',
- 'know',
- 'about',
- 'your',
- 'you',
- 'me',
- 'my',
- 'all',
- 'do',
- 'are',
- 'was',
- 'were',
- 'been',
- 'being',
- 'have',
- 'has',
- 'had',
- 'will',
- 'would',
- 'should',
- 'could',
- 'can',
- 'may',
- 'might'
-])
-
-/** Extract lowercase Unicode words (keep internal hyphens/underscores),
- * drop tokens < 3 chars and STOPWORDS, de-dup, cap at maxTokens. */
-export function tokenizeQuery(query: string, maxTokens: number = 6): string[] {
- const tokens = (query.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}\p{M}_-]*/gu) ?? [])
- .filter((t) => t.length >= 3)
- .filter((t) => !STOPWORDS.has(t))
- return Array.from(new Set(tokens)).slice(0, maxTokens)
-}
-
-/** Any-term recall with the existing stopword filter, using the shared FTS escape. */
-export function ftsMatchExpression(query: string, maxTokens: number = 6): string {
- const tokens = tokenizeQuery(query, maxTokens)
- return escapeFtsMatch(tokens.length ? tokens : query, { maxTokens })
-}
-
-/** Clip text to maxLength, replacing the final char with an ellipsis when it
- * overflows. Empty/undefined text → ''. */
-export function clipText(text: string, maxLength: number): string {
- if (!text) return ''
- if (text.length <= maxLength) return text
- return text.slice(0, Math.max(0, maxLength - 1)) + '…'
-}
-
-// Build/generate requests ("build a react app", "write an svg", "make a landing
-// page") don't benefit from memory retrieval — pulling in unrelated SOURCES makes
-// the model cite junk and second-guess itself. Detect them so we can answer with
-// the artifact instructions only and skip the search.
-export function isGenerativeRequest(text: string): boolean {
- const q = (text || '').trim().toLowerCase()
- if (!q) return false
- const hasNoun =
- /\b(react|next\.?js|vue|svelte|html|css|svg|website|web ?app|web ?page|landing page|component|widget|diagram|chart|flowchart|mermaid|game|canvas|prototype|mock-?up|ui|app|script|function|snippet|webpage|playground|frontend|front-end|dashboard|form|interface|page|tool|visualization|visualisation|simulator|editor|viewer|demo|site)\b/.test(
- q
- )
- const hasVerb =
- /\b(build|create|make|write|generate|code|implement|design|draw|render|scaffold|give me a|show me a)\b/.test(
- q
- )
- return hasNoun && hasVerb
-}
-
-/**
- * The ` LIKE ?` fragment + its bound param for an optional app-name
- * filter, or null when no filter applies ('All' / empty = every app). Callers add
- * their own connector (WHERE / AND). Single source for the appName gate that was
- * inlined 4× across db:get-memories and the rag:chat vector / FTS-fallback /
- * message queries (each with a different column, same guard + `%…%` wildcarding).
- */
-export function appNameLikeClause(
- appName: string | undefined,
- column: string
-): { clause: string; param: string } | null {
- if (!appName || appName === 'All') {
- return null
- }
- return { clause: `${column} LIKE ?`, param: `%${appName}%` }
-}
-
-/** A short pleasantry/acknowledgement ("hi", "ok", "thanks") — or empty — that
- * shouldn't trigger memory extraction. Real messages return false. */
-export function isTrivialMessage(text: string): boolean {
- const normalized = (text || '').trim()
- if (normalized.length === 0) return true
- if (normalized.length < 20) {
- if (
- /^(hi|hello|hey|thanks|thank you|ok|okay|sure|yes|no|cool|great|nice|good|fine|bye|see ya|yep|nope)[!.]?$/i.test(
- normalized
- )
- ) {
- return true
- }
- }
- return false
-}
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 3bda77dbc..b0df66c14 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -1,3 +1,4 @@
+import { backendChoicesForUi } from './backend-facts'
import { settingsChanged } from './settings-version'
import { withStorageDownload, storageDownloadsActive } from './model-storage-guard'
import { moveModelStorage } from './model-storage-move'
@@ -12,16 +13,7 @@ import {
getChatSessions,
upsertChatSummary,
getMemoriesForSession,
- getMemoryRecordsForSession,
- getMasterMemory,
- addEntityFact,
- updateEntitySummary,
- getEntities,
- getEntityDetails,
- upsertEntitySession,
- deleteMemory,
getEntitiesForSession,
- getDashboardStats,
getUserProfile,
saveUserProfile,
UserProfile,
@@ -37,12 +29,11 @@ import {
saveSetting,
getSetting
} from './database'
-import { deleteEntityById, resolveEntityCandidate } from './entity-domain'
+import { deleteEntityById } from './entity-domain'
import { setComputerUseSettings } from './computer-use-settings'
import { COMPUTER_USE_SETTINGS_KEY } from '../shared/computer-use-settings'
import { setWebUseSettings } from './web-use-settings'
import { WEB_USE_SETTINGS_KEY } from '../shared/web-use-settings'
-import { embeddings } from './embeddings'
import {
getResidency,
setResidencyMode,
@@ -66,18 +57,11 @@ import { getAllPromptDefs } from './prompts'
import { getPrompt, getPromptTemplate, resetPrompt } from './prompt-store'
import { setupTtsIpc } from './tts-ipc'
import { setupVoiceTranscriptionIpc } from './voice-transcription-ipc'
-import {
- safeParseJson,
- ftsMatchExpression,
- clipText,
- isGenerativeRequest,
- isTrivialMessage,
- appNameLikeClause
-} from './ipc-query-logic'
import { requestApplicationRelaunch } from './shutdown'
import {
activateInstalledPerformancePack,
onPerformancePackChanged,
+ onPerformancePackInstalled,
pausePerformancePack,
performancePackStatus,
startPerformancePack
@@ -87,28 +71,6 @@ import { sampleProgressRate, type ProgressRateSample } from '@offgrid/ui'
import { notifyRagConversationChanged } from './rag-conversation-events'
// import { llm } from './llm'; // Moved to dynamic import to support ESM
-// Incrementally update master memory with a new conversation summary
-// This approach keeps context bounded by only processing current master + new summary
-async function updateMasterMemoryIncremental(_newSummary: string): Promise {
- // Master memory (the consolidated "profile") is a retired My Memories feature —
- // no longer injected into chat. Don't regenerate it so it stays cleared.
- return null
-}
-
-// Full regeneration using map-reduce:
-// Phase 1 (map): Split summaries into chunks, generate a partial summary for each
-// Phase 2 (reduce): Merge all partial summaries into the final master memory
-// This avoids the growing-prompt problem and minimizes LLM calls
-async function regenerateMasterMemoryFull(): Promise {
- // Retired feature — see updateMasterMemoryIncremental. Don't rebuild the profile.
- return null
-}
-
-// Main entry point - full regeneration
-async function regenerateMasterMemory(): Promise {
- return regenerateMasterMemoryFull()
-}
-
// Generate the answer for a rag:chat turn. When a streamId + sender are present,
// stream tokens/reasoning to the renderer over the 'rag:stream' channel as they
// arrive (inline chain-of-thought); otherwise fall back to a single blocking call.
@@ -133,6 +95,30 @@ import { runChatWithFallback } from './chat-fallback'
const streamControllers = new Map()
+function deviceFallbackNotices(
+ sender: { send: (channel: string, payload: unknown) => void } | undefined,
+ streamId: string | undefined
+): Pick<
+ NonNullable[1]>,
+ 'onDeviceFallback' | 'onRestarting'
+> {
+ if (!sender || !streamId) return {}
+ const send = (step: unknown): void => {
+ try {
+ sender.send('rag:stream', { streamId, type: 'step', step })
+ } catch {
+ /* window gone */
+ }
+ }
+ return {
+ onDeviceFallback: (message) => send({ kind: 'device_fallback', message }),
+ onRestarting: (restarting) => {
+ if (restarting) resetChatStreamPartial(streamId)
+ send({ kind: restarting ? 'restarting_model' : 'waiting' })
+ }
+ }
+}
+
async function streamAnswer(
event: { sender?: { send: (channel: string, payload: unknown) => void } } | undefined,
streamId: string | undefined,
@@ -152,8 +138,9 @@ async function streamAnswer(
// evicts nothing (evicting 'llm' would evict itself). run()'s finally releases
// the slot even if fn throws, so we let errors propagate from inside.
return modalityQueue.run(CHAT_JOB, async () => {
- const { result, modelName } = await runChatWithFallback(() =>
- llm.chatStream(prompt, images, () => {}, { thinking })
+ const { result, modelName } = await runChatWithFallback(
+ () => llm.chatStream(prompt, images, () => {}, { thinking }),
+ deviceFallbackNotices(event?.sender, streamId)
)
const response = toResponseGenerationResult(result)
return modelName ? { ...response, metrics: { ...response.metrics, modelName } } : response
@@ -188,6 +175,7 @@ async function streamAnswer(
),
{
signal: controller.signal,
+ ...deviceFallbackNotices(sender, streamId),
onModelChanged: (failed, next) => {
resetChatStreamPartial(streamId)
try {
@@ -216,286 +204,11 @@ async function streamAnswer(
}
}
-type ChatIntent = { intent: 'build' | 'image' | 'chat'; urls: string[] }
-
-const INTENT_SCHEMA = {
- type: 'object',
- properties: {
- intent: { type: 'string', enum: ['build', 'image', 'chat'] },
- urls: { type: 'array', items: { type: 'string' } }
- },
- required: ['intent', 'urls'],
- additionalProperties: false
-}
-
-// Decide the output format for a turn with the model itself (grammar-constrained
-// JSON), instead of brittle keyword matching: build (runnable artifact), image
-// (generate a picture), or chat. Also pulls out any URLs the user wants read.
-// Falls back to the keyword heuristic if the classifier call fails.
-async function classifyIntent(
- query: string,
- history?: { role: string; content: string }[],
- streamId?: string
-): Promise {
- const regexUrls = (query.match(/https?:\/\/[^\s)<>"']+/g) || []).slice(0, 3)
- // Register a controller for this pre-stream classify so a rag:cancel during the
- // "Searching your memory…" window actually aborts the model call (D11) — without
- // it the classify ran to completion after Stop, holding the model + the next turn.
- const controller = streamId ? new AbortController() : undefined
- if (streamId && controller) streamControllers.set(streamId, controller)
- try {
- const { llm } = await import('./llm')
- const hist = (history ?? [])
- .slice(-4)
- .map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${clipText(m.content, 200)}`)
- .join('\n')
- const prompt = [
- 'You route a request for an on-device assistant. Decide the OUTPUT FORMAT:',
- '- "build": the user wants runnable code / a UI created — an app, component, page, playground, dashboard, form, diagram, chart, visualization, game, etc. (rendered live in a canvas).',
- '- "image": the user wants a picture/photo/logo/illustration/art generated.',
- '- "chat": anything else — questions, explanations, writing, discussion.',
- 'Also list any http(s) URLs the user wants you to read or build from.',
- 'Reply with ONLY JSON: {"intent":"build|image|chat","urls":[]}.',
- hist ? `Recent conversation:\n${hist}` : '',
- `User: ${query}`
- ]
- .filter(Boolean)
- .join('\n\n')
- const raw = await llm.chat(prompt, [], undefined, 200, {
- disableThinking: true,
- responseFormat: {
- type: 'json_schema',
- json_schema: { name: 'intent', schema: INTENT_SCHEMA, strict: true }
- },
- signal: controller?.signal
- })
- const j = JSON.parse(raw) as Partial
- const intent = j.intent === 'build' || j.intent === 'image' ? j.intent : 'chat'
- const urls = Array.isArray(j.urls) ? j.urls.filter((u) => /^https?:\/\//i.test(u)) : []
- return { intent, urls: urls.length ? urls.slice(0, 3) : regexUrls }
- } catch (e) {
- console.warn('[intent] classifier failed, falling back to heuristic', (e as Error).message)
- return { intent: isGenerativeRequest(query) ? 'build' : 'chat', urls: regexUrls }
- } finally {
- // Only remove OUR entry — streamAnswer re-registers its own controller under
- // the same streamId for the streaming phase.
- if (streamId && controller && streamControllers.get(streamId) === controller)
- streamControllers.delete(streamId)
- }
-}
-
-async function insertMemoryRecord(params: {
- content: string
- name?: string | null
- rawText?: string | null
- sourceApp?: string | null
- sessionId?: string | null
- messageId?: number | null
-}): Promise {
- const db = getDB()
- const content = (params.content || '').trim()
- if (!content) return null
-
- if (params.messageId) {
- const existing = db
- .prepare('SELECT id FROM memories WHERE message_id = ? LIMIT 1')
- .get(params.messageId) as { id: number } | undefined
- if (existing?.id) return existing.id
- }
-
- if (params.sessionId) {
- const existing = db
- .prepare('SELECT id FROM memories WHERE session_id = ? AND content = ? LIMIT 1')
- .get(params.sessionId, content) as { id: number } | undefined
- if (existing?.id) return existing.id
- }
-
- let vectorJson = '[]'
- try {
- const vector = await embeddings.generateEmbedding(content)
- vectorJson = JSON.stringify(vector)
- } catch (e) {
- console.error('Failed to generate embedding for memory record:', e)
- }
-
- const stmt = db.prepare(
- 'INSERT INTO memories (content, name, raw_text, source_app, session_id, embedding, message_id) VALUES (?, ?, ?, ?, ?, ?, ?)'
- )
- const info = stmt.run(
- content,
- params.name || null,
- params.rawText || null,
- params.sourceApp || null,
- params.sessionId || null,
- vectorJson,
- params.messageId || null
- )
- const memoryId = Number(info.lastInsertRowid || 0) || null
-
- // Send notification about new memory
- if (memoryId) {
- BrowserWindow.getAllWindows().forEach((win) => {
- win.webContents.send('notification:new-memory', {
- sessionId: params.sessionId || null,
- memoryContent: content.slice(0, 100) + (content.length > 100 ? '...' : '')
- })
- })
- }
-
- return memoryId
-}
-
-export async function evaluateAndStoreMemoryForMessage(params: {
- sessionId: string
- appName: string
- role: string
- content: string
- messageId?: number | null
-}): Promise {
- const role = (params.role || 'unknown').toLowerCase()
- const text = (params.content || '').trim()
- if (!text || isTrivialMessage(text)) return
-
- // Get strictness setting
- const strictness = getSetting<'lenient' | 'balanced' | 'strict'>('memoryStrictness', 'balanced')
-
- const prompt = getPrompt(`memoryFilter.${strictness}`, { ROLE: role, MESSAGE: text })
-
- // Minimum content length filter: skip very short messages
- if (role === 'user' && text.length < 30) return
- if (role === 'assistant' && text.length < 50) return
-
- try {
- const { llm } = await import('./llm')
- const response = await llm.chat(prompt)
- const parsed = safeParseJson<{ store: boolean; name?: string; memory?: string }>(response, {
- store: false
- })
- if (!parsed.store) return
- const memoryText = (parsed.memory || '').trim()
- const memoryName = (parsed.name || '').trim() || null
- if (!memoryText) return
- if (memoryText.split(/\s+/).length < 4) return
- if (memoryText.length > 280) return
-
- // Post-LLM filter: skip memories matching generic / low-value patterns
- const genericPatterns = [
- /^the user (asked|said|mentioned|wanted|is|was|has|had)\b/i,
- /^(this|that|it) (is|was|seems|appears|looks)\b/i,
- /^(a|an|the) (good|great|nice|common|typical|standard|normal)\b/i,
- /\b(in general|generally speaking|as usual|as always)\b/i
- ]
- if (genericPatterns.some((p) => p.test(memoryText))) return
-
- // Post-LLM filter: skip near-duplicates via substring check against existing session memories
- if (params.sessionId) {
- const existingMemories = getMemoryRecordsForSession(params.sessionId)
- const memLower = memoryText.toLowerCase()
- const isDuplicate = existingMemories.some((m) => {
- const existing = (m.content || '').toLowerCase()
- return existing === memLower || existing.includes(memLower) || memLower.includes(existing)
- })
- if (isDuplicate) return
- }
-
- await insertMemoryRecord({
- content: memoryText,
- name: memoryName,
- rawText: text,
- sourceApp: params.appName,
- sessionId: params.sessionId,
- messageId: params.messageId || null
- })
- } catch (e) {
- console.error('[IPC] Memory evaluation failed:', e)
- }
-}
-
-async function extractEntitiesForSession(sessionId: string): Promise {
- const memories = getMemoryRecordsForSession(sessionId)
- if (memories.length === 0) return
-
- // Get strictness setting
- const strictness = getSetting<'lenient' | 'balanced' | 'strict'>('entityStrictness', 'balanced')
-
- const memoryText = memories.map((m) => `- ${m.content}`).join('\n')
-
- const prompt = getPrompt(`entityExtraction.${strictness}`, { MEMORY_TEXT: memoryText })
-
- try {
- const { llm } = await import('./llm')
- const response = await llm.chat(prompt)
- const parsed = safeParseJson<{ entities: { name: string; type?: string; facts?: string[] }[] }>(
- response,
- { entities: [] }
- )
-
- if (parsed.entities.length === 0) return
-
- for (const entity of parsed.entities) {
- const name = (entity.name || '').trim()
- if (!name) continue
- const type = (entity.type || 'Unknown').trim() || 'Unknown'
- const facts = Array.isArray(entity.facts)
- ? entity.facts
- .filter(Boolean)
- .map((f) => f.trim())
- .filter(Boolean)
- : []
-
- if (facts.length === 0) continue
-
- const resolution = resolveEntityCandidate({ name, type })
- if (!resolution.admitted) continue
- const entityId = resolution.entityId
- if (!entityId) continue
- upsertEntitySession(entityId, sessionId)
-
- const newFacts: string[] = []
- for (const fact of facts) {
- const inserted = addEntityFact(entityId, fact, sessionId)
- if (inserted) newFacts.push(fact)
- }
-
- // Send notification about new entity with facts
- if (newFacts.length > 0) {
- BrowserWindow.getAllWindows().forEach((win) => {
- win.webContents.send('notification:new-entity', {
- entityId,
- entityName: name,
- entityType: type,
- factsCount: newFacts.length
- })
- })
- }
-
- if (newFacts.length === 0) continue
-
- const details = getEntityDetails(entityId) as { entity?: { summary?: string } } | null
- const existingSummary = details?.entity?.summary || ''
-
- const summaryPrompt = getPrompt('entitySummary', {
- NAME: name,
- TYPE: type,
- EXISTING_SUMMARY: existingSummary || '(none)',
- NEW_FACTS: '- ' + newFacts.join('\n- ')
- })
-
- try {
- const updatedSummary = await llm.chat(summaryPrompt)
- if (updatedSummary && updatedSummary.trim()) {
- updateEntitySummary(entityId, updatedSummary.trim())
- }
- } catch (e) {
- console.error('[IPC] Failed to update entity summary:', e)
- }
- }
- } catch (e) {
- console.error('[IPC] Entity extraction failed:', e)
- }
-}
-
-export async function summarizeSession(sessionId: string): Promise {
+// A legacy chat session's summary (chat_summaries, searched as 'summary' records). The legacy
+// memories and entity facts that used to be extracted alongside it are retired: nothing writes or
+// reads them, and their rows stay until the user deletes their data. Live memory is screen moments,
+// entities, to-dos, meetings and chats.
+async function summarizeSession(sessionId: string): Promise {
const memories = getMemoriesForSession(sessionId)
if (memories.length === 0) return null
@@ -506,21 +219,6 @@ export async function summarizeSession(sessionId: string): Promise {
+ // Voice and search helpers restart onto the pack (Windows tests its ONNX CUDA addon first),
+ // and auto-managed settings move onto the CUDA engine.
+ void (async () => {
+ const [{ invalidateBackendFacts }, { probeOnnxCuda }, { embeddings }, { ttsOnnxRuntime }] =
+ await Promise.all([
+ import('./backend-facts'),
+ import('./onnx-host/onnx-cuda-probe'),
+ import('./embeddings'),
+ import('./tts')
+ ])
+ invalidateBackendFacts()
+ await probeOnnxCuda()
+ invalidateBackendFacts()
+ await Promise.all([embeddings.restart(), ttsOnnxRuntime.restart()])
+ })().catch((error: unknown) => console.warn('[performance-pack] helper restart failed', error))
+ void import('./best-settings')
+ .then((m) => {
+ m.forgetDetectedMachine()
+ return m.ensureBestSettings()
+ })
+ .catch((error: unknown) => console.warn('[best-settings] CUDA refresh failed', error))
+ })
ipcMain.handle('performance-pack:status', performancePackStatus)
ipcMain.handle('performance-pack:start', startPerformancePack)
ipcMain.handle('performance-pack:pause', pausePerformancePack)
- ipcMain.handle('performance-pack:restart', () => requestApplicationRelaunch(app))
// Engines published between releases install in the background unless the user turns it off.
engineUpdater.onChanged((status) => {
BrowserWindow.getAllWindows().forEach((window) =>
@@ -559,7 +279,7 @@ export function setupIPC(): void {
setupAIRequestLogIPC((ref) => nameModel?.(ref) ?? modelDisplayName(ref) ?? ref)
setupChatNextActionIpc()
setupVoiceTranscriptionIpc()
- const db = getDB()
+ getDB()
setupTtsIpc()
setupSystemStatusIpc(ipcMain, {
publish: (health) =>
@@ -568,102 +288,6 @@ export function setupIPC(): void {
)
})
- ipcMain.handle('db:get-memories', (_, limit: number = 50, appName?: string) => {
- let query = 'SELECT * FROM memories '
- const params: unknown[] = []
-
- const memFilter = appNameLikeClause(appName, 'source_app')
- if (memFilter) {
- query += `WHERE ${memFilter.clause} `
- params.push(memFilter.param)
- }
-
- query += 'ORDER BY created_at DESC LIMIT ?'
- params.push(limit)
-
- const stmt = db.prepare(query)
- // SQLite stores timestamps as UTC strings "YYYY-MM-DD HH:MM:SS" by default with CURRENT_TIMESTAMP
- // To ensure JS treats them as UTC, we might need to append 'Z' or standardise.
- // However, simplest is to let frontend handle "UTC" assumption.
- return stmt.all(...params)
- })
-
- ipcMain.handle(
- 'db:add-memory',
- async (_, content: string, source: string = 'user-input', sessionId?: string) => {
- // Generate embedding
- let vectorJson = '[]'
- try {
- const vector = await embeddings.generateEmbedding(content)
- vectorJson = JSON.stringify(vector)
- } catch (e) {
- console.error('Failed to generate embedding:', e)
- }
-
- // Check if we can update an existing session
- if (sessionId) {
- // Look for a recent memory (e.g. last 12 hours) with this session_id
- const existing = db
- .prepare(
- 'SELECT id FROM memories WHERE session_id = ? AND created_at > datetime("now", "-12 hours") ORDER BY id DESC LIMIT 1'
- )
- .get(sessionId) as { id: number } | undefined
-
- if (existing) {
- console.log(`Updating existing memory session ${sessionId} (ID: ${existing.id})`)
- const stmt = db.prepare(
- 'UPDATE memories SET content = ?, embedding = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?'
- )
- stmt.run(content, vectorJson, existing.id)
- return { id: existing.id, updated: true }
- }
- }
-
- const stmt = db.prepare(
- 'INSERT INTO memories (content, source_app, session_id, embedding) VALUES (?, ?, ?, ?)'
- )
- const info = stmt.run(content, source, sessionId || null, vectorJson)
- return { id: info.lastInsertRowid }
- }
- )
-
- ipcMain.handle('db:search-memories', async (_, query: string) => {
- try {
- const queryVector = await embeddings.generateEmbedding(query, { interactive: true })
- const vecStr = JSON.stringify(queryVector)
-
- const stmt = db.prepare(`
- SELECT *, cosine_similarity(embedding, ?) as score
- FROM memories
- WHERE embedding IS NOT NULL AND embedding != '[]'
- ORDER BY score DESC
- LIMIT 20
- `)
- return stmt.all(vecStr)
- } catch (e) {
- console.error('Vector search failed, falling back to FTS', e)
- const match = ftsMatchExpression(query)
- if (!match) return []
- const stmt = db.prepare(`
- SELECT memories.*
- FROM memories
- JOIN memory_fts ON memories.id = memory_fts.rowid
- WHERE memory_fts MATCH ?
- LIMIT 20
- `)
- return stmt.all(match)
- }
- })
-
- ipcMain.handle('db:get-stats', () => {
- const count = db.prepare('SELECT COUNT(*) as count FROM memories').get()
- return count
- })
-
- ipcMain.handle('db:get-dashboard-stats', () => {
- return getDashboardStats()
- })
-
ipcMain.handle('llm:extract', async (_, text: string) => {
try {
const { llm } = await import('./llm')
@@ -692,403 +316,38 @@ export function setupIPC(): void {
streamControllers.get(streamId)?.abort()
})
+ // A plain on-device model turn: no memory retrieval and no routing. Memory, tools and artifacts
+ // are the chat tools loop's (tools.ts); this channel is the direct call the preset setup form
+ // uses to fill a field. The memory arguments of the old contract are accepted and ignored.
ipcMain.handle(
'rag:chat',
async (
event,
query: string,
- appName?: string,
+ _appName?: string,
conversationHistory?: { role: string; content: string }[],
- projectId?: string | null,
+ _projectId?: string | null,
conversationId?: string,
- noMemory?: boolean,
+ _noMemory?: boolean,
streamId?: string,
thinking?: boolean,
images?: string[]
) => {
- const imgs = images || []
- // Before the classifier, so the whole turn - including its thinking - is attributable to the
- // conversation it belongs to.
bindChatStream(streamId, conversationId, thinking ? 'thinking' : 'waiting')
- // Intelligence layer: a grammar-constrained classifier picks the output
- // format (build / image / chat) and extracts URLs to read — replacing the
- // brittle keyword gate. Skip it in project mode (that path is its own thing).
- const { intent, urls: intentUrls } = projectId
- ? { intent: 'chat' as const, urls: [] as string[] }
- : await classifyIntent(query, conversationHistory, streamId)
-
- // Image request → have the model write a vivid prompt, then the renderer
- // generates it (it already detects an ```image block).
- if (intent === 'image') {
- const imgPrompt = `Write ONE vivid, detailed image-generation prompt (visual description only, no preamble) for this request:\n${query}`
- const desc = (
- await (
- await import('./llm')
- ).llm.chat(imgPrompt, [], undefined, 200, { disableThinking: true })
- )
- .trim()
- .replace(/^["']|["']$/g, '')
- return { answer: '```image\n' + (desc || query) + '\n```', context: undefined }
- }
-
- // Build request → artifact prompt (even in No-memory mode), with any URLs
- // fetched for us so the small model never has to chain tools.
- if (intent === 'build') {
- let historyBlock = ''
- if (conversationHistory && conversationHistory.length > 0) {
- const historyLines = conversationHistory
- .map(
- (msg) =>
- `${msg.role === 'user' ? 'User' : 'Assistant'}: ${clipText(msg.content, 400)}`
- )
- .join('\n')
- historyBlock = `Conversation so far:\n${historyLines}`
- }
- // read_url → build: fetch the classifier's URLs (deterministic).
- let referenceBlock = ''
- const urls = intentUrls
- if (urls.length) {
- if (streamId)
- event.sender.send('rag:stream', {
- streamId,
- type: 'step',
- step: { kind: 'reading', counts: { urls: urls.length } }
- })
- const { readUrlText } = await import('./tools')
- const parts: string[] = []
- for (const u of urls) {
- try {
- parts.push(
- `--- Content fetched from ${u} ---\n${clipText(await readUrlText(u), 5000)}`
- )
- } catch (e) {
- parts.push(`--- Could not fetch ${u}: ${(e as Error).message} ---`)
- }
- }
- referenceBlock = `REFERENCE — the user pointed you at these page(s); BUILD using this content (e.g. if it's API docs, build a UI that actually calls those endpoints):\n${parts.join('\n\n')}`
- }
- const prompt = [
- 'You are Off Grid AI, an on-device assistant with a LIVE, sandboxed code canvas built in.',
- 'The user wants you to BUILD something. Output the FINISHED, self-contained code as ONE fenced block — it runs immediately in the canvas beside the chat:',
- '- React app/component -> ```jsx — write idiomatic React (you may `import React, { useState } from "react"` and `export default function App() {…}`; the sandbox handles imports/exports). Define the main component as `App` or a default export.',
- '- a plain web page / interactive UI (no React) -> ```html — one complete document, inline all CSS and JS.',
- '- a diagram -> ```mermaid. a static graphic -> ```svg.',
- 'You DO have a real execution sandbox — do NOT say "since I am on-device" or "copy this into a new project", do NOT give npm/Vite/Create-React-App setup steps, and do NOT split it into src/App.js + src/App.css instructions. Just write ONE runnable code block. At most one short sentence before it.',
- referenceBlock,
- historyBlock,
- `User: ${query}`,
- 'Assistant:'
- ]
- .filter(Boolean)
- .join('\n\n')
- const completion = await streamAnswer(event, streamId, prompt, thinking, imgs)
- return { ...completion, context: undefined }
- }
-
- // No-memory mode: a plain on-device assistant — no retrieval at all.
- if (noMemory) {
- const { llm } = await import('./llm')
- const hist = (conversationHistory ?? [])
- .slice(-10)
- .map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${m.content}`)
- .join('\n')
- const prompt = [
- 'You are Off Grid AI, a private, on-device assistant.',
- 'You can generate images on-device. If (and only if) the user is asking for a picture/image/logo/art to be CREATED, respond with ONLY a fenced block ```image\\n\\n``` and nothing else. For everything else, answer normally in text.',
- hist ? `Conversation so far:\n${hist}` : '',
- `User: ${query}`,
- 'Assistant:'
- ]
- .filter(Boolean)
- .join('\n\n')
- void llm // retained for non-stream fallback inside streamAnswer
- const completion = await streamAnswer(event, streamId, prompt, thinking, imgs)
- return { ...completion, context: undefined }
- }
-
- if (streamId)
- event.sender.send('rag:stream', { streamId, type: 'step', step: { kind: 'searching' } })
- const db = getDB()
- // Quote each token as an FTS5 phrase (via the shared safe builder) so a hyphenated word like
- // "best-reviewed" can't reach MATCH as invalid syntax and throw "no such column: reviewed",
- // which failed the whole retrieval. Preserves the any-term (OR) recall the retrieval expects.
- const ftsQuery = ftsMatchExpression(query)
-
- let memories: Array<{
- source_app: string | null
- created_at: string
- content: string
- score?: number
- }> = []
- try {
- const queryVector = await embeddings.generateEmbedding(query, { interactive: true })
- const vecStr = JSON.stringify(queryVector)
- const params: unknown[] = [vecStr]
- let memoryQuery = `
- SELECT *, cosine_similarity(embedding, ?) as score
- FROM memories
- WHERE embedding IS NOT NULL AND embedding != '[]'
- `
- const vecFilter = appNameLikeClause(appName, 'source_app')
- if (vecFilter) {
- memoryQuery += ` AND ${vecFilter.clause} `
- params.push(vecFilter.param)
- }
- memoryQuery += ` ORDER BY score DESC LIMIT 12`
- memories = db.prepare(memoryQuery).all(...params)
- memories = memories.filter((m) => typeof m.score !== 'number' || m.score >= 0.2)
- } catch (e) {
- console.error('[RAG] Vector search failed, falling back to FTS', e)
- const params: unknown[] = []
- let fallbackQuery = `
- SELECT memories.*
- FROM memories
- JOIN memory_fts ON memories.id = memory_fts.rowid
- WHERE memory_fts MATCH ?
- `
- params.push(ftsQuery)
- const ftsFilter = appNameLikeClause(appName, 'memories.source_app')
- if (ftsFilter) {
- fallbackQuery += ` AND ${ftsFilter.clause} `
- params.push(ftsFilter.param)
- }
- fallbackQuery += ` LIMIT 12`
- memories = ftsQuery
- ? db.prepare(fallbackQuery).all(...params)
- : []
- }
-
- const messageParams: unknown[] = [ftsQuery]
- let messageQuery = `
- SELECT m.id, m.conversation_id, m.role, m.content, m.created_at, c.title, c.app_name,
- bm25(message_fts) as score
- FROM message_fts
- JOIN messages m ON message_fts.rowid = m.id
- JOIN conversations c ON c.id = m.conversation_id
- WHERE message_fts MATCH ?
- `
- const msgFilter = appNameLikeClause(appName, 'c.app_name')
- if (msgFilter) {
- messageQuery += ` AND ${msgFilter.clause} `
- messageParams.push(msgFilter.param)
- }
- messageQuery += ` ORDER BY score ASC LIMIT 12`
- const messages = ftsQuery
- ? db
- .prepare<
- unknown[],
- {
- app_name: string | null
- title: string | null
- created_at: string
- role: string
- content: string
- }
- >(messageQuery)
- .all(...messageParams)
- : []
-
- const summaryParams: unknown[] = [ftsQuery]
- let summaryQuery = `
- SELECT cs.session_id, cs.summary, c.title, c.app_name, c.updated_at,
- bm25(summary_fts) as score
- FROM summary_fts
- JOIN chat_summaries cs ON summary_fts.rowid = cs.rowid
- JOIN conversations c ON c.id = cs.session_id
- WHERE summary_fts MATCH ?
- `
- const sumFilter = appNameLikeClause(appName, 'c.app_name')
- if (sumFilter) {
- summaryQuery += ` AND ${sumFilter.clause} `
- summaryParams.push(sumFilter.param)
- }
- summaryQuery += ` ORDER BY score ASC LIMIT 8`
- const summaries = ftsQuery
- ? db
- .prepare(
- summaryQuery
- )
- .all(...summaryParams)
- : []
-
- const entityParams: unknown[] = [ftsQuery]
- let entityQuery = `
- SELECT e.id, e.name, e.type, e.summary, e.updated_at,
- bm25(entity_fts) as score
- FROM entity_fts
- JOIN entities e ON entity_fts.rowid = e.id
- WHERE entity_fts MATCH ?
- `
- const entFilter = appNameLikeClause(appName, 'c.app_name')
- if (entFilter) {
- entityQuery += `
- AND e.id IN (
- SELECT es.entity_id
- FROM entity_sessions es
- JOIN conversations c ON c.id = es.session_id
- WHERE ${entFilter.clause}
- )
- `
- entityParams.push(entFilter.param)
- }
- entityQuery += ` ORDER BY score ASC LIMIT 8`
- const entities = ftsQuery
- ? db
- .prepare(
- entityQuery
- )
- .all(...entityParams)
- : []
-
- const factParams: unknown[] = [ftsQuery]
- let factQuery = `
- SELECT f.fact, f.created_at, f.source_session_id, e.name, e.type,
- bm25(entity_fact_fts) as score
- FROM entity_fact_fts
- JOIN entity_facts f ON entity_fact_fts.rowid = f.id
- JOIN entities e ON e.id = f.entity_id
- WHERE entity_fact_fts MATCH ?
- `
- const factFilter = appNameLikeClause(appName, 'app_name')
- if (factFilter) {
- factQuery += ` AND f.source_session_id IN (SELECT id FROM conversations WHERE ${factFilter.clause}) `
- factParams.push(factFilter.param)
- }
- factQuery += ` ORDER BY score ASC LIMIT 8`
- const entityFacts = ftsQuery
- ? db
- .prepare(factQuery)
- .all(...factParams)
- : []
-
- // Supplementary context (no bracket labels — the ONLY citeable tags are the
- // numbered [S#] SOURCES below, so the model can't invent uncited labels).
- const memoryLines = memories
- .slice(0, 6)
- .map(
- (m) => `- (${m.source_app || 'Unknown'} | ${m.created_at}): ${clipText(m.content, 500)}`
- )
- .join('\n')
-
- const messageLines = messages
- .slice(0, 6)
- .map(
- (m) =>
- `- (${m.app_name || 'Unknown'} | ${m.title || 'Untitled'} | ${m.created_at}) ${m.role}: ${clipText(m.content, 400)}`
- )
+ const history = (conversationHistory ?? [])
+ .slice(-10)
+ .map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${m.content}`)
.join('\n')
-
- const summaryLines = summaries
- .slice(0, 6)
- .map(
- (s) =>
- `- (${s.app_name || 'Unknown'} | ${s.title || 'Untitled'}): ${clipText(s.summary, 600)}`
- )
- .join('\n')
-
- const entityLines = entities
- .slice(0, 6)
- .map((e) => `- (${e.type || 'Unknown'}) ${e.name}: ${clipText(e.summary || '', 400)}`)
- .join('\n')
-
- const factLines = entityFacts
- .slice(0, 6)
- .map((f) => `- (${f.type || 'Unknown'}) ${f.name}: ${clipText(f.fact, 400)}`)
- .join('\n')
-
- const contextBlock = `RELEVANT MEMORIES:\n${memoryLines || '(none)'}\n\nRELEVANT MESSAGES:\n${messageLines || '(none)'}\n\nRELEVANT SUMMARIES:\n${summaryLines || '(none)'}\n\nRELEVANT ENTITIES:\n${entityLines || '(none)'}\n\nRELEVANT ENTITY FACTS:\n${factLines || '(none)'}`
-
- // Unified search: fuse in the best-ranked hits across screens, meetings,
- // memories, entities and facts (hybrid FTS + vectors with RRF) — the same
- // engine as the search screen, so the chat gets the right context too.
- let unifiedBlock = ''
- let unifiedHits: {
- kind: string
- title: string
- snippet: string
- surface: string
- ts: number
- refId: number
- imagePath: string | null
- }[] = []
- try {
- const { universalSearch } = await import('./search')
- unifiedHits = await universalSearch(query, { limit: 12, semantic: true })
- if (unifiedHits.length) {
- unifiedBlock =
- '\n\nSOURCES — every factual claim must cite the source it came from using its tag in square brackets, e.g. [S2]. Cite ONLY sources you actually used; never invent a citation:\n' +
- unifiedHits
- .map((h, i) => {
- const when = h.ts ? ` · ${new Date(h.ts).toISOString().slice(0, 10)}` : ''
- return `[S${i + 1}] (${h.kind} · ${h.surface || 'unknown'}${when})${h.title ? ` ${h.title} —` : ''} ${clipText(h.snippet || '', 350)}`
- })
- .join('\n')
- }
- } catch (e) {
- console.error('[RAG] universalSearch failed', e)
- }
-
- // Build conversation history block if provided
- let historyBlock = ''
- if (conversationHistory && conversationHistory.length > 0) {
- const historyLines = conversationHistory
- .map(
- (msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${clipText(msg.content, 500)}`
- )
- .join('\n\n')
- historyBlock = `\nCONVERSATION HISTORY:\n${historyLines}\n`
- }
-
- let skillsBlock = 'None installed.'
- try {
- const { listSkills } = await import('./skills')
- const sk = listSkills()
- if (sk.length) skillsBlock = sk.map((s) => `- /${s.name}: ${s.description}`).join('\n')
- } catch {
- /* skills optional */
- }
-
- const prompt = getPrompt('ragChat', {
- HISTORY_BLOCK: historyBlock,
- QUERY: query,
- CONTEXT_BLOCK: contextBlock + unifiedBlock,
- SKILLS_BLOCK: skillsBlock
- })
-
- try {
- if (streamId)
- event.sender.send('rag:stream', {
- streamId,
- type: 'step',
- step: {
- kind: 'memory',
- counts: {
- memories: memories.length,
- messages: messages.length,
- summaries: summaries.length,
- entities: entities.length,
- facts: entityFacts.length,
- unified: unifiedHits.length
- }
- }
- })
- const completion = await streamAnswer(event, streamId, prompt, thinking, imgs)
- return {
- ...completion,
- context: {
- masterMemory: null,
- memories,
- messages,
- summaries,
- entities,
- entityFacts,
- unified: unifiedHits
- }
- }
- } catch (e) {
- console.error('[RAG] LLM chat failed:', e)
- throw e
- }
+ const prompt = [
+ 'You are Off Grid AI, a private, on-device assistant.',
+ history ? `Conversation so far:\n${history}` : '',
+ `User: ${query}`,
+ 'Assistant:'
+ ]
+ .filter(Boolean)
+ .join('\n\n')
+ const completion = await streamAnswer(event, streamId, prompt, thinking, images ?? [])
+ return { ...completion, context: undefined }
}
)
@@ -1099,31 +358,18 @@ export function setupIPC(): void {
// Need to export this from database.ts first or import it
return getMemoriesForSession(sessionId)
})
- ipcMain.handle('db:get-entities', (_, appName?: string) => {
- return getEntities(appName)
- })
- ipcMain.handle('db:get-entity-details', (_, entityId: number, appName?: string) => {
- return getEntityDetails(entityId, appName)
- })
ipcMain.handle('db:get-entities-for-session', (_, sessionId: string) => {
return getEntitiesForSession(sessionId)
})
- ipcMain.handle('db:get-memory-records-for-session', (_, sessionId: string) => {
- return getMemoryRecordsForSession(sessionId)
- })
- ipcMain.handle('db:delete-session', async (_, sessionId: string) => {
+ ipcMain.handle('db:delete-session', (_, sessionId: string) => {
const db = getDB()
// Delete from new tables (messages will cascade due to foreign key)
db.prepare('DELETE FROM conversations WHERE id = ?').run(sessionId)
- // Also delete from legacy tables for cleanup
+ // Also delete from legacy tables for cleanup (retired memories stay deletable)
db.prepare('DELETE FROM memories WHERE session_id = ?').run(sessionId)
db.prepare('DELETE FROM chat_summaries WHERE session_id = ?').run(sessionId)
console.log(`Deleted session: ${sessionId}`)
-
- // Regenerate master memory after deletion
- await regenerateMasterMemory()
-
return true
})
@@ -1131,26 +377,12 @@ export function setupIPC(): void {
return await summarizeSession(sessionId)
})
- ipcMain.handle('db:get-master-memory', () => {
- return getMasterMemory()
- })
-
ipcMain.handle('db:delete-entity', (_, entityId: number) => {
const result = deleteEntityById(entityId)
console.log(`[IPC] Deleted entity ${entityId}: ${result}`)
return result
})
- ipcMain.handle('db:delete-memory', (_, memoryId: number) => {
- const result = deleteMemory(memoryId)
- console.log(`[IPC] Deleted memory ${memoryId}: ${result}`)
- return result
- })
-
- ipcMain.handle('db:regenerate-master-memory', async () => {
- return await regenerateMasterMemory()
- })
-
// User Profile handlers
ipcMain.handle('db:get-user-profile', () => {
return getUserProfile()
@@ -1364,13 +596,38 @@ export function setupIPC(): void {
;(await import('./best-settings')).markSpeedSettingsManual()
return setResidencyMode(modality, mode)
})
+ ipcMain.handle('runtime:backend:choices', () => backendChoicesForUi())
+ // "Try the GPU again": forget this session's demotion and restart the engine on its ladder.
+ ipcMain.handle('runtime:backend:retry', async (_e, modality: BackendModality) => {
+ const { clearDemotions, invalidateBackendFacts } = await import('./backend-facts')
+ clearDemotions(modality)
+ invalidateBackendFacts()
+ if (modality === 'llm') {
+ const { llm } = await import('./llm')
+ if (llm.shouldBeRunning()) await llm.restartSameModel()
+ } else if (modality === 'grounding') {
+ const { grounderRuntime } = await import('./vision/grounder-runtime')
+ if (grounderRuntime.shouldBeRunning()) await grounderRuntime.restart()
+ } else if (modality === 'decision') {
+ const { decisionRuntime } = await import('./accessibility/decision-runtime')
+ if (decisionRuntime.shouldBeRunning()) await decisionRuntime.restart()
+ } else if (modality === 'tts') {
+ await (await import('./tts')).ttsOnnxRuntime.restart()
+ } else if (modality === 'embeddings') {
+ await (await import('./embeddings')).embeddings.restart()
+ }
+ // Image and transcription load per job; their next job uses the restored ladder.
+ })
ipcMain.handle('runtime:backend:get', () => getBackendPreferences())
ipcMain.handle(
'runtime:backend:set',
async (_e, modality: BackendModality, preference: BackendPreference) => {
;(await import('./best-settings')).markSpeedSettingsManual()
const next = setBackendPreference(modality, preference)
- // A loaded engine keeps its present backend until its next load. The UI says so.
+ // Chat, image and the task engines take the new backend at their next load (the UI says
+ // so); voice and search helpers restart between requests onto it.
+ if (modality === 'embeddings') void (await import('./embeddings')).embeddings.restart()
+ if (modality === 'tts') void (await import('./tts')).ttsOnnxRuntime.restart()
return next
}
)
@@ -1455,128 +712,6 @@ export function setupIPC(): void {
return true
})
- // === REPROCESS ALL SESSIONS ===
-
- ipcMain.handle('db:reprocess-all-sessions', async (_, clean: boolean = false) => {
- const db = getDB()
- const sessions = db.prepare('SELECT id FROM conversations').all() as { id: string }[]
- let processed = 0
-
- if (clean) {
- // Clean reprocess: delete all old data and rebuild from scratch
- console.log('[IPC] Clean reprocess: clearing all entities, facts, edges, and memories...')
-
- // Drop FTS AFTER DELETE triggers first — if the FTS index is out of sync
- // with the source tables, the delete triggers will error and silently
- // prevent rows from being deleted. We recreate them after.
- db.exec('DROP TRIGGER IF EXISTS memories_ad')
- db.exec('DROP TRIGGER IF EXISTS entities_ad')
- db.exec('DROP TRIGGER IF EXISTS entity_facts_ad')
-
- // Delete only strictness-dependent data (children first)
- // Conversations, messages, chat_summaries, and master_memory are NOT touched
- db.prepare('DELETE FROM entity_edges').run()
- db.prepare('DELETE FROM entity_facts').run()
- db.prepare('DELETE FROM entity_sessions').run()
- db.prepare('DELETE FROM entities').run()
- db.prepare('DELETE FROM memories').run()
-
- // Recreate the delete triggers
- db.exec(`CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
- INSERT INTO memory_fts(memory_fts, rowid, content) VALUES('delete', old.id, old.content);
- END;`)
- db.exec(`CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
- INSERT INTO entity_fts(entity_fts, rowid, name, summary, type) VALUES('delete', old.id, old.name, old.summary, old.type);
- END;`)
- db.exec(`CREATE TRIGGER IF NOT EXISTS entity_facts_ad AFTER DELETE ON entity_facts BEGIN
- INSERT INTO entity_fact_fts(entity_fact_fts, rowid, fact, entity_id) VALUES('delete', old.id, old.fact, old.entity_id);
- END;`)
-
- // Rebuild FTS indexes so they reflect the now-empty source tables
- try {
- db.exec("INSERT INTO memory_fts(memory_fts) VALUES('rebuild')")
- db.exec("INSERT INTO entity_fts(entity_fts) VALUES('rebuild')")
- db.exec("INSERT INTO entity_fact_fts(entity_fact_fts) VALUES('rebuild')")
- } catch (e) {
- console.error('[IPC] FTS rebuild during clean reprocess failed (non-fatal):', e)
- }
-
- const deletedCounts = {
- entities: (db.prepare('SELECT COUNT(*) as c FROM entities').get() as { c: number }).c,
- memories: (db.prepare('SELECT COUNT(*) as c FROM memories').get() as { c: number }).c,
- facts: (db.prepare('SELECT COUNT(*) as c FROM entity_facts').get() as { c: number }).c
- }
- console.log('[IPC] Post-delete counts (should all be 0):', deletedCounts)
-
- // Notify frontend to refresh immediately after clearing
- BrowserWindow.getAllWindows().forEach((win) => {
- win.webContents.send('reprocess:progress', {
- phase: 'cleared',
- processed: 0,
- total: sessions.length
- })
- })
-
- for (const session of sessions) {
- try {
- // Re-evaluate memories for each message in the session
- const msgs = db
- .prepare(
- 'SELECT id, role, content FROM messages WHERE conversation_id = ? ORDER BY created_at ASC'
- )
- .all(session.id) as { id: number; role: string; content: string }[]
- const conv = db
- .prepare('SELECT app_name FROM conversations WHERE id = ?')
- .get(session.id) as { app_name: string } | undefined
- const appName = conv?.app_name || 'Unknown'
-
- for (const msg of msgs) {
- await evaluateAndStoreMemoryForMessage({
- sessionId: session.id,
- appName,
- role: msg.role,
- content: msg.content,
- messageId: msg.id
- })
- }
-
- // Re-extract entities from the newly created memories
- await extractEntitiesForSession(session.id)
- processed++
-
- // Send progress updates
- BrowserWindow.getAllWindows().forEach((win) => {
- win.webContents.send('reprocess:progress', {
- phase: 'processing',
- processed,
- total: sessions.length
- })
- })
- } catch (e) {
- console.error(`[IPC] Failed to reprocess session ${session.id}:`, e)
- }
- }
- } else {
- // Additive reprocess: keep existing data, just re-run entity extraction on top
- console.log(
- '[IPC] Additive reprocess: re-extracting entities with current settings (keeping existing data)...'
- )
- for (const session of sessions) {
- try {
- await extractEntitiesForSession(session.id)
- processed++
- } catch (e) {
- console.error(`[IPC] Failed to reprocess session ${session.id}:`, e)
- }
- }
- }
-
- console.log(
- `[IPC] Reprocessed ${processed} sessions (clean=${clean}) with current strictness settings`
- )
- return { processed, total: sessions.length }
- })
-
// === MODEL DOWNLOAD HANDLERS ===
ipcMain.handle('model:check-status', async () => {
@@ -2189,8 +1324,9 @@ export function setupIPC(): void {
// Non-stream fallback (no streamId): buffer, no live deltas (matches streamAnswer).
if (!streamId) {
return modalityQueue.run(CHAT_JOB, async () => {
- const { result, modelName } = await runChatWithFallback((attempt) =>
- toolChat(query, history || [], { ...(opts || {}), ...attempt })
+ const { result, modelName } = await runChatWithFallback(
+ (attempt) => toolChat(query, history || [], { ...(opts || {}), ...attempt }),
+ deviceFallbackNotices(sender, streamId)
)
return modelName ? { ...result, metrics: { ...result.metrics, modelName } } : result
})
@@ -2265,6 +1401,7 @@ export function setupIPC(): void {
{
signal: controller.signal,
canRetry: () => !toolStarted,
+ ...deviceFallbackNotices(sender, streamId),
onModelChanged: (failed, next) => {
resetChatStreamPartial(streamId)
try {
diff --git a/src/main/licensing/license-service.ts b/src/main/licensing/license-service.ts
index 6e6b1eaed..c76cd76d3 100644
--- a/src/main/licensing/license-service.ts
+++ b/src/main/licensing/license-service.ts
@@ -5,7 +5,7 @@ export const PRO_PAY_PAGE_URL = PRO_PURCHASE_URL
export type ActivateResult = PersonalMeshActivationResult
-export type ProTier = 'lifetime' | 'monthly' | 'annual' | 'subscription'
+type ProTier = 'lifetime' | 'monthly' | 'annual' | 'subscription'
export interface ProLicenseInfo {
isPro: boolean
diff --git a/src/main/llama-error.ts b/src/main/llama-error.ts
index ee9b2114f..14af25a15 100644
--- a/src/main/llama-error.ts
+++ b/src/main/llama-error.ts
@@ -13,6 +13,7 @@ export interface LlamaFailure {
| 'os_too_old'
| 'out_of_memory'
| 'gpu_unsupported'
+ | 'gpu_fault'
| 'speculation_unsupported'
| 'missing_library'
| 'model_corrupt'
@@ -22,6 +23,9 @@ export interface LlamaFailure {
reason: string
}
+export const GPU_FAULT_PATTERN =
+ /compute error|cuda error|ggml_cuda|device lost|ErrorDeviceLost|vk::DeviceLostError|failed to allocate|out of memory|cudaMalloc/i
+
/** One source of truth for a live second-instance conflict, whether it is detected before spawn
* from process ownership or reported by the native engine as EADDRINUSE. */
export function modelPortConflictReason(port: number): string {
@@ -137,6 +141,10 @@ export function classifyLlamaError(
}
}
+ if (GPU_FAULT_PATTERN.test(stderr)) {
+ return { code: 'gpu_fault', reason: 'The GPU stopped responding.' }
+ }
+
// Corrupt / truncated weights.
if (
/failed to load model|invalid magic|tensor.*not found|gguf.*(invalid|corrupt|truncat)|done_getting_tensors.*wrong/.test(
diff --git a/src/main/llm.ts b/src/main/llm.ts
index a598e5127..8c06586ec 100644
--- a/src/main/llm.ts
+++ b/src/main/llm.ts
@@ -11,17 +11,11 @@ import path from 'path'
import { recordAIRequest, currentAIRequest } from './ai-request-log'
import * as fs from 'fs'
import { modelMetadataFile, resolveModelReference } from './model-paths'
-import {
- modelsDir as getModelsDir,
- binRoots,
- isPackaged,
- exe,
- requireModelStorage
-} from './runtime-env'
+import { modelsDir as getModelsDir, binRoots, isPackaged, requireModelStorage } from './runtime-env'
import { reapOrphanProcessesOnPort, type PortReapResult } from './kill-orphan-port'
import { loadAttempts, type KvCacheType, type PerformanceMode } from './model-sizing'
import { resolveMaxTokens, maxTokensForWire, MAX_TOKENS_AUTO } from './llm/gen-params'
-import { classifyLlamaError, modelPortConflictReason } from './llama-error'
+import { classifyLlamaError, modelPortConflictReason, GPU_FAULT_PATTERN } from './llama-error'
import type { ManagedRuntime } from './runtime-manager'
import { LLAMA_SERVER_PORT } from '../shared/ports'
import {
@@ -32,6 +26,8 @@ import {
import {
REASONING_BUDGET_AUTO,
reasoningBudgetPayload,
+ backendReasonText,
+ type NativeBackend,
isGatewayMode,
llmSlotPlan,
type GatewayMode,
@@ -68,15 +64,14 @@ import {
type CatalogEntry
} from './models/catalog-logic'
import { pickFreePort, isPortFree } from './free-port'
-import { postCompletionOnce } from './llm/http-post'
+import { ModelServerError, postCompletionOnce } from './llm/http-post'
import { modalityQueue } from './modality-queue/queue'
import { engineSpawnEnv } from './llm/spawn-env'
-import { gpuDeviceAvailable } from './llm/gpu-device-probe'
import { shouldAutoRecover } from './llm/crash-policy'
-import { enginePriority } from './llm/engine-priority'
+import { engineCandidates, nativeLadder } from './backend-facts'
import { modelStartupQuietLimit, startupStalled } from './llm/startup-timeout'
+import { waitForEngineResumeHealth } from './engine-resume-health'
import { getBackendPreference } from './backend-preferences'
-import { prioritizeBackend, type BackendPreference } from '../shared/backend-preferences'
import { streamCompletion, type StreamResult } from './llm/stream'
import type { AssembledToolCall } from './llm/sse-stream'
import {
@@ -116,7 +111,7 @@ export interface LlmSettings {
// Launch-time (require a server respawn to take effect):
kvCacheType?: KvCacheType // quantize the KV cache to cut memory (needs flash-attn)
flashAttn?: boolean // FlashAttention: faster + lower memory; required for quantized KV
- gpuLayers?: number // -ngl: layers offloaded to the GPU. 99 = all.
+ gpuLayers?: number | null // null lets llama.cpp fit layers automatically.
threads?: number // CPU threads for inference
batchSize?: number // -b: prompt batch size
speculativeDecoding?: SpeculativeDecodingMode
@@ -272,6 +267,10 @@ export class LLMService {
private backendOutput = ''
private backendModelPath = ''
private backendPlacement: ReturnType
+ /** Layers the engine put on the GPU, from its load log ("offloaded N/M layers to GPU"). */
+ private offloadedLayers: { gpu: number; total: number } | undefined
+ /** When the current engine started loading, for the first-load notice. */
+ private loadingSince: number | undefined
private backendFallbackReason: string | undefined
// A model selection is durable as soon as the manager writes active-model.json, but
// replacing llama-server while it is answering destroys the user's in-flight turn.
@@ -319,7 +318,7 @@ export class LLMService {
// choice (the "q8_0 reverts to f16 on every restart" bug). Persisted alongside the
// values so a plain restart keeps the pin.
private readonly userExplicit = new Set()
- private gpuLayers = 99
+ private gpuLayers: number | null = null
private threads: number | undefined
private batchSize: number | undefined
private speculativeDecoding: SpeculativeDecodingMode = 'off'
@@ -348,6 +347,10 @@ export class LLMService {
// a server that keeps dying (e.g. memory pressure on a too-large model) can NOT
// thrash-respawn a multi-GB process forever.
private restartTimes: number[] = []
+ private deviceFaultAt: number | null = null
+ private recoveryPromise: Promise | null = null
+ private recoveryModel: { path: string; mmproj: string } | null = null
+ private currentBackend: NativeBackend | undefined
// Launch-setting restarts are serialized and last-wins. A slider drag or a fast
// sequence of settings writes used to call stop()+init() once per change, so several
// spawns raced and an EARLIER one could win and leave the engine running arguments the
@@ -413,7 +416,8 @@ export class LLMService {
if (s.kvCacheType === 'f16' || s.kvCacheType === 'q8_0' || s.kvCacheType === 'q4_0')
this.kvCacheType = s.kvCacheType
if (typeof s.flashAttn === 'boolean') this.flashAttn = s.flashAttn
- if (typeof s.gpuLayers === 'number') this.gpuLayers = s.gpuLayers
+ if (s.gpuLayers === null || typeof s.gpuLayers === 'number')
+ this.gpuLayers = s.gpuLayers === 99 ? null : s.gpuLayers
if (typeof s.threads === 'number') this.threads = s.threads
if (typeof s.batchSize === 'number') this.batchSize = s.batchSize
if (
@@ -428,7 +432,11 @@ export class LLMService {
if (isGatewayMode(s.gatewayMode)) this.gatewayModeSetting = s.gatewayMode
if (Number.isInteger(s.gatewayClients) && s.gatewayClients >= 1)
this.gatewayClientsSetting = s.gatewayClients
- if (Number.isInteger(s.speculativeDraftMax) && s.speculativeDraftMax >= 0 && s.speculativeDraftMax <= 16)
+ if (
+ Number.isInteger(s.speculativeDraftMax) &&
+ s.speculativeDraftMax >= 0 &&
+ s.speculativeDraftMax <= 16
+ )
this.speculativeDraftMax = s.speculativeDraftMax
if (
s.performanceMode === 'conservative' ||
@@ -596,11 +604,27 @@ export class LLMService {
runtimeBackend(): RuntimeBackend {
const placement = this.initialized ? this.backendPlacement : undefined
return {
- id: 'chat', model: this.backendModelPath,
- state: this.initialized ? 'loaded' : this.isStarting() ? 'loading' : this.lastError() ? 'error' : 'stopped',
+ id: 'chat',
+ model: this.backendModelPath,
+ state:
+ this.deviceFaultAt !== null
+ ? 'error'
+ : this.initialized
+ ? 'loaded'
+ : this.isStarting()
+ ? 'loading'
+ : this.lastError()
+ ? 'error'
+ : 'stopped',
backend: placement?.backend,
device: placement?.device,
- detail: this.lastError() ?? this.backendFallbackReason
+ detail: this.lastError() ?? this.backendFallbackReason,
+ fallbackReason: this.backendFallbackReason,
+ lastError: this.lastError() ?? undefined,
+ ...(placement && this.offloadedLayers
+ ? { gpuLayers: this.offloadedLayers.gpu, totalLayers: this.offloadedLayers.total }
+ : {}),
+ ...(this.isStarting() && this.loadingSince ? { loadingSince: this.loadingSince } : {})
}
}
@@ -664,7 +688,11 @@ export class LLMService {
}
/** Build argv for the selected context and a GPU-layer count. */
- private launchArgsFor(effectiveCtxSize: number, gpuLayers: number): string[] {
+ private launchArgsFor(
+ effectiveCtxSize: number,
+ gpuLayers: number | null,
+ cpuOnly = false
+ ): string[] {
const useSelectedModelSpeculation =
this.runtimeModelOverride === null &&
this.speculativeModeSupported(this.speculativeDecoding, this.draftModel)
@@ -676,6 +704,7 @@ export class LLMService {
port: this.port,
effectiveCtxSize,
gpuLayers,
+ cpuOnly,
flashAttn: this.flashAttn,
kvCacheType: this.kvCacheType,
threads: this.threads,
@@ -920,8 +949,12 @@ export class LLMService {
/** Update inference settings; respawns the server if any launch-time arg changed
* (context, KV-cache type, flash-attn, GPU layers, threads, batch). */
async setSettings(s: LlmSettings, options: LlmSettingsUpdateOptions = {}): Promise {
- if (s.speculativeDraftMax !== undefined &&
- (!Number.isInteger(s.speculativeDraftMax) || s.speculativeDraftMax < 0 || s.speculativeDraftMax > 16)) {
+ if (
+ s.speculativeDraftMax !== undefined &&
+ (!Number.isInteger(s.speculativeDraftMax) ||
+ s.speculativeDraftMax < 0 ||
+ s.speculativeDraftMax > 16)
+ ) {
throw new Error('Draft token limit must be Auto (0) or a whole number from 1 to 16.')
}
this.ensureLoaded()
@@ -1007,7 +1040,7 @@ export class LLMService {
this.kvCacheType = compatibleSettings.kvCacheType
if (typeof compatibleSettings.flashAttn === 'boolean')
this.flashAttn = compatibleSettings.flashAttn
- if (typeof compatibleSettings.gpuLayers === 'number')
+ if (compatibleSettings.gpuLayers === null || typeof compatibleSettings.gpuLayers === 'number')
this.gpuLayers = compatibleSettings.gpuLayers
if (typeof compatibleSettings.threads === 'number') this.threads = compatibleSettings.threads
if (typeof compatibleSettings.batchSize === 'number')
@@ -1072,7 +1105,8 @@ export class LLMService {
this.systemPrompt = priorSettings.systemPrompt ?? this.systemPrompt
this.kvCacheType = priorSettings.kvCacheType ?? this.kvCacheType
this.flashAttn = priorSettings.flashAttn ?? this.flashAttn
- this.gpuLayers = priorSettings.gpuLayers ?? this.gpuLayers
+ this.gpuLayers =
+ priorSettings.gpuLayers === undefined ? this.gpuLayers : priorSettings.gpuLayers
this.threads = priorSettings.threads
this.batchSize = priorSettings.batchSize
this.speculativeDecoding = priorSettings.speculativeDecoding ?? this.speculativeDecoding
@@ -1169,6 +1203,11 @@ export class LLMService {
// ({ id, primary, mmproj }) after resolving a catalog entry; default to the
// bundled Qwen3-VL vision model when nothing is selected yet.
private resolveModel(): void {
+ if (this.recoveryModel) {
+ this.modelPath = this.recoveryModel.path
+ this.mmProjPath = this.recoveryModel.mmproj
+ return
+ }
if (this.runtimeModelOverride) {
this.modelPath = resolveModelReference(this.runtimeModelOverride.primary)
this.mmProjPath = this.runtimeModelOverride.mmproj
@@ -1419,30 +1458,31 @@ export class LLMService {
// Try CUDA on NVIDIA, Vulkan on other GPUs, then the CPU-only engine.
// macOS has its own Metal engines, so the absent CUDA/Vulkan paths are skipped.
const roots = binRoots()
- const serverPaths = roots
- .flatMap((r) =>
- requiresPrism
- ? [
- path.join(r, 'llama-prism-cuda', exe('llama-server')),
- path.join(r, 'llama-prism', exe('llama-server')),
- path.join(r, 'llama-prism-cpu', exe('llama-server'))
- ]
- : [
- path.join(r, 'llama-cuda', exe('llama-server')),
- path.join(r, 'llama', exe('llama-server')),
- path.join(r, 'llama-cpu', exe('llama-server')),
- path.join(r, exe('llama-server'))
- ]
+ const serverPaths = await nativeLadder(
+ 'llm',
+ undefined,
+ getBackendPreference('llm'),
+ requiresPrism
+ )
+ this.backendFallbackReason =
+ serverPaths[0]?.cpuOnly && this.recoveryModel && this.hasDeviceFault()
+ ? 'The GPU stopped responding.'
+ : undefined
+ const candidates = await engineCandidates('llm', undefined, requiresPrism)
+ const firstGpu = candidates.find((candidate) => candidate.backend !== 'cpu')
+ if (serverPaths[0]?.cpuOnly || (firstGpu && serverPaths[0]?.path !== firstGpu.id)) {
+ const skipped = candidates.find(
+ (candidate) => candidate.backend !== 'cpu' && !candidate.usable
)
- .filter((p) => fs.existsSync(p))
+ if (skipped?.reason) this.backendFallbackReason = backendReasonText(skipped.reason)
+ }
if (!serverPaths.length) {
if (requiresPrism) {
throw new Error(
'Bonsai 2 requires the bundled Prism llama.cpp engine, which is missing from this build.'
)
}
- console.error(`[LLMService] llama-server binary not found under: ${roots.join(', ')}`)
- return
+ throw new Error(`No chat engine is installed. Searched: ${roots.join(', ')}`)
}
console.log(`[LLMService] Model: ${this.modelPath}`)
@@ -1476,54 +1516,16 @@ export class LLMService {
/** Try all accelerated engines before CPU at the selected context. */
private async launchWithFallback(
- serverPaths: string[],
+ serverPaths: Awaited>,
generation = this.launchGeneration
): Promise {
- this.backendFallbackReason = undefined
- const preference = getBackendPreference('llm')
- const ordered = prioritizeBackend(
- [...serverPaths].sort((a, b) => enginePriority(a) - enginePriority(b)),
- preference === 'cpu' ? 'auto' : preference,
- (serverPath): BackendPreference => {
- const directory = path.basename(path.dirname(serverPath))
- if (directory.endsWith('-cpu')) return 'cpu'
- if (directory.endsWith('-cuda')) return 'cuda'
- return process.platform === 'darwin' ? 'metal' : 'vulkan'
- }
- )
- const candidates = preference === 'cpu'
- ? ordered.map((serverPath) => ({ serverPath, cpuOnly: true }))
- : [
- ...ordered.map((serverPath) => ({ serverPath, cpuOnly: false })),
- ...ordered
- .filter((p) => !p.includes('llama-cpu') && !p.includes('llama-prism-cpu'))
- .map((serverPath) => ({ serverPath, cpuOnly: true }))
- ]
- for (const { serverPath, cpuOnly } of candidates) {
+ for (const { path: serverPath, cpuOnly, backend } of serverPaths) {
if (generation !== this.launchGeneration) return false
- if (!serverPath) continue
+ this.currentBackend = cpuOnly ? 'cpu' : backend
const engineDir = path.basename(path.dirname(serverPath))
- const backend = engineDir.endsWith('-cuda')
- ? 'CUDA'
- : engineDir === 'llama' || engineDir === 'llama-prism'
- ? 'Vulkan'
- : null
- if (!cpuOnly && (process.platform === 'win32' || process.platform === 'linux') && backend) {
- if (this.gpuLayers === 0) continue
- if (!(await gpuDeviceAvailable(serverPath, backend, process.platform))) {
- this.backendFallbackReason = `${backend} did not report a usable device. Another engine was selected.`
- console.warn(
- `[LLMService] no usable ${backend} device for ${serverPath}; trying next engine`
- )
- continue
- }
- }
// Read after the device probe's wait, with no await before the launch arguments, so a save made
// during the probe cannot leave the checkpoint and the arguments describing different launches.
- const attempts = loadAttempts(
- this.ctxSize,
- cpuOnly || enginePriority(serverPath) === 2 ? 0 : this.gpuLayers
- ).slice(0, 1)
+ const attempts = loadAttempts(this.ctxSize, cpuOnly ? 0 : this.gpuLayers).slice(0, 1)
for (let a = 0; a < attempts.length; a++) {
if (generation !== this.launchGeneration) return false
const at = attempts[a]
@@ -1531,12 +1533,16 @@ export class LLMService {
if (a > 0) {
console.warn(`[LLMService] out of memory — retrying load at ${at.reason}`)
}
- const args = this.launchArgsFor(at.ctxSize, at.gpuLayers)
+ const args = this.launchArgsFor(at.ctxSize, at.gpuLayers, cpuOnly)
// The six-token Qwen3.5 9B gain was measured with stock llama.cpp on
// Metal. Preserve the existing limit for other engines and backends.
const draftMaxIndex = args.indexOf('--spec-draft-n-max')
- if (draftMaxIndex >= 0 && this.speculativeDecoding === 'mtp' && this.speculativeDraftMax === 0 &&
- (engineDir !== 'llama' || cpuOnly || process.platform !== 'darwin')) {
+ if (
+ draftMaxIndex >= 0 &&
+ this.speculativeDecoding === 'mtp' &&
+ this.speculativeDraftMax === 0 &&
+ (engineDir !== 'llama' || cpuOnly || process.platform !== 'darwin')
+ ) {
args[draftMaxIndex + 1] = '2'
}
// Captured in the same step as the arguments: a save can land during init's earlier awaits,
@@ -1551,8 +1557,9 @@ export class LLMService {
}
if (generation !== this.launchGeneration) return false
const failure = classifyLlamaError(this.stderrTail.join('\n'))
- this.backendFallbackReason = failure?.reason ?? 'The previous engine could not load the model.'
- if (failure?.code === 'speculation_unsupported') {
+ this.backendFallbackReason =
+ failure?.reason ?? 'The previous engine could not load the model.'
+ if (failure?.code === 'speculation_unsupported' && !this.recoveryModel) {
console.warn(
`[LLMService] ${failure.reason} Retrying the selected model with speculative decoding off.`
)
@@ -1564,10 +1571,7 @@ export class LLMService {
}
await this.prepareModelPort()
if (generation !== this.launchGeneration) return false
- const retryArgs = this.launchArgsFor(
- this.ctxSize,
- cpuOnly || enginePriority(serverPath) === 2 ? 0 : this.gpuLayers
- )
+ const retryArgs = this.launchArgsFor(this.ctxSize, cpuOnly ? 0 : this.gpuLayers, cpuOnly)
const retriedWith = { settings: this.getSettings(), explicit: new Set(this.userExplicit) }
if (await this.launchServer(serverPath, retryArgs)) {
this.lastWorkingLaunch = retriedWith
@@ -1628,6 +1632,8 @@ export class LLMService {
this.backendOutput = ''
this.backendModelPath = this.modelPath
this.backendPlacement = undefined
+ this.offloadedLayers = undefined
+ this.loadingSince = Date.now()
this.stderrTail = []
this.lastServerOutputAt = Date.now()
this.invalidateHealth()
@@ -1652,7 +1658,15 @@ export class LLMService {
const text = String(data)
// Verbosity 4 also emits request traces. Read and log it only during model
// load so user prompts cannot enter the app log or its diagnostic tail.
- if (!probing || this.server !== proc) return
+ if (this.server !== proc) return
+ if (!probing) {
+ if (GPU_FAULT_PATTERN.test(text)) {
+ this.lastErrorMsg = 'The GPU stopped responding.'
+ this.deviceFaultAt = Date.now()
+ this.invalidateHealth()
+ }
+ return
+ }
console.log(`[llama-server] ${text}`)
this.backendOutput = (this.backendOutput + text).slice(-65536)
// Keep a rolling tail so we can classify a load failure after it exits.
@@ -1687,7 +1701,10 @@ export class LLMService {
this.invalidateHealth()
// Recover unexpected exits of a previously healthy engine, including OS kills.
// App stops and memory pauses stay stopped; launch failures use the engine ladder.
- if (shouldAutoRecover({ probing, wasIntentional, paused: this.paused, signal })) {
+ if (
+ this.deviceFaultAt === null &&
+ shouldAutoRecover({ probing, wasIntentional, paused: this.paused, signal })
+ ) {
void this.handleCrash(code ?? -1).catch((error) => {
console.error('[LLMService] automatic recovery failed:', error)
})
@@ -1705,11 +1722,19 @@ export class LLMService {
console.log('[LLMService] Vision server ready!')
const engineDir = path.basename(binDir)
this.backendPlacement = parseNativeBackend(this.backendOutput)
+ const layers = [
+ ...this.backendOutput.matchAll(/\boffloaded\s+(\d+)\/(\d+)\s+layers to GPU\b/gi)
+ ].at(-1)
+ this.offloadedLayers = layers
+ ? { gpu: Number(layers[1]), total: Number(layers[2]) }
+ : undefined
+ this.loadingSince = undefined
this.lastPlacedBackend = this.backendPlacement?.backend ?? this.lastPlacedBackend
console.log(
`[LLMService] model ready: engine=${engineDir}, backend=${this.backendPlacement?.backend ?? 'unconfirmed'}`
)
this.initialized = true
+ this.deviceFaultAt = null
this.lastErrorMsg = null // healthy again — clear any prior failure reason
this.invalidateHealth()
return true
@@ -1737,6 +1762,11 @@ export class LLMService {
* init, and fail loudly if init didn't take. Single source of truth so the
* three chat methods don't each re-implement it. */
private async ensureReady(): Promise {
+ await waitForEngineResumeHealth()
+ if (this.recoveryPromise) await this.recoveryPromise
+ if (this.hasDeviceFault()) {
+ throw new ModelServerError('device_fault', 'The GPU stopped responding while answering.')
+ }
await prepareModelMemory('chat')
if (this.paused) throw new Error('LLM paused during image generation — deferred')
if (!this.initialized) {
@@ -1811,7 +1841,8 @@ export class LLMService {
await this.init()
} catch (error) {
console.error('[LLMService] recovery startup failed:', error)
- if (generation === this.launchGeneration && !(this.server as ChildProcess | null)) await this.handleCrash(code)
+ if (generation === this.launchGeneration && !(this.server as ChildProcess | null))
+ await this.handleCrash(code)
}
}
@@ -2021,6 +2052,7 @@ export class LLMService {
timeoutMs?: number,
maxTokens?: number,
opts: {
+ requireImages?: boolean
responseFormat?: unknown
temperature?: number
enableThinking?: boolean
@@ -2031,7 +2063,13 @@ export class LLMService {
shared?: boolean
} = {}
): Promise {
- const messages = buildMessages(message, readImages(images), this.systemPrompt)
+ const decodedImages = readImages(images)
+ if (opts.requireImages && (images.length === 0 || decodedImages.length !== images.length)) {
+ throw new Error(
+ 'Required model image could not be read. Take a fresh screenshot before retrying.'
+ )
+ }
+ const messages = buildMessages(message, decodedImages, this.systemPrompt)
return recordAIRequest(
{
modality: 'text',
@@ -2429,6 +2467,7 @@ export class LLMService {
stop(): void {
this.launchGeneration++
this.initialized = false
+ this.intentionalStop = true
if (this.server) {
this.intentionalStop = true // deliberate shutdown — don't auto-restart
this.server.kill()
@@ -2536,7 +2575,9 @@ export class LLMService {
private async pauseAndWait(): Promise {
const { portFree } = await this.unload(true)
if (!portFree) {
- throw new Error('The chat model port is still occupied; image generation cannot start safely.')
+ throw new Error(
+ 'The chat model port is still occupied; image generation cannot start safely.'
+ )
}
}
@@ -2599,6 +2640,67 @@ export class LLMService {
return this.lastErrorMsg
}
+ hasDeviceFault(): boolean {
+ return this.deviceFaultAt !== null
+ }
+
+ gpuBackend(): NativeBackend | undefined {
+ return this.currentBackend === 'cpu' ? undefined : this.currentBackend
+ }
+
+ shouldBeRunning(): boolean {
+ return (
+ !this.paused && !this.intentionalStop && this.lastWorkingLaunch !== null && !this.initPromise
+ )
+ }
+
+ async healthCheck(): Promise {
+ const proc = this.server
+ if (
+ !proc ||
+ proc.killed ||
+ proc.exitCode !== null ||
+ proc.signalCode !== null ||
+ this.hasDeviceFault()
+ )
+ return false
+ try {
+ const response = await fetch(`http://127.0.0.1:${this.port}/health`, {
+ signal: AbortSignal.timeout(3000)
+ })
+ await response.body?.cancel()
+ // The process may have been replaced while the request was in flight.
+ return response.status === 200 && (this.server as ChildProcess | null) === proc
+ } catch {
+ return false
+ }
+ }
+
+ /** Fault recovery shares the crash budget and never writes the user's settings. */
+ async restartSameModel(): Promise {
+ if (this.recoveryPromise) return this.recoveryPromise
+ const now = Date.now()
+ this.restartTimes = this.restartTimes.filter((time) => now - time < 120_000)
+ if (this.restartTimes.length >= 3) {
+ this.lastErrorMsg =
+ 'Model server stopped repeatedly. Automatic restart limit reached. Check GPU memory and use Restart after fixing the cause.'
+ this.invalidateHealth()
+ throw new Error(this.lastErrorMsg)
+ }
+ this.restartTimes.push(now)
+ this.recoveryModel = { path: this.modelPath, mmproj: this.mmProjPath }
+ this.recoveryPromise = (async () => {
+ this.stop()
+ await this.init()
+ if (!this.isReady())
+ throw new Error('Server did not come back up — check the model is downloaded')
+ })().finally(() => {
+ this.recoveryModel = null
+ this.recoveryPromise = null
+ })
+ return this.recoveryPromise
+ }
+
/** Hard restart: kill the server and spawn it fresh (picks up a model swap or
* recovers a crashed/hung instance). Used by "Configure for me", the Health
* panel's restart action, and the Models screen dev control. Clears `paused`
diff --git a/src/main/llm/__tests__/dedicated-llama-runtime.test.ts b/src/main/llm/__tests__/dedicated-llama-runtime.test.ts
new file mode 100644
index 000000000..2ffa31348
--- /dev/null
+++ b/src/main/llm/__tests__/dedicated-llama-runtime.test.ts
@@ -0,0 +1,160 @@
+// A dedicated llama-server (grounding, decision) owned by DedicatedLlamaRuntime: it starts, comes
+// back on its own when the engine dies, stops trying after repeated deaths, and stops cleanly.
+// The native engine is the external boundary: a small process that implements llama-server's
+// health contract and dies on request. Engine selection, ports, launch and recovery are real.
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { afterAll, afterEach, describe, expect, it } from 'vitest'
+import { binRoots, configureRuntime, dataDir } from '../../runtime-env'
+import { DedicatedLlamaRuntime } from '../dedicated-llama-runtime'
+
+const previous = { dataDir: dataDir(), binRoots: binRoots() }
+const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-dedicated-runtime-'))
+const binaries = path.join(profile, 'bin')
+const engine = path.join(binaries, 'llama-cpu', 'llama-server')
+/** What the engine does on its next start: serve, die once healthy, or never answer. */
+const modeFile = path.join(profile, 'mode')
+/** One line per engine start. */
+const spawnLog = path.join(profile, 'spawns')
+configureRuntime({ dataDir: profile, binRoots: [binaries] })
+
+fs.mkdirSync(path.dirname(engine), { recursive: true })
+fs.writeFileSync(
+ engine,
+ `#!/usr/bin/env node
+const fs = require('node:fs');
+const http = require('node:http');
+fs.appendFileSync(${JSON.stringify(spawnLog)}, process.argv.slice(2).join(' ') + '\\n');
+const mode = fs.existsSync(${JSON.stringify(modeFile)}) ? fs.readFileSync(${JSON.stringify(modeFile)}, 'utf8').trim() : 'serve';
+const args = process.argv.slice(2);
+const port = Number(args[args.indexOf('--port') + 1]);
+const server = http.createServer((req, res) => {
+ if (mode === 'silent') return;
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/health') {
+ res.end(JSON.stringify({ status: 'ok' }));
+ if (mode === 'fault') setTimeout(() => { process.stderr.write('CUDA error: device lost\\n'); process.exit(1); }, 150);
+ return;
+ }
+ res.statusCode = 404; res.end('{}');
+});
+server.listen(port, '127.0.0.1');
+process.on('SIGTERM', () => server.close(() => process.exit(0)));
+`,
+ { mode: 0o755 }
+)
+
+const spawns = (): number =>
+ fs.existsSync(spawnLog) ? fs.readFileSync(spawnLog, 'utf8').trim().split('\n').length : 0
+
+let runtime: DedicatedLlamaRuntime | undefined
+
+function makeRuntime(readyTimeoutMs?: number): DedicatedLlamaRuntime {
+ runtime = new DedicatedLlamaRuntime({
+ id: 'decision',
+ label: 'Decision',
+ modality: 'decision',
+ port: 30_000 + Math.floor(Math.random() * 20_000),
+ ...(readyTimeoutMs ? { readyTimeoutMs } : {}),
+ modelPaths: async () => ({
+ model: 'test/decider',
+ modelPath: path.join(profile, 'decider.gguf'),
+ mmProjPath: ''
+ }),
+ extraArgs: () => ({
+ effectiveCtxSize: 4_096,
+ flashAttn: true,
+ kvCacheType: 'q8_0',
+ speculativeDecoding: 'off',
+ threads: undefined,
+ batchSize: 128
+ })
+ })
+ return runtime
+}
+
+async function until(
+ condition: () => boolean | Promise,
+ timeoutMs = 15_000
+): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (!(await condition())) {
+ if (Date.now() > deadline) throw new Error('timed out waiting')
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ }
+}
+
+afterEach(async () => {
+ await runtime?.stop()
+ runtime = undefined
+ fs.rmSync(modeFile, { force: true })
+ fs.rmSync(spawnLog, { force: true })
+})
+
+afterAll(() => {
+ configureRuntime(previous)
+ fs.rmSync(profile, { recursive: true, force: true })
+})
+
+describe.skipIf(process.platform === 'win32')('DedicatedLlamaRuntime', () => {
+ it('starts the engine on the CPU build and answers health', async () => {
+ const decision = makeRuntime()
+ await decision.start()
+ expect(decision.isRunning).toBe(true)
+ expect(decision.shouldBeRunning()).toBe(true)
+ expect(await decision.health()).toBe(true)
+ expect(decision.backend).toBe('CPU')
+ // The CPU build runs CPU only.
+ expect(fs.readFileSync(spawnLog, 'utf8')).toContain('--device none')
+ // A second start while running is the same engine.
+ await decision.start()
+ expect(spawns()).toBe(1)
+ })
+
+ it('brings the same model back on its own after the engine dies', async () => {
+ const decision = makeRuntime()
+ fs.writeFileSync(modeFile, 'fault')
+ await decision.start()
+ fs.writeFileSync(modeFile, 'serve')
+ await decision.health()
+ // The engine dies after answering; the runtime starts it again with the same model.
+ await until(async () => spawns() >= 2 && decision.isRunning && (await decision.health()))
+ const starts = fs.readFileSync(spawnLog, 'utf8').trim().split('\n')
+ expect(starts[1]).toContain(path.join(profile, 'decider.gguf'))
+ })
+
+ it('stops restarting after repeated deaths and says so', async () => {
+ const decision = makeRuntime()
+ fs.writeFileSync(modeFile, 'fault')
+ await decision.start()
+ // Each death is restarted with a growing pause, until the budget runs out.
+ await until(() => decision.failureCode === 'startup', 30_000)
+ expect(decision.isRunning).toBe(false)
+ const before = spawns()
+ await new Promise((resolve) => setTimeout(resolve, 1_500))
+ expect(spawns()).toBe(before)
+ }, 40_000)
+
+ it('gives up on an engine that never becomes ready', async () => {
+ const decision = makeRuntime(800)
+ fs.writeFileSync(modeFile, 'silent')
+ await expect(decision.start()).rejects.toThrow()
+ expect(decision.isRunning).toBe(false)
+ })
+
+ it('restarts on request and stops cleanly', async () => {
+ const decision = makeRuntime()
+ await decision.start()
+ await decision.restart()
+ expect(spawns()).toBe(2)
+ expect(await decision.health()).toBe(true)
+ await decision.stop()
+ expect(decision.isRunning).toBe(false)
+ expect(decision.shouldBeRunning()).toBe(false)
+ expect(await decision.health()).toBe(false)
+ // Restarting a stopped runtime does nothing.
+ await decision.restart()
+ expect(spawns()).toBe(2)
+ })
+})
diff --git a/src/main/llm/__tests__/engine-priority.test.ts b/src/main/llm/__tests__/engine-priority.test.ts
deleted file mode 100644
index ec3fab92e..000000000
--- a/src/main/llm/__tests__/engine-priority.test.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { expect, it } from 'vitest'
-import { enginePriority } from '../engine-priority'
-it('tries CUDA and Metal/Vulkan across roots before CPU', () => {
- const paths = ['/a/llama-cpu/server', '/b/llama/server', 'C:\\b\\llama-cuda\\server.exe']
- expect(paths.sort((a, b) => enginePriority(a) - enginePriority(b))).toEqual([
- 'C:\\b\\llama-cuda\\server.exe',
- '/b/llama/server',
- '/a/llama-cpu/server'
- ])
- expect(enginePriority('/a/llama-prism-cpu/server')).toBe(2)
-})
diff --git a/src/main/llm/__tests__/model-switch.integration.test.ts b/src/main/llm/__tests__/model-switch.integration.test.ts
index 2053646a6..f8f8116ed 100644
--- a/src/main/llm/__tests__/model-switch.integration.test.ts
+++ b/src/main/llm/__tests__/model-switch.integration.test.ts
@@ -30,7 +30,9 @@ describe('model switch during startup', () => {
it('does not try another engine after the current load is cancelled', async () => {
const service = new LLMService()
const internals = service as unknown as {
- launchWithFallback(paths: string[]): Promise
+ launchWithFallback(
+ steps: { path: string; backend: string; cpuOnly: boolean }[]
+ ): Promise
launchServer(path: string, args: string[], layers: number): Promise
launchArgsFor(context: number, layers: number): string[]
prepareModelPort(): Promise
@@ -41,9 +43,12 @@ describe('model switch during startup', () => {
return false
})
const prepare = vi.spyOn(internals, 'prepareModelPort').mockResolvedValue(undefined)
- await expect(internals.launchWithFallback(['/old/server', '/old/fallback'])).resolves.toBe(
- false
- )
+ await expect(
+ internals.launchWithFallback([
+ { path: '/old/server', backend: 'metal', cpuOnly: false },
+ { path: '/old/fallback', backend: 'metal', cpuOnly: true }
+ ])
+ ).resolves.toBe(false)
expect(launch).toHaveBeenCalledTimes(1)
expect(prepare).not.toHaveBeenCalled()
})
diff --git a/src/main/llm/__tests__/mtp-launch.integration.test.ts b/src/main/llm/__tests__/mtp-launch.integration.test.ts
index 4943bd0bb..5b217f664 100644
--- a/src/main/llm/__tests__/mtp-launch.integration.test.ts
+++ b/src/main/llm/__tests__/mtp-launch.integration.test.ts
@@ -25,11 +25,17 @@ function gguf(file: string, embeddedMtp: boolean, size = 1024, metadataPadding =
const str = (value: string): Buffer =>
Buffer.concat([u64(Buffer.byteLength(value)), Buffer.from(value)])
const architecture = /gemma-4/i.test(file)
- ? path.basename(file).startsWith('mtp-') ? 'gemma4-assistant' : 'gemma4'
+ ? path.basename(file).startsWith('mtp-')
+ ? 'gemma4-assistant'
+ : 'gemma4'
: 'qwen35'
const values = [Buffer.concat([str('general.architecture'), u32(8), str(architecture)])]
- if (metadataPadding) values.push(Buffer.concat([str('tokenizer.chat_template'), u32(8), str('x'.repeat(metadataPadding))]))
- if (embeddedMtp) values.push(Buffer.concat([str(`${architecture}.nextn_predict_layers`), u32(4), u32(1)]))
+ if (metadataPadding)
+ values.push(
+ Buffer.concat([str('tokenizer.chat_template'), u32(8), str('x'.repeat(metadataPadding))])
+ )
+ if (embeddedMtp)
+ values.push(Buffer.concat([str(`${architecture}.nextn_predict_layers`), u32(4), u32(1)]))
fs.writeFileSync(
file,
Buffer.concat([Buffer.from('GGUF'), u32(3), u64(0), u64(values.length), ...values])
@@ -91,7 +97,10 @@ describe('MTP installation and persisted launch contract', () => {
expect.arrayContaining(['--spec-type', 'draft-mtp', '--spec-draft-model', sidecar])
)
expect(args[args.indexOf('--spec-draft-n-max') + 1]).toBe('2')
- expect(args[args.indexOf('--spec-draft-ngl') + 1]).toBe('99')
+ // A stored 99 (the old "all layers") is Auto now: llama.cpp fits the model and its draft to the
+ // GPU itself, which an explicit layer count would switch off.
+ expect(args).not.toContain('-ngl')
+ expect(args).not.toContain('--spec-draft-ngl')
expect(args).not.toContain('unrelated.gguf')
})
@@ -123,8 +132,9 @@ describe('MTP installation and persisted launch contract', () => {
it.each([-1, 17, 2.5, NaN])('rejects an invalid draft token limit (%s)', async (limit) => {
const service = await load(primary, true)
await service.pause()
- await expect(service.setSettings({ speculativeDraftMax: limit }, { emitSync: false }))
- .rejects.toThrow('Draft token limit')
+ await expect(
+ service.setSettings({ speculativeDraftMax: limit }, { emitSync: false })
+ ).rejects.toThrow('Draft token limit')
expect(service.getSettings().speculativeDraftMax).toBe(0)
})
@@ -175,35 +185,51 @@ describe('MTP installation and persisted launch contract', () => {
it.each([
['unsloth/gemma-4-E4B-it-GGUF', 'gemma-4-E4B-it-Q4_K_M.gguf'],
['unsloth/gemma-4-12b-it-GGUF', 'gemma-4-12b-it-Q4_K_M.gguf']
- ] as const)('offers %s MTP only after its matching companion is installed', async (id, selected) => {
- const { desktopCatalog } = await import('../../models-manager')
- const { LLMService } = await import('../../llm')
- const service = await load(selected, false, 'off')
- const file = MTP_COMPANIONS[id]
- const entry = (await desktopCatalog()).find((model) => model.id === id)
- expect(entry?.files).toContainEqual(file)
- expect(visionStatus(entry!, (name) => fs.existsSync(path.join(profile, 'models', name))))
- .toMatchObject({ supportsMtp: true, mtpInstalled: false })
- expect(service.getSettings()).toMatchObject({ supportsMtp: false, speculativeDecoding: 'off' })
+ ] as const)(
+ 'offers %s MTP only after its matching companion is installed',
+ async (id, selected) => {
+ const { desktopCatalog } = await import('../../models-manager')
+ const { LLMService } = await import('../../llm')
+ const service = await load(selected, false, 'off')
+ const file = MTP_COMPANIONS[id]
+ const entry = (await desktopCatalog()).find((model) => model.id === id)
+ expect(entry?.files).toContainEqual(file)
+ expect(
+ visionStatus(entry!, (name) => fs.existsSync(path.join(profile, 'models', name)))
+ ).toMatchObject({ supportsMtp: true, mtpInstalled: false })
+ expect(service.getSettings()).toMatchObject({
+ supportsMtp: false,
+ speculativeDecoding: 'off'
+ })
- // Real, sparse metadata files exercise installation readiness without downloading weights.
- const wrong = MTP_COMPANIONS[id.includes('E4B')
- ? 'unsloth/gemma-4-12b-it-GGUF' : 'unsloth/gemma-4-E4B-it-GGUF']
- gguf(path.join(profile, 'models', wrong.name), true, wrong.sizeBytes)
- expect(service.getSettings().supportsMtp).toBe(false)
- const sidecar = path.join(profile, 'models', file.name)
- gguf(sidecar, true, file.sizeBytes - 1)
- expect(service.getSettings().supportsMtp).toBe(false)
- gguf(sidecar, true, file.sizeBytes)
- expect(visionStatus(entry!, (name) => fs.existsSync(path.join(profile, 'models', name))))
- .toMatchObject({ mtpInstalled: true })
- await service.pause()
- await service.setSettings({ speculativeDecoding: 'mtp' }, { emitSync: false })
- expect(new LLMService().getSettings()).toMatchObject({ supportsMtp: true, speculativeDecoding: 'mtp' })
+ // Real, sparse metadata files exercise installation readiness without downloading weights.
+ const wrong =
+ MTP_COMPANIONS[
+ id.includes('E4B') ? 'unsloth/gemma-4-12b-it-GGUF' : 'unsloth/gemma-4-E4B-it-GGUF'
+ ]
+ gguf(path.join(profile, 'models', wrong.name), true, wrong.sizeBytes)
+ expect(service.getSettings().supportsMtp).toBe(false)
+ const sidecar = path.join(profile, 'models', file.name)
+ gguf(sidecar, true, file.sizeBytes - 1)
+ expect(service.getSettings().supportsMtp).toBe(false)
+ gguf(sidecar, true, file.sizeBytes)
+ expect(
+ visionStatus(entry!, (name) => fs.existsSync(path.join(profile, 'models', name)))
+ ).toMatchObject({ mtpInstalled: true })
+ await service.pause()
+ await service.setSettings({ speculativeDecoding: 'mtp' }, { emitSync: false })
+ expect(new LLMService().getSettings()).toMatchObject({
+ supportsMtp: true,
+ speculativeDecoding: 'mtp'
+ })
- fs.unlinkSync(sidecar)
- expect(new LLMService().getSettings()).toMatchObject({ supportsMtp: false, speculativeDecoding: 'off' })
- })
+ fs.unlinkSync(sidecar)
+ expect(new LLMService().getSettings()).toMatchObject({
+ supportsMtp: false,
+ speculativeDecoding: 'off'
+ })
+ }
+ )
it.each([primary, 'Ternary-Bonsai-2-27B-PQ2_0.gguf'])(
'preserves N-gram decoding for %s without adding an MTP companion',
diff --git a/src/main/llm/__tests__/select-local-engine.test.ts b/src/main/llm/__tests__/select-local-engine.test.ts
deleted file mode 100644
index 752fc37ce..000000000
--- a/src/main/llm/__tests__/select-local-engine.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { afterEach, expect, it, vi } from 'vitest'
-const mocks = vi.hoisted(() => ({ probe: vi.fn(), exists: vi.fn(() => true) }))
-vi.mock('node:fs', () => ({ default: { existsSync: mocks.exists } }))
-vi.mock('../../runtime-env', () => ({ binRoots: () => ['/bundle'], exe: () => 'llama-server' }))
-vi.mock('../gpu-device-probe', () => ({ gpuDeviceAvailable: mocks.probe }))
-import { selectLocalEngine } from '../select-local-engine'
-afterEach(() => {
- vi.unstubAllGlobals()
- vi.clearAllMocks()
-})
-
-it('prefers a usable CUDA engine on Windows', async () => {
- vi.stubGlobal('process', { ...process, platform: 'win32' })
- mocks.probe.mockResolvedValue(true)
- expect(await selectLocalEngine()).toBe('/bundle/llama-cuda/llama-server')
-})
-it('uses Vulkan when CUDA has no device', async () => {
- vi.stubGlobal('process', { ...process, platform: 'linux' })
- mocks.probe.mockImplementation(async (_path, backend) => backend === 'Vulkan')
- expect(await selectLocalEngine()).toBe('/bundle/llama/llama-server')
-})
-it('preserves the Metal engine on macOS', async () => {
- vi.stubGlobal('process', { ...process, platform: 'darwin' })
- expect(await selectLocalEngine()).toBe('/bundle/llama/llama-server')
- expect(mocks.probe).not.toHaveBeenCalled()
-})
diff --git a/src/main/llm/dedicated-llama-runtime.ts b/src/main/llm/dedicated-llama-runtime.ts
new file mode 100644
index 000000000..4ade1aafb
--- /dev/null
+++ b/src/main/llm/dedicated-llama-runtime.ts
@@ -0,0 +1,322 @@
+import { spawn, type ChildProcess } from 'node:child_process'
+import path from 'node:path'
+import { beginRuntimeBackend, parseNativeBackend } from '../runtime-backends'
+import { demoteBackend } from '../backend-facts'
+import { classifyLlamaError } from '../llama-error'
+import { isPortFree, pickFreePort } from '../free-port'
+import { reapOrphanProcessesOnPort } from '../kill-orphan-port'
+import { selectLocalEngines } from './select-local-engine'
+import { buildLaunchArgs } from './settings-math'
+import { engineSpawnEnv } from './spawn-env'
+
+type Step = Awaited>[number]
+type LaunchArgs = Parameters[0]
+interface Options {
+ id: 'grounding' | 'decision'
+ label: string
+ modality: 'grounding' | 'decision'
+ port: number
+ modelPaths(): Promise<{ model: string; modelPath: string; mmProjPath: string }>
+ extraArgs(step: Step): Omit
+ readyTimeoutMs?: number
+}
+
+const WINDOW_MS = 120_000
+const LIMIT_REASON = 'Stopped repeatedly. Automatic restart limit reached.'
+const GPU_FAULT = /compute error|cuda error|ggml_cuda|device lost|ErrorDeviceLost/i
+const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms))
+
+/** Owns the process, backend ladder and recovery budget for a dedicated server. */
+export class DedicatedLlamaRuntime {
+ private process: ChildProcess | null = null
+ private pending: Promise | null = null
+ private stopping: Promise | null = null
+ private wanted = false
+ private generation = 0
+ private attempted = false
+ private ready = false
+ private model = ''
+ private healthyOnce = false
+ private restarts: number[] = []
+ private faults: { backend: Step['backend']; at: number }[] = []
+ private record?: ReturnType
+ private failure = ''
+ failureCode: string | undefined
+ backend: string | undefined
+ port: number
+
+ constructor(private readonly options: Options) {
+ this.port = options.port
+ }
+
+ get isRunning(): boolean {
+ return this.ready && this.processAlive
+ }
+
+ /** Running until the process reports an exit code or signal (a sent kill is not an exit yet). */
+ private get processAlive(): boolean {
+ return this.process !== null && this.process.exitCode == null && this.process.signalCode == null
+ }
+
+ shouldBeRunning(): boolean {
+ return this.wanted
+ }
+ recordRequest(): void {
+ this.record?.recordRequest()
+ }
+
+ async health(): Promise {
+ const child = this.process
+ if (!this.processAlive) return false
+ try {
+ const response = await fetch(`http://127.0.0.1:${this.port}/health`, {
+ signal: AbortSignal.timeout(3_000)
+ })
+ return response.status === 200 && this.process === child && this.processAlive
+ } catch {
+ return false
+ }
+ }
+
+ async start(): Promise {
+ if (this.stopping) await this.stopping
+ if (this.pending) return this.pending
+ if (this.isRunning) return
+ this.wanted = true
+ const generation = this.generation
+ this.pending = this.run(generation).finally(() => {
+ this.pending = null
+ })
+ return this.pending
+ }
+
+ private assertWanted(generation: number): void {
+ if (!this.wanted || generation !== this.generation)
+ throw new Error(`${this.options.label} stopped.`)
+ }
+
+ private async run(generation: number): Promise {
+ for (;;) {
+ this.assertWanted(generation)
+ if (this.attempted) {
+ this.restarts = this.restarts.filter((at) => Date.now() - at < WINDOW_MS)
+ if (this.restarts.length >= 3) {
+ this.failureCode = 'startup'
+ this.failure = LIMIT_REASON
+ // A failed lease is terminal. Publish the budget failure on a fresh lease.
+ this.record = beginRuntimeBackend(this.options.id, this.model || this.options.label)
+ this.record.fail(LIMIT_REASON)
+ throw new Error(LIMIT_REASON)
+ }
+ this.restarts.push(Date.now())
+ await delay(1_000 * this.restarts.length)
+ this.assertWanted(generation)
+ }
+ this.attempted = true
+ try {
+ await this.launch(generation)
+ return
+ } catch (error) {
+ this.assertWanted(generation)
+ if (!this.healthyOnce) throw error
+ // Recovery attempts that fail to load use the same budget as crashes.
+ }
+ }
+ }
+
+ private async launch(generation: number): Promise {
+ let paths: Awaited>
+ try {
+ paths = await this.options.modelPaths()
+ } catch (error) {
+ this.assertWanted(generation)
+ this.record = beginRuntimeBackend(this.options.id, this.model || this.options.label)
+ this.record.fail(error)
+ throw error
+ }
+ this.model = paths.model
+ this.assertWanted(generation)
+ this.record = beginRuntimeBackend(this.options.id, paths.model)
+ const failures: string[] = []
+ this.failureCode = undefined
+ try {
+ reapOrphanProcessesOnPort(
+ this.options.port,
+ (command) => /llama-server/i.test(command),
+ `${this.options.label} llama-server`
+ )
+ const port = await pickFreePort(this.options.port, isPortFree, 20)
+ if (port === null)
+ throw new Error(`No private port is available for the ${this.options.label} runtime.`)
+ this.port = port
+ const steps = await selectLocalEngines(this.options.modality)
+ for (const step of steps) {
+ this.assertWanted(generation)
+ let stderr = ''
+ let output = ''
+ let loading = true
+ let faultedAt: number | undefined
+ let spawnError: Error | undefined
+ const record = this.record
+ const child = spawn(
+ step.path,
+ buildLaunchArgs({
+ ...this.options.extraArgs(step),
+ modelPath: paths.modelPath,
+ mmProjPath: paths.mmProjPath,
+ port,
+ cpuOnly: step.cpuOnly
+ }),
+ {
+ env: {
+ ...process.env,
+ ...engineSpawnEnv({
+ platform: process.platform,
+ binDir: path.dirname(step.path),
+ currentEnv: process.env
+ })
+ },
+ stdio: ['ignore', 'ignore', 'pipe']
+ }
+ )
+ this.process = child
+ child.on('error', (error) => {
+ spawnError = error
+ })
+ child.stderr!.on('data', (chunk) => {
+ if (loading) {
+ const text = String(chunk)
+ stderr = (stderr + text).slice(-16_384)
+ output = (output + text).slice(-65_536)
+ record.observe(text)
+ console.log(`[${this.options.label} runtime] ${text}`)
+ } else if (GPU_FAULT.test(String(chunk))) {
+ faultedAt = Date.now() // Do not retain or log request text.
+ }
+ })
+ child.once('close', (code, signal) => {
+ if (this.process !== child) return
+ this.process = null
+ this.ready = false
+ if (loading || !this.wanted || generation !== this.generation) return
+ const now = Date.now()
+ const gpuFault = faultedAt !== undefined && now - faultedAt < WINDOW_MS
+ this.failure = gpuFault
+ ? `${this.options.label} GPU compute failed.`
+ : `${this.options.label} runtime stopped unexpectedly (${signal ?? code ?? 'unknown'}).`
+ this.faults = this.faults.filter((fault) => now - fault.at < WINDOW_MS)
+ if (gpuFault && step.backend !== 'cpu') {
+ this.faults.push({ backend: step.backend, at: now })
+ if (this.faults.filter((fault) => fault.backend === step.backend).length >= 2)
+ demoteBackend(this.options.modality, step.backend)
+ }
+ record.fail(this.failure)
+ // Wait for a start that has just completed before requesting recovery.
+ void (async () => {
+ await this.pending?.catch(() => {})
+ if (this.wanted && generation === this.generation) await this.start()
+ })().catch(() => {})
+ })
+ try {
+ const startedAt = Date.now()
+ for (;;) {
+ this.assertWanted(generation)
+ if (spawnError) throw spawnError
+ if (!this.processAlive)
+ throw new Error(`${this.options.label} runtime stopped during startup.`)
+ if (Date.now() - startedAt > (this.options.readyTimeoutMs ?? Infinity))
+ throw new Error(`${this.options.label} runtime timed out during startup.`)
+ if (await this.health()) break
+ await delay(100)
+ }
+ this.assertWanted(generation)
+ loading = false
+ this.ready = true
+ const placement = parseNativeBackend(output)
+ this.backend = step.cpuOnly ? 'CPU' : (placement?.backend ?? step.backend)
+ record.ready(
+ this.backend,
+ placement?.device,
+ failures.join(' ') || this.failure || undefined
+ )
+ stderr = ''
+ output = ''
+ this.failure = ''
+ this.healthyOnce = true
+ return
+ } catch (error) {
+ const classified =
+ classifyLlamaError(stderr) ??
+ (/out of memory|cannot allocate|metal.*alloc|cuda.*memory/i.test(stderr)
+ ? { code: 'out_of_memory', reason: 'The device does not have enough free memory.' }
+ : null)
+ const code: string | undefined = classified?.code
+ if (this.failureCode !== 'out_of_memory') this.failureCode = code ?? this.failureCode
+ const reason =
+ classified?.reason ?? (error instanceof Error ? error.message : String(error))
+ failures.push(reason)
+ // Also accepts gpu_fault when the shared classifier adds that code.
+ if (code === 'gpu_fault' && this.failureCode !== 'out_of_memory')
+ this.failureCode = 'gpu_fault'
+ await this.closeProcess()
+ this.assertWanted(generation)
+ }
+ }
+ throw new Error(failures.join(' ') || `The bundled ${this.options.label} engine is missing.`)
+ } catch (error) {
+ this.assertWanted(generation)
+ this.failure = error instanceof Error ? error.message : String(error)
+ this.record.fail(this.failure)
+ throw new Error(this.failure)
+ }
+ }
+
+ private async closeProcess(): Promise {
+ const child = this.process
+ this.process = null
+ this.ready = false
+ if (!child) return
+ const closed = new Promise((resolve) => child.once('close', () => resolve()))
+ if (child.exitCode != null || child.signalCode != null) {
+ // Already exited: its streams close shortly (or closed before this listener was added).
+ await Promise.race([closed, delay(1_000)])
+ return
+ }
+ child.kill('SIGTERM')
+ let timer: ReturnType | undefined
+ const exited = await Promise.race([
+ closed.then(() => true),
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve(false), 1_000)
+ })
+ ])
+ clearTimeout(timer)
+ if (!exited) {
+ child.kill('SIGKILL')
+ await closed
+ }
+ }
+
+ async stop(): Promise {
+ this.wanted = false
+ this.generation += 1
+ if (this.stopping) return this.stopping
+ this.record?.stop()
+ this.stopping = (async () => {
+ await this.closeProcess()
+ await this.pending?.catch(() => {})
+ this.attempted = false
+ this.healthyOnce = false
+ })().finally(() => {
+ this.stopping = null
+ })
+ return this.stopping
+ }
+
+ async restart(): Promise {
+ if (!this.wanted) return
+ await this.stop()
+ this.attempted = true
+ await this.start()
+ }
+}
diff --git a/src/main/llm/engine-priority.ts b/src/main/llm/engine-priority.ts
deleted file mode 100644
index 0bb95f333..000000000
--- a/src/main/llm/engine-priority.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/** Search every binary root for GPU engines before accepting a CPU build. */
-export function enginePriority(serverPath: string): number {
- const directory = serverPath.replaceAll('\\', '/').split('/').at(-2) ?? ''
- if (directory.endsWith('-cuda')) return 0
- if (directory.endsWith('-cpu')) return 2
- return 1
-}
diff --git a/src/main/llm/http-post.ts b/src/main/llm/http-post.ts
index 1ae2acb21..841b7a6b2 100644
--- a/src/main/llm/http-post.ts
+++ b/src/main/llm/http-post.ts
@@ -10,6 +10,7 @@
// integration test drives postCompletionOnce against a real socket-closing server (behaviour).
import * as http from 'http'
+import { GPU_FAULT_PATTERN } from '../llama-error'
/**
* WHY a failure happened, decided by the only layer that can see it.
@@ -20,12 +21,14 @@ import * as http from 'http'
* identical request can never succeed against this model.
* - `rejected` — the server is up and refused this request (malformed body, uncompilable grammar).
* Also deterministic for this input.
+ * - `device_fault` — the GPU failed while answering; recover without changing the model.
* - `aborted` — the caller cancelled. Not a failure of the request at all.
*
* A caller must NOT re-derive this from the message text. Capture did exactly that, called every
* refusal `capture-model-unavailable`, and retried a permanently broken request 32 times.
*/
-export type ModelServerFailureKind = 'unavailable' | 'overflow' | 'rejected' | 'aborted'
+export type ModelServerFailureKind =
+ 'unavailable' | 'overflow' | 'rejected' | 'aborted' | 'device_fault'
export class ModelServerError extends Error {
readonly kind: ModelServerFailureKind
@@ -56,7 +59,7 @@ function serverDetail(body: string): string {
* llama-server returns a JSON body like {"error":{"message":"request (22825 tokens) exceeds
* the available context size (16384 tokens) …"}}; the bare status code alone is useless both
* to the user and to a retry policy. */
-export function classifyServerError(
+function classifyServerError(
statusCode: number | undefined,
body: string
): { kind: ModelServerFailureKind; message: string } {
@@ -87,6 +90,9 @@ export function classifyServerError(
message: `LLM Server Error: ${statusCode ?? '?'}${detail ? ` ${detail}` : ''}`
}
}
+ if (GPU_FAULT_PATTERN.test(detail)) {
+ return { kind: 'device_fault', message: 'The GPU stopped responding while answering.' }
+ }
// A 4xx means the server understood the request and refused it. Anything else — 502, 503, 504, a
// bare 500 — is the server failing to service it, which is the definition of an outage.
//
@@ -98,11 +104,6 @@ export function classifyServerError(
return { kind, message: `LLM Server Error: ${statusCode ?? '?'}${detail ? ` ${detail}` : ''}` }
}
-/** Turn a non-200 model-server response into an ACTIONABLE message. */
-export function describeServerError(statusCode: number | undefined, body: string): string {
- return classifyServerError(statusCode, body).message
-}
-
/** The typed rejection for a non-200 response, carrying the reason with the message. */
export function serverResponseError(
statusCode: number | undefined,
@@ -181,6 +182,18 @@ export function postCompletionOnce(
const req = http.request(
modelRequestOptions(port, Buffer.byteLength(body), requestPath),
(res) => {
+ res.on('error', (error) =>
+ finish(() =>
+ reject(
+ new ModelServerError(
+ 'unavailable',
+ 'The model server connection closed.',
+ undefined,
+ error
+ )
+ )
+ )
+ )
let data = ''
res.on('data', (chunk) => {
data += chunk
diff --git a/src/main/llm/remote-chat.ts b/src/main/llm/remote-chat.ts
index 3ded3949c..46f2ad33f 100644
--- a/src/main/llm/remote-chat.ts
+++ b/src/main/llm/remote-chat.ts
@@ -199,7 +199,7 @@ export function remoteModelUnreachable(code: string): string | null {
/** Keep remote transport errors useful without exposing the endpoint, headers,
* API key, or request body. */
-export function remoteTextModelTransportError(error: unknown): Error {
+function remoteTextModelTransportError(error: unknown): Error {
if (!(error instanceof Error)) return new Error('Remote text model request failed.')
const cause = error.cause
if (!cause || typeof cause !== 'object') {
diff --git a/src/main/llm/select-local-engine.ts b/src/main/llm/select-local-engine.ts
index a23d01aab..09f5e1256 100644
--- a/src/main/llm/select-local-engine.ts
+++ b/src/main/llm/select-local-engine.ts
@@ -1,31 +1,8 @@
-import fs from 'node:fs'
-import path from 'node:path'
-import { binRoots, exe } from '../runtime-env'
-import { gpuDeviceAvailable } from './gpu-device-probe'
-import { prioritizeBackend, type BackendPreference } from '../../shared/backend-preferences'
+import { nativeLadder, type NativeStep } from '../backend-facts'
-/** Dedicated decision and grounding models use the same GPU priority as chat. */
-export async function selectLocalEngine(preference: BackendPreference = 'auto'): Promise {
- const platform = process.platform
- const base = ['llama-cuda', 'llama', 'llama-cpu', '']
- const directories = preference === 'cpu'
- ? ['llama-cpu', ...base.filter((directory) => directory !== 'llama-cpu')]
- : prioritizeBackend(base, preference, (directory) =>
- directory === 'llama-cpu' ? 'cpu' : directory === 'llama-cuda' ? 'cuda' : platform === 'darwin' ? 'metal' : 'vulkan'
- )
- for (const directory of directories) {
- if (platform !== 'win32' && platform !== 'linux' && directory === 'llama-cuda') continue
- for (const root of binRoots()) {
- const candidate = path.join(root, directory, exe('llama-server'))
- if (!fs.existsSync(candidate)) continue
- if (platform === 'win32' || platform === 'linux') {
- const backend =
- directory === 'llama-cuda' ? 'CUDA' : directory === 'llama' ? 'Vulkan' : null
- if (backend && !(await gpuDeviceAvailable(candidate, backend, platform))) continue
- }
- console.log(`[Local model] selected engine=${directory || 'legacy'}`)
- return candidate
- }
- }
- return undefined
+/** Grounding, decision and chat share the native backend policy. */
+export function selectLocalEngines(
+ modality: 'grounding' | 'decision' | 'llm'
+): Promise {
+ return nativeLadder(modality)
}
diff --git a/src/main/llm/settings-math.ts b/src/main/llm/settings-math.ts
index f56df76b8..c8b901fe2 100644
--- a/src/main/llm/settings-math.ts
+++ b/src/main/llm/settings-math.ts
@@ -97,7 +97,7 @@ export interface LaunchState {
ctxSize: number
kvCacheType: KvCacheType
flashAttn: boolean
- gpuLayers: number
+ gpuLayers?: number | null
threads: number | undefined
batchSize: number | undefined
speculativeDecoding?: SpeculativeDecodingMode
@@ -112,11 +112,12 @@ export interface LaunchState {
* file sizes) and stays in LLMService; everything here is a pure function of these
* inputs so the terminal artifact (the args array handed to llama-server) is testable. */
export interface LaunchArgsInput {
+ cpuOnly?: boolean
modelPath: string
mmProjPath: string // empty string = text-only (no --mmproj)
port: number
effectiveCtxSize: number // already RAM-clamped
- gpuLayers: number
+ gpuLayers?: number | null
flashAttn: boolean
kvCacheType: KvCacheType
threads: number | undefined
@@ -177,7 +178,7 @@ export function slotInput(i: LaunchArgsInput): LlmSlotInput {
* 16 GB machine 8 GB of cache next to the model invites swapping, so it is capped at a quarter of
* RAM; machines with 32 GB or more keep the engine default.
*/
-export function promptCacheMiB(totalRamBytes: number | undefined): number | undefined {
+function promptCacheMiB(totalRamBytes: number | undefined): number | undefined {
if (!totalRamBytes || !Number.isFinite(totalRamBytes)) return undefined
const quarter = Math.floor(totalRamBytes / 1024 / 1024 / 4)
return quarter >= 8192 ? undefined : Math.max(1024, quarter)
@@ -201,10 +202,10 @@ export function buildLaunchArgs(i: LaunchArgsInput): string[] {
'-c',
// Team mode may grow the shared pool past the person's context so every slot gets its share;
// it is never smaller. Single mode: exactly the person's context.
- String(llmSlotPlan(slotInput(i)).poolCtx),
- '-ngl',
- String(i.gpuLayers)
+ String(llmSlotPlan(slotInput(i)).poolCtx)
)
+ if (i.cpuOnly) args.push('--device', 'none', '-ngl', '0')
+ else if (typeof i.gpuLayers === 'number') args.push('-ngl', String(i.gpuLayers))
// Hybrid models (Qwen3.5, Bonsai 2) cannot roll recurrent state back past a saved
// context checkpoint. At the 8192-token default spacing, any edit earlier in a shorter
// conversation (a dropped screenshot, a compacted turn) re-processes the whole prompt;
@@ -230,7 +231,7 @@ export function buildLaunchArgs(i: LaunchArgsInput): string[] {
if (kv.k !== 'f16' || kv.v !== 'f16') {
args.push('--cache-type-k', kv.k, '--cache-type-v', kv.v)
}
- if (typeof i.threads === 'number') {
+ if (typeof i.threads === 'number' && i.threads > 0) {
args.push('-t', String(i.threads))
}
if (typeof i.batchSize === 'number') {
@@ -247,7 +248,7 @@ export function buildLaunchArgs(i: LaunchArgsInput): string[] {
const draftMax = i.speculativeDraftMax ? String(i.speculativeDraftMax) : '2'
args.push('--spec-type', 'draft-mtp', '--spec-draft-n-max', draftMax)
if (i.draftModelPath) {
- args.push('--spec-draft-model', i.draftModelPath, '--spec-draft-ngl', String(i.gpuLayers))
+ args.push('--spec-draft-model', i.draftModelPath)
}
} else if (
i.draftModelPath &&
@@ -261,6 +262,10 @@ export function buildLaunchArgs(i: LaunchArgsInput): string[] {
)
if (i.speculativeDraftMax) args.push('--spec-draft-n-max', String(i.speculativeDraftMax))
}
+ if (i.draftModelPath && ['mtp', 'draft', 'dflash'].includes(i.speculativeDecoding ?? '')) {
+ if (i.cpuOnly) args.push('--spec-draft-ngl', '0')
+ else if (typeof i.gpuLayers === 'number') args.push('--spec-draft-ngl', String(i.gpuLayers))
+ }
// Grounding models (UI-TARS / Qwen-VL) need a minimum image-token budget or
// clicks land in the wrong place; only set when a projector is present.
if (i.mmProjPath && typeof i.imageMinTokens === 'number') {
@@ -278,7 +283,7 @@ export function launchArgsChanged(
ctxSize?: number
kvCacheType?: KvCacheType
flashAttn?: boolean
- gpuLayers?: number
+ gpuLayers?: number | null
threads?: number
batchSize?: number
speculativeDecoding?: SpeculativeDecodingMode
@@ -295,7 +300,7 @@ export function launchArgsChanged(
(typeof patch.ctxSize === 'number' && patch.ctxSize !== current.ctxSize) ||
(patch.kvCacheType !== undefined && patch.kvCacheType !== current.kvCacheType) ||
(typeof patch.flashAttn === 'boolean' && patch.flashAttn !== current.flashAttn) ||
- (typeof patch.gpuLayers === 'number' && patch.gpuLayers !== current.gpuLayers) ||
+ (patch.gpuLayers !== undefined && patch.gpuLayers !== current.gpuLayers) ||
(typeof patch.threads === 'number' && patch.threads !== current.threads) ||
(typeof patch.batchSize === 'number' && patch.batchSize !== current.batchSize) ||
(patch.speculativeDecoding !== undefined &&
diff --git a/src/main/llm/spawn-env.ts b/src/main/llm/spawn-env.ts
index 0c250411d..7892f6e5f 100644
--- a/src/main/llm/spawn-env.ts
+++ b/src/main/llm/spawn-env.ts
@@ -55,7 +55,13 @@ export interface EngineSpawnEnvInput {
*/
export function engineSpawnEnv(i: EngineSpawnEnvInput): Record {
const pathApi = i.platform === 'win32' ? path.win32 : path.posix
- const cudaRuntimeDir = i.binDir.endsWith('-cuda')
+ const folder = pathApi.basename(i.binDir)
+ const cudaEnv: Record =
+ (folder.endsWith('-cuda') || (i.platform === 'win32' && folder === 'whisper')) &&
+ i.currentEnv?.CUDA_CACHE_MAXSIZE === undefined
+ ? { CUDA_CACHE_MAXSIZE: '4294967296' }
+ : {}
+ const cudaRuntimeDir = folder.endsWith('-cuda')
? pathApi.join(pathApi.dirname(i.binDir), 'cuda-runtime')
: null
if (i.platform === 'linux') {
@@ -63,10 +69,12 @@ export function engineSpawnEnv(i: EngineSpawnEnvInput): Record {
const libraryDirs = cudaRuntimeDir ? [i.binDir, cudaRuntimeDir] : [i.binDir]
if (inherited.LD_LIBRARY_PATH) libraryDirs.push(inherited.LD_LIBRARY_PATH)
return {
+ ...cudaEnv,
LD_LIBRARY_PATH: libraryDirs.join(path.posix.delimiter)
}
}
const env: Record = {
+ ...cudaEnv,
// macOS: rpath for the co-located dylibs. Harmless elsewhere.
DYLD_LIBRARY_PATH: i.binDir
}
diff --git a/src/main/llm/stream.ts b/src/main/llm/stream.ts
index 7c2f2ab98..4a239353b 100644
--- a/src/main/llm/stream.ts
+++ b/src/main/llm/stream.ts
@@ -18,7 +18,7 @@ import {
type SseTimings,
type SseUsage
} from './sse-stream'
-import { modelRequestOptions, serverResponseError } from './http-post'
+import { ModelServerError, modelRequestOptions, serverResponseError } from './http-post'
import { generationMetrics, type GenerationMetrics } from '../../shared/generation-metrics'
/** Injectable clock so the accumulator's timing is testable without waiting in real time. */
@@ -193,6 +193,19 @@ export function streamCompletion(
armIdleTimer()
const req = http.request(modelRequestOptions(port, Buffer.byteLength(body)), (res) => {
+ res.on('error', (error) => {
+ cleanup()
+ if (!timedOut && !aborted) {
+ reject(
+ new ModelServerError(
+ 'unavailable',
+ 'The model server connection closed.',
+ undefined,
+ error
+ )
+ )
+ }
+ })
if (res.statusCode !== 200) {
// Read the server's error body (small, capped) so we surface an ACTIONABLE
// message (e.g. context overflow from too many connectors, or a tool schema
diff --git a/src/main/mcp-auth.ts b/src/main/mcp-auth.ts
index 97081f90c..65e837373 100644
--- a/src/main/mcp-auth.ts
+++ b/src/main/mcp-auth.ts
@@ -9,32 +9,19 @@ import path from 'path'
import type http from 'http'
import { randomBytes } from 'crypto'
import { app } from 'electron'
-import { authorizeBearer, authorizeBearerAny } from './mcp-auth-logic'
+import { authorizeBearer } from './mcp-auth-logic'
let cached: string | null = null
-/** Supplies the tokens that authorize action tools RIGHT NOW - one per paired device that may
- * run this Mac's tools. Registered by the pro sync layer, which owns the pairing set; core
- * stays free of any pairing/device knowledge. Un-pairing a device drops its token from this
- * list, so its next call is rejected - that is what makes tool access revoke on un-pair. */
-export type ActiveActionTokens = () => readonly string[]
-
-export interface ActiveActionCredential {
+interface ActiveActionCredential {
deviceId: string
token: string
}
export type ActiveActionCredentials = () => readonly ActiveActionCredential[]
-let activeActionTokens: ActiveActionTokens | null = null
let activeActionCredentials: ActiveActionCredentials | null = null
-/** Install (or clear, with null) the live per-device token provider. When set, ONLY those
- * tokens authorize; the legacy single global token is ignored. */
-export function registerActiveActionTokens(provider: ActiveActionTokens | null): void {
- activeActionTokens = provider
-}
-
/** Install the device-bound credentials owned by the paired-device layer. */
export function registerActiveActionCredentials(provider: ActiveActionCredentials | null): void {
activeActionCredentials = provider
@@ -60,9 +47,6 @@ export function authorizeActionRequest(req: http.IncomingMessage): ActionRequest
}
return { allowed: deviceId !== null, deviceId }
}
- if (activeActionTokens) {
- return { allowed: authorizeBearerAny(provided, activeActionTokens()), deviceId: null }
- }
return { allowed: authorizeBearer(provided, getActionToken()), deviceId: null }
}
@@ -72,7 +56,7 @@ function tokenPath(): string {
/** The action-tool token, generating + persisting one on first use. 32 random
* bytes (64 hex chars). Owner-only file perms. */
-export function getActionToken(): string {
+function getActionToken(): string {
if (cached) {
return cached
}
@@ -95,17 +79,6 @@ export function getActionToken(): string {
return token
}
-/** True when the request carries a valid action-tool token. Unauthenticated
- * requests still get the open model tools - just not the action tools.
- *
- * When the pro sync layer has registered a live-token provider, ONLY the tokens
- * of currently paired + tools-allowed devices authorize (per-device, revoked on
- * un-pair). With no provider (free build / no device sync), it falls back to the
- * legacy single global token, which is never distributed in that build anyway. */
-export function isActionAuthorized(req: http.IncomingMessage): boolean {
- return authorizeActionRequest(req).allowed
-}
-
/** Dev-only: print the action token so a device can be paired for testing. In a
* packaged build the token is NEVER logged (that would defeat the gate); the
* shipped path surfaces it in a Settings copy-field instead. */
diff --git a/src/main/mcp-server.ts b/src/main/mcp-server.ts
index c70f4ef84..9335b7ab0 100644
--- a/src/main/mcp-server.ts
+++ b/src/main/mcp-server.ts
@@ -20,6 +20,7 @@ import { llm } from './llm'
import { generateImage, imageGenStatus } from './imagegen'
import * as tts from './tts'
import { embeddings } from './embeddings'
+import { DEFAULT_EMBEDDER } from './embedding-models'
import { mediaText } from './media-text'
import { parseDataUrl } from './mcp-parse-data-url'
import { runTool, getToolExtensions } from './tools'
@@ -285,7 +286,11 @@ export function buildMcpServer(
: await embeddings.generateEmbedding(input)
return {
content: [{ type: 'text', text: JSON.stringify(embedding) }],
- structuredContent: { model: 'all-MiniLM-L6-v2', dimensions: embedding.length, embedding }
+ structuredContent: {
+ model: DEFAULT_EMBEDDER.model.replace(/^Xenova\//, ''),
+ dimensions: embedding.length,
+ embedding
+ }
}
}
)
diff --git a/src/main/modality-queue/__tests__/jobs.test.ts b/src/main/modality-queue/__tests__/jobs.test.ts
index 9faac8ad9..1667fd49a 100644
--- a/src/main/modality-queue/__tests__/jobs.test.ts
+++ b/src/main/modality-queue/__tests__/jobs.test.ts
@@ -12,8 +12,13 @@ describe('standard modality jobs', () => {
expect(CHAT_JOB).toEqual({ tier: 2, label: 'chat', uses: { id: 'llm' }, evicts: ['image'] })
})
- it('IMAGE_JOB evicts the resident LLM (tier 2)', () => {
- expect(IMAGE_JOB).toEqual({ tier: 2, label: 'image', uses: { id: 'image' }, evicts: ['llm'] })
+ it('IMAGE_JOB evicts the resident LLM and the task engines (tier 2)', () => {
+ expect(IMAGE_JOB).toEqual({
+ tier: 2,
+ label: 'image',
+ uses: { id: 'image' },
+ evicts: ['llm', 'grounding', 'decision']
+ })
})
it('they evict each other (mutually exclusive on unified memory)', () => {
diff --git a/src/main/modality-queue/queue.ts b/src/main/modality-queue/queue.ts
index 98f9ea346..8bff59acb 100644
--- a/src/main/modality-queue/queue.ts
+++ b/src/main/modality-queue/queue.ts
@@ -56,8 +56,8 @@ export interface QueueRequest {
// The two standard heavy jobs, defined once instead of spelled out at every
// call site (chat was inlined 4× in ipc.ts). A chat/tool turn evicts a resident
-// image server; image generation evicts the resident LLM — they can't co-reside
-// on unified memory. Frozen so a caller can't mutate the shared descriptor.
+// image server; image generation evicts the resident LLM and the task engines (grounding,
+// decision) — they can't co-reside on unified memory or on one GPU's VRAM. Frozen so a caller can't mutate the shared descriptor.
export const CHAT_JOB: QueueRequest = Object.freeze({
tier: 2,
label: 'chat',
@@ -68,7 +68,7 @@ export const IMAGE_JOB: QueueRequest = Object.freeze({
tier: 2,
label: 'image',
uses: { id: 'image' },
- evicts: ['llm']
+ evicts: ['llm', 'grounding', 'decision']
})
// Background model work (capture distill, replay-vision, secretary/agent passes).
@@ -104,7 +104,7 @@ export function backgroundJob(label: string): QueueRequest {
}
/** Why a preempted job's model request was aborted. */
-export class PreemptedError extends Error {
+class PreemptedError extends Error {
constructor(readonly by: string) {
super(`Stopped to make room for ${by}; it runs again later.`)
this.name = 'PreemptedError'
diff --git a/src/main/model-server.ts b/src/main/model-server.ts
index d5e22a230..eca88c95e 100644
--- a/src/main/model-server.ts
+++ b/src/main/model-server.ts
@@ -51,6 +51,7 @@ import { whisperModel } from './media-text'
import { getActiveModal } from './active-models'
import { modalityForKind } from './active-models-logic'
import { embeddings } from './embeddings'
+import { DEFAULT_EMBEDDER } from './embedding-models'
import { docsText, docsHtml, openApiSpec } from './api-docs'
import { handleMcpRequest } from './mcp-server'
import { logActionTokenForDev } from './mcp-auth'
@@ -78,7 +79,16 @@ import {
} from './model-server/chat-messages'
import { applyThinkingPayload, requestedThinking } from './llm/chat-payload'
import { parseMultipart } from './model-server/multipart'
-import { tagLlmEntries, modelEntry, ollamaMirror, specialistModelEntries, installedTextModelEntries, installedImageModelEntries, leadWithActiveChatModel, liveChatCapabilities } from './model-server/models-list'
+import {
+ tagLlmEntries,
+ modelEntry,
+ ollamaMirror,
+ specialistModelEntries,
+ installedTextModelEntries,
+ installedImageModelEntries,
+ leadWithActiveChatModel,
+ liveChatCapabilities
+} from './model-server/models-list'
import { buildGatewayModalities, type GatewayModalities } from './model-server/health'
import { safeProxyResponse } from './model-server/proxy-response'
import { writeDiagnosticLog } from './diagnostics-log'
@@ -1527,9 +1537,9 @@ async function handleTeamCompletion(
}
// ─── Embeddings (local MiniLM) ───────────────────────────────────────────────
-// Served by the in-app embedder (all-MiniLM-L6-v2) rather than proxied to
-// llama-server, which isn't started with --embeddings. Same model the RAG/memory
-// layer uses, so vectors are consistent across the app.
+// Served by the in-app embedder (DEFAULT_EMBEDDER, all-MiniLM-L6-v2) rather than
+// proxied to llama-server, which isn't started with --embeddings. Same path every
+// in-app embedding takes (embeddings.embed).
async function handleEmbeddings(
req: http.IncomingMessage,
res: http.ServerResponse,
@@ -1549,19 +1559,22 @@ async function handleEmbeddings(
return
}
const run = async (): Promise => {
- const data: { object: string; index: number; embedding: number[] }[] = []
+ const vectors: number[][] = []
if (teamMode()) {
// Team mode: the whole input in one forward pass, ahead of background indexing.
- const vectors = await embedCoalesced(inputs)
- vectors.forEach((embedding, index) => data.push({ object: 'embedding', index, embedding }))
- } else for (let i = 0; i < inputs.length; i++) {
- const embedding = await embeddings.generateEmbedding(inputs[i]!)
- data.push({ object: 'embedding', index: i, embedding })
+ vectors.push(...(await embedCoalesced(inputs)))
+ } else {
+ // One forward pass per batch: padding to the longest input stays bounded.
+ for (let i = 0; i < inputs.length; i += 32) {
+ vectors.push(
+ ...(await embeddings.embed(inputs.slice(i, i + 32), { model: DEFAULT_EMBEDDER.model }))
+ )
+ }
}
return {
object: 'list',
- data,
- model: 'all-MiniLM-L6-v2',
+ data: vectors.map((embedding, index) => ({ object: 'embedding', index, embedding })),
+ model: DEFAULT_EMBEDDER.model.replace(/^Xenova\//, ''),
usage: { prompt_tokens: 0, total_tokens: 0 }
}
}
@@ -1692,9 +1705,15 @@ async function handleModelsList(
(whisperModel() ? path.basename(whisperModel() as string) : null)
const transcription = sttId ? [tag(sttId, 'transcription')] : []
- const { desktopCatalog, listInstalled, getCatalog, getActiveModel } = await import('./models-manager')
+ const { desktopCatalog, listInstalled, getCatalog, getActiveModel } =
+ await import('./models-manager')
const installed = await listInstalled()
- const catalog = (await getCatalog()).models as { id: string; name: string; kind: string; remoteServerId?: string }[]
+ const catalog = (await getCatalog()).models as {
+ id: string
+ name: string
+ kind: string
+ remoteServerId?: string
+ }[]
if (!activeRemote) {
const downloaded = installedTextModelEntries(catalog, installed, now)
if (downloaded.length) text = downloaded
@@ -2319,7 +2338,10 @@ export async function startModelServer(port = GATEWAY_PORT): Promise {
json(
res,
503,
- errBody(locked ? 'API key storage is locked.' : 'API key storage is unavailable.', 'server_error')
+ errBody(
+ locked ? 'API key storage is locked.' : 'API key storage is unavailable.',
+ 'server_error'
+ )
)
return
}
diff --git a/src/main/model-sizing.ts b/src/main/model-sizing.ts
index 769a10c24..e99bc9d03 100644
--- a/src/main/model-sizing.ts
+++ b/src/main/model-sizing.ts
@@ -12,15 +12,7 @@
// The RAM-fit verdict is defined once in shared/ and re-exported here so existing
// main-process importers (setup.ts, tests) are unchanged while the renderer badge
// imports the same source directly — one rule, no drift.
-export {
- fitLevel,
- fitTier,
- isLoadableOnDevice,
- FIT_OK_FRAC,
- FIT_TIGHT_FRAC,
- type FitLevel,
- type FitTier
-} from '../shared/model-fit'
+export { fitLevel, fitTier, isLoadableOnDevice } from '../shared/model-fit'
import { FIT_SOFT_FRAC, FIT_CEIL_FRAC } from '../shared/model-fit'
export type KvCacheType = 'f16' | 'q8_0' | 'q4_0'
@@ -136,8 +128,8 @@ export function preferredModelIds(ramGb: number, mode: PerformanceMode): string[
// ceiling, offers "Load anyway" instead of a dead end. Only the physics floor
// below can truly refuse a load — the point where the OS would kill the app.
-// fitTier / isLoadableOnDevice / FitTier now live in ../shared/model-fit (re-exported
-// above) so the renderer browse chip and this loader share ONE definition + fractions.
+// fitTier / isLoadableOnDevice / FitTier now live in ../shared/model-fit (the functions are
+// re-exported above) so the renderer browse chip and this loader share ONE definition + fractions.
/** The hard headroom (GB) a "Load anyway" must still leave free after the model's
* weights are resident, or the OS starts killing the app. Below this we refuse
@@ -158,14 +150,14 @@ export function checkOverrideSurvival(opts: { availGb: number; incomingWeightsGb
export interface LoadAttempt {
ctxSize: number
- gpuLayers: number
+ gpuLayers: number | null
reason: string
}
/** Try GPU, then CPU at the selected context. Never reduce the selected value. */
-export function loadAttempts(requestedCtx: number, gpuLayers: number): LoadAttempt[] {
+export function loadAttempts(requestedCtx: number, gpuLayers: number | null): LoadAttempt[] {
const out: LoadAttempt[] = [{ ctxSize: requestedCtx, gpuLayers, reason: 'requested' }]
- if (gpuLayers > 0) {
+ if (gpuLayers === null || gpuLayers > 0) {
out.push({ ctxSize: requestedCtx, gpuLayers: 0, reason: 'CPU-only' })
}
return out
diff --git a/src/main/models-manager.ts b/src/main/models-manager.ts
index 9b328d11a..87c848048 100644
--- a/src/main/models-manager.ts
+++ b/src/main/models-manager.ts
@@ -649,6 +649,20 @@ export async function getCatalog(): Promise<{ kinds: readonly string[]; models:
})
const servers = getRemoteVisionServerSettings().servers
const remoteModels = remoteVisionInventoryModels(servers)
+ // A model whose runtime is not on this machine is not offered for download: Kev without its
+ // runtime, Parakeet without sherpa-onnx. One already downloaded stays listed, so it can be
+ // deleted.
+ const { kevRuntimeInstalled, parakeetRuntimeInstalled } = await import('./runtime-availability')
+ const kev = kevRuntimeInstalled()
+ const parakeet = parakeetRuntimeInstalled()
+ const offered = models.filter((entry) => {
+ const model = entry as { id?: string; engine?: string; files?: { name: string }[] }
+ const runtimeMissing =
+ (!kev && isKevModel(model.id)) || (!parakeet && model.engine === 'parakeet')
+ if (!runtimeMissing) return true
+ const files = model.files ?? []
+ return files.length > 0 && files.every((file) => present(file.name))
+ })
// Every row is named by the one shared resolver, so no screen ever shows a file or repo id.
const sources: ModelNameSources = {
catalog: CATALOG,
@@ -661,7 +675,7 @@ export async function getCatalog(): Promise<{ kinds: readonly string[]; models:
// list). One file at a time, so a first read holds one template window in memory, not one per
// model; cached afterwards. Unknown (not installed, no template) stays undefined: no icon.
const traitsById = new Map()
- for (const model of models as CatalogEntry[]) {
+ for (const model of offered as CatalogEntry[]) {
if (model.kind !== 'text' && model.kind !== 'vision') continue
const name = primaryFileName(model)
if (!name || fileSizeOf(dir, name) <= 0) continue
@@ -671,7 +685,7 @@ export async function getCatalog(): Promise<{ kinds: readonly string[]; models:
return {
kinds: MODEL_KINDS,
models: [
- ...models.map((model) => ({ ...model, ...traitsById.get((model as CatalogEntry).id) })),
+ ...offered.map((model) => ({ ...model, ...traitsById.get((model as CatalogEntry).id) })),
...remoteModels
].map((model) => ({
...model,
@@ -1395,6 +1409,8 @@ export async function deleteModel(modelId: string): Promise {
}
async function deleteModelFiles(modelId: string): Promise {
+ if (modelId.startsWith('external:'))
+ return { success: false, error: 'External models are read only.' }
const dir = llm.getModelsDir()
if (modelId.startsWith('external:')) return trashExternalModel(modelId, dir)
// Imported local model: remove its files + registry entry, clear if active.
diff --git a/src/main/native-helper-health.ts b/src/main/native-helper-health.ts
index 009561e9a..5c06c4ad9 100644
--- a/src/main/native-helper-health.ts
+++ b/src/main/native-helper-health.ts
@@ -1,5 +1,6 @@
import type { SystemHealthComponentContract } from '../shared/ipc-contracts'
-import { ffmpegBin, whisperBin } from './transcription/whisper-cli'
+import { ffmpegBin } from './transcription/whisper-cli'
+import { nativeBinaryInstalled } from './backend-facts'
interface NativeHelperOwner {
id: string
@@ -12,7 +13,11 @@ interface NativeHelperOwner {
// runtime facts a stable System Health identity and label.
const NATIVE_HELPERS: readonly NativeHelperOwner[] = [
{ id: 'helper-ffmpeg', label: 'Audio decoder (ffmpeg)', resolve: ffmpegBin },
- { id: 'helper-whisper', label: 'Speech to text helper', resolve: whisperBin }
+ {
+ id: 'helper-whisper',
+ label: 'Speech to text helper',
+ resolve: () => (nativeBinaryInstalled('stt', 'whisper-cli') ? 'whisper-cli' : null)
+ }
]
export function getNativeHelperHealth(): SystemHealthComponentContract[] {
diff --git a/src/main/native-library-env.ts b/src/main/native-library-env.ts
index 48acde208..25415b246 100644
--- a/src/main/native-library-env.ts
+++ b/src/main/native-library-env.ts
@@ -6,14 +6,26 @@ export function nativeLibraryEnv(
binDir: string,
inherited: Record = {}
): Record {
- if (platform === 'darwin') return { DYLD_LIBRARY_PATH: binDir }
+ const pathApi = platform === 'win32' ? path.win32 : path.posix
+ const folder = pathApi.basename(binDir)
+ const cudaEnv: Record =
+ (folder.endsWith('-cuda') || (platform === 'win32' && folder === 'whisper')) &&
+ inherited.CUDA_CACHE_MAXSIZE === undefined
+ ? { CUDA_CACHE_MAXSIZE: '4294967296' }
+ : {}
+ if (platform === 'darwin') return { ...cudaEnv, DYLD_LIBRARY_PATH: binDir }
if (platform === 'linux') {
return {
+ ...cudaEnv,
LD_LIBRARY_PATH: inherited.LD_LIBRARY_PATH ? `${binDir}:${inherited.LD_LIBRARY_PATH}` : binDir
}
}
if (platform === 'win32') {
- return { PATH: `${binDir}${path.win32.delimiter}${inherited.PATH ?? ''}` }
+ return {
+ ...cudaEnv,
+ GGML_VK_DISABLE_BFLOAT16: '1',
+ PATH: `${binDir}${path.win32.delimiter}${inherited.PATH ?? ''}`
+ }
}
- return {}
+ return cudaEnv
}
diff --git a/src/main/nvidia-driver.ts b/src/main/nvidia-driver.ts
new file mode 100644
index 000000000..34028eae3
--- /dev/null
+++ b/src/main/nvidia-driver.ts
@@ -0,0 +1,77 @@
+// The installed NVIDIA driver version. CUDA builds need a minimum driver (shared policy
+// NVIDIA_DRIVER_FLOORS); offering them on an older driver ends in an engine that cannot start.
+
+import fs from 'node:fs'
+import path from 'node:path'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+
+const execFileAsync = promisify(execFile)
+
+/** "Kernel Module 580.82.07" (also "Open Kernel Module for x86_64 580.82.07") → "580.82.07". */
+export function parseLinuxDriverVersion(text: string): string | null {
+ return /Kernel Module[^\n]*?\s(\d+\.\d+(?:\.\d+)?)\s/i.exec(text)?.[1] ?? null
+}
+
+/** The driver version, 'unknown' when an NVIDIA driver is present but its version cannot be read,
+ * or null when there is no NVIDIA driver. */
+export async function readNvidiaDriverVersion(
+ platform: NodeJS.Platform = process.platform
+): Promise {
+ if (platform === 'linux') {
+ try {
+ return (
+ parseLinuxDriverVersion(fs.readFileSync('/proc/driver/nvidia/version', 'utf8')) ?? 'unknown'
+ )
+ } catch {
+ return null
+ }
+ }
+ if (platform === 'win32') {
+ const system32 = path.join(
+ process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows',
+ 'System32'
+ )
+ if (!fs.existsSync(path.join(system32, 'nvcuda.dll'))) return null
+ try {
+ const { stdout } = await execFileAsync(
+ path.join(system32, 'nvidia-smi.exe'),
+ ['--query-gpu=driver_version', '--format=csv,noheader'],
+ { timeout: 5_000, windowsHide: true }
+ )
+ return /^\s*(\d+\.\d+)/.exec(stdout)?.[1] ?? 'unknown'
+ } catch {
+ return 'unknown'
+ }
+ }
+ return null
+}
+
+let cached: string | null | undefined
+
+/** The last read driver version (null until the first read finishes, or with no NVIDIA driver). */
+export function cachedNvidiaDriverVersion(): string | null {
+ return cached ?? null
+}
+
+/** Whether Windows has an NVIDIA driver installed (its CUDA driver library is present). */
+export function hasWindowsNvidiaDriver(
+ windowsDir = process.env.WINDIR ?? process.env.SystemRoot ?? 'C:\\Windows'
+): boolean {
+ return fs.existsSync(path.join(windowsDir, 'System32', 'nvcuda.dll'))
+}
+
+/** Whether Linux has an NVIDIA driver loaded (it exposes /dev/nvidiaN). */
+export function hasLinuxNvidiaDriver(): boolean {
+ try {
+ return fs.readdirSync('/dev').some((name) => /^nvidia\d+$/.test(name))
+ } catch {
+ return false
+ }
+}
+
+/** Read the driver again: at startup, on wake and when the GPU settings open. */
+export async function refreshNvidiaDriverVersion(): Promise {
+ cached = await readNvidiaDriverVersion().catch(() => null)
+ return cached
+}
diff --git a/src/main/ocr.ts b/src/main/ocr.ts
index 984e646a3..32cb12cfe 100644
--- a/src/main/ocr.ts
+++ b/src/main/ocr.ts
@@ -1,5 +1,5 @@
// OCR a screenshot via the bundled macOS Vision binary (electron/accessibility/ocr).
-// The legacy text API remains stable. The boxed API owns structured OCR.
+// The boxed API owns structured OCR; runPdfOCR reads scanned PDF pages.
import { execFile } from 'child_process'
import { promisify } from 'util'
@@ -84,18 +84,6 @@ export async function runBoxedOCR(imagePath: string): Promise {
}
}
-export async function runOCR(imagePath: string): Promise {
- const bin = ocrBin()
- if (!bin) return ''
- try {
- const { stdout } = await execFileAsync(bin, [imagePath], { maxBuffer: 32 * 1024 * 1024 })
- return stdout.trim()
- } catch (e) {
- console.error('[OCR] failed:', e)
- return ''
- }
-}
-
/** The Vision helper is present (macOS builds). */
export function visionOcrAvailable(): boolean {
return process.platform === 'darwin' && ocrBin() !== null
diff --git a/src/main/onnx-device.ts b/src/main/onnx-device.ts
index ec926ebd9..4263821ff 100644
--- a/src/main/onnx-device.ts
+++ b/src/main/onnx-device.ts
@@ -1,28 +1,4 @@
import type { DeviceType } from '@huggingface/transformers'
-import { prioritizeBackend, type BackendPreference } from '../shared/backend-preferences'
-
-/**
- * Try one accelerator at a time. ONNX Runtime does not always continue to the
- * next provider when a GPU provider is installed but its driver/runtime cannot
- * start, so the caller must create a fresh session for each candidate.
- */
-export function onnxDeviceCandidates(
- platform = process.platform,
- preference: BackendPreference = 'auto'
-): DeviceType[] {
- const accelerated: DeviceType[] =
- platform === 'darwin'
- ? ['coreml', 'webgpu']
- : platform === 'win32'
- ? ['cuda', 'dml', 'webgpu']
- : platform === 'linux'
- ? ['cuda', 'webgpu']
- : ['webgpu']
-
- return prioritizeBackend([...accelerated, 'cpu'], preference, (device) =>
- device === 'coreml' ? 'metal' : device === 'dml' ? 'directml' : device as BackendPreference
- )
-}
export interface LoadedOnnxRuntime {
runtime: T
@@ -32,14 +8,18 @@ export interface LoadedOnnxRuntime {
export async function loadWithOnnxFallback(
load: (device: DeviceType) => Promise,
- candidates = onnxDeviceCandidates()
+ candidates: DeviceType[]
): Promise> {
const failures: string[] = []
for (const device of candidates) {
try {
const runtime = await load(device)
console.log(`[ONNX] selected provider=${device}`)
- return { runtime, device, ...(failures.length ? { fallbackReason: failures.join('; ') } : {}) }
+ return {
+ runtime,
+ device,
+ ...(failures.length ? { fallbackReason: failures.join('; ') } : {})
+ }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
failures.push(`${device}: ${message}`)
diff --git a/src/main/onnx-host/__tests__/onnx-runtime-env.test.ts b/src/main/onnx-host/__tests__/onnx-runtime-env.test.ts
new file mode 100644
index 000000000..3339206fb
--- /dev/null
+++ b/src/main/onnx-host/__tests__/onnx-runtime-env.test.ts
@@ -0,0 +1,161 @@
+// The environment a voice or search helper starts with, over a performance pack installed on disk
+// the way the installer leaves it. The operating system is the controlled boundary: the platform
+// and architecture are set per test, everything else (manifest, pack folder, check records) is real.
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { configureRuntime } from '../../runtime-env'
+import { installedOnnxCudaDir, onnxHelperEnv } from '../onnx-runtime-env'
+import { onnxCudaVerified } from '../onnx-cuda-verified'
+import { probeOnnxCuda } from '../onnx-cuda-probe'
+
+const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-onnx-env-'))
+const SHA = 'ab'.repeat(32)
+const VERSION = 'test-v1'
+const original = {
+ platform: Object.getOwnPropertyDescriptor(process, 'platform')!,
+ arch: Object.getOwnPropertyDescriptor(process, 'arch')!,
+ ldPath: process.env.LD_LIBRARY_PATH,
+ localAppData: process.env.LOCALAPPDATA
+}
+
+function runOn(platform: 'linux' | 'win32' | 'darwin', arch = 'x64'): void {
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true })
+ Object.defineProperty(process, 'arch', { value: arch, configurable: true })
+}
+
+/** The bundled manifest naming this platform's pack. */
+function manifest(platform: 'linux' | 'win32'): void {
+ fs.writeFileSync(
+ path.join(ROOT, 'performance-packs.json'),
+ JSON.stringify({
+ schemaVersion: 1,
+ cuda: {
+ [platform]: {
+ url: `https://runtime.getoffgridai.co/desktop/cuda/${platform}/${VERSION}.tar.gz`,
+ sha256: SHA,
+ bytes: 1024,
+ version: VERSION
+ }
+ }
+ })
+ )
+}
+
+/** A verified pack with its ONNX CUDA libraries, as the installer leaves it. */
+function installPack(platform: 'linux' | 'win32'): string {
+ const pack = path.join(ROOT, 'performance-packs', `cuda-${VERSION}-${SHA.slice(0, 12)}`)
+ const libraries = path.join(pack, 'bin', 'onnx-cuda')
+ fs.mkdirSync(libraries, { recursive: true })
+ fs.writeFileSync(path.join(pack, 'verified.sha256'), `${SHA}\n`)
+ fs.writeFileSync(
+ path.join(libraries, platform === 'win32' ? 'onnxruntime.dll' : 'libcublasLt.so.13'),
+ 'test'
+ )
+ return pack
+}
+
+beforeAll(() => {
+ delete process.env.LOCALAPPDATA
+ configureRuntime({ dataDir: ROOT, resourceDirs: [ROOT] })
+})
+
+afterEach(() => {
+ Object.defineProperty(process, 'platform', original.platform)
+ Object.defineProperty(process, 'arch', original.arch)
+ fs.rmSync(path.join(ROOT, 'performance-packs'), { recursive: true, force: true })
+ fs.rmSync(path.join(ROOT, 'performance-packs.json'), { force: true })
+})
+
+afterAll(() => {
+ if (original.ldPath === undefined) delete process.env.LD_LIBRARY_PATH
+ else process.env.LD_LIBRARY_PATH = original.ldPath
+ if (original.localAppData !== undefined) process.env.LOCALAPPDATA = original.localAppData
+ configureRuntime({ dataDir: undefined, resourceDirs: undefined })
+ fs.rmSync(ROOT, { recursive: true, force: true })
+})
+
+describe('helper environment', () => {
+ it('starts a helper with the plain environment when no pack is installed', () => {
+ runOn('linux')
+ manifest('linux')
+ expect(installedOnnxCudaDir()).toBeNull()
+ const env = onnxHelperEnv()
+ expect(env.LD_LIBRARY_PATH).toBe(process.env.LD_LIBRARY_PATH)
+ expect(env.OFFGRID_ONNX_CUDA_VERIFIED).toBeUndefined()
+ })
+
+ it('puts the pack’s CUDA libraries first on Linux, once', () => {
+ runOn('linux')
+ manifest('linux')
+ const pack = installPack('linux')
+ const libraries = path.join(pack, 'bin', 'onnx-cuda')
+ expect(installedOnnxCudaDir()).toBe(libraries)
+
+ process.env.LD_LIBRARY_PATH = '/usr/lib/existing'
+ expect(onnxHelperEnv().LD_LIBRARY_PATH).toBe(`${libraries}:/usr/lib/existing`)
+ // Already on the path: not added again.
+ process.env.LD_LIBRARY_PATH = `${libraries}:/usr/lib/existing`
+ expect(onnxHelperEnv().LD_LIBRARY_PATH).toBe(`${libraries}:/usr/lib/existing`)
+ })
+
+ it('does not offer the pack on an architecture it has no engines for', () => {
+ runOn('linux', 'arm64')
+ manifest('linux')
+ installPack('linux')
+ expect(installedOnnxCudaDir()).toBeNull()
+ })
+
+ it('loads the Windows CUDA addon only after the CUDA check passed (or for the check itself)', () => {
+ runOn('win32')
+ manifest('win32')
+ const pack = installPack('win32')
+ const libraries = path.join(pack, 'bin', 'onnx-cuda')
+
+ expect(onnxCudaVerified()).toEqual({ usable: false, reason: { code: 'not-verified' } })
+ expect(onnxHelperEnv().OFFGRID_ONNX_CUDA_VERIFIED).toBeUndefined()
+
+ // The check itself runs with the addon.
+ const forced = onnxHelperEnv('force')
+ expect(forced.OFFGRID_ONNX_CUDA_VERIFIED).toBe('1')
+ expect(forced.OFFGRID_PERFORMANCE_PACK_BIN).toBe(path.join(pack, 'bin'))
+ expect(forced.PATH!.startsWith(`${libraries}${path.win32.delimiter}`)).toBe(true)
+
+ // The check passed for this pack and driver: every helper gets the addon.
+ fs.writeFileSync(
+ path.join(pack, 'onnx-cuda-verified.json'),
+ JSON.stringify({
+ packRoot: pack,
+ driver: null,
+ at: new Date().toISOString()
+ })
+ )
+ expect(onnxCudaVerified()).toEqual({ usable: true })
+ expect(onnxHelperEnv().OFFGRID_ONNX_CUDA_VERIFIED).toBe('1')
+
+ // A record for another pack does not count.
+ fs.writeFileSync(
+ path.join(pack, 'onnx-cuda-verified.json'),
+ JSON.stringify({ packRoot: '/elsewhere', driver: null, at: new Date().toISOString() })
+ )
+ expect(onnxCudaVerified().usable).toBe(false)
+ })
+
+ it('says why CUDA voice and search are unavailable', async () => {
+ runOn('darwin', 'arm64')
+ expect(onnxCudaVerified()).toEqual({ usable: false, reason: { code: 'no-device' } })
+ expect(await probeOnnxCuda()).toBe(false)
+
+ runOn('win32')
+ manifest('win32')
+ expect(onnxCudaVerified()).toEqual({ usable: false, reason: { code: 'needs-pack' } })
+ // No pack, nothing to check.
+ expect(await probeOnnxCuda()).toBe(false)
+
+ // A pack but no search model on disk yet: the check waits rather than marking CUDA failed.
+ const pack = installPack('win32')
+ expect(await probeOnnxCuda()).toBe(false)
+ expect(fs.existsSync(path.join(pack, 'onnx-cuda-failed.json'))).toBe(false)
+ })
+})
diff --git a/src/main/onnx-host/__tests__/utility-host.test.ts b/src/main/onnx-host/__tests__/utility-host.test.ts
new file mode 100644
index 000000000..7ba6f02ab
--- /dev/null
+++ b/src/main/onnx-host/__tests__/utility-host.test.ts
@@ -0,0 +1,101 @@
+// Voice and search helpers as separate processes: a helper gets its init message first, answers
+// requests, and a crash ends only the helper. After repeated crashes it stays down, saying why,
+// until it is cleared. Electron's utilityProcess is the controlled boundary: it is backed by a
+// real Node child process, which has the same message, exit and kill behaviour.
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', async () => {
+ const { fork } = await import('node:child_process')
+ return {
+ utilityProcess: {
+ fork: (entry: string, args: string[], options: { env?: Record }) => {
+ const child = fork(entry, args, { env: options.env, stdio: 'inherit' })
+ return {
+ postMessage: (message: unknown) => child.send(message as object),
+ on: (event: string, listener: (...values: unknown[]) => void) =>
+ child.on(event, listener),
+ once: (event: string, listener: (...values: unknown[]) => void) =>
+ child.once(event, listener),
+ kill: () => child.kill()
+ }
+ }
+ }
+ }
+})
+
+import { clearHelperCrashes, forkHelper, helperStoppedReason } from '../utility-host'
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-utility-host-'))
+const helper = path.join(dir, 'helper.js')
+// Echoes what it was initialised with, answers 'ping', and dies with code 3 on 'crash'.
+fs.writeFileSync(
+ helper,
+ `let init = null;
+process.on('message', (message) => {
+ if (message.type === 'init') { init = message.data; return; }
+ if (message === 'crash') process.exit(3);
+ process.send({ reply: message, init });
+});
+`
+)
+
+const open: Array> = []
+const start = (serviceName: string): ReturnType => {
+ const handle = forkHelper(helper, { model: 'search' }, { serviceName, label: 'Search', env: {} })
+ open.push(handle)
+ return handle
+}
+const exitOf = (handle: ReturnType): Promise =>
+ new Promise((resolve) => handle.on('exit', resolve))
+
+afterEach(async () => {
+ await Promise.all(open.splice(0).map((handle) => handle.terminate()))
+})
+
+afterAll(() => {
+ fs.rmSync(dir, { recursive: true, force: true })
+})
+
+describe('helper processes', () => {
+ it('sends the helper its init first, then carries requests both ways', async () => {
+ const search = start('offgrid-test-echo')
+ const reply = new Promise((resolve) => search.on('message', resolve))
+ search.postMessage('ping')
+ expect(await reply).toEqual({ reply: 'ping', init: { model: 'search' } })
+ })
+
+ it('counts crashes, stays down after three in a minute, and starts again once cleared', async () => {
+ const name = 'offgrid-test-crash'
+ for (let i = 0; i < 3; i++) {
+ const crashing = start(name)
+ const exited = exitOf(crashing)
+ crashing.postMessage('crash')
+ expect(await exited).toBe(3)
+ // A request to a helper that has exited goes nowhere instead of throwing.
+ crashing.postMessage('ping')
+ }
+ expect(helperStoppedReason(name)).toBe('the helper exited with code 3')
+ expect(() => start(name)).toThrow('Search stopped: the helper exited with code 3')
+
+ clearHelperCrashes(name)
+ expect(helperStoppedReason(name)).toBeNull()
+ const again = start(name)
+ const reply = new Promise((resolve) => again.on('message', resolve))
+ again.postMessage('ping')
+ expect(await reply).toMatchObject({ reply: 'ping' })
+ })
+
+ it('does not count a helper it stopped itself as a crash', async () => {
+ const name = 'offgrid-test-terminate'
+ for (let i = 0; i < 4; i++) {
+ const helperProcess = start(name)
+ await helperProcess.terminate()
+ // Terminating twice is harmless.
+ await helperProcess.terminate()
+ }
+ expect(helperStoppedReason(name)).toBeNull()
+ })
+})
diff --git a/src/main/onnx-host/helper-port.ts b/src/main/onnx-host/helper-port.ts
new file mode 100644
index 000000000..9ece72b5f
--- /dev/null
+++ b/src/main/onnx-host/helper-port.ts
@@ -0,0 +1,42 @@
+// The child side of a helper process (utility-host.ts): the first message carries the init data,
+// every later message is a request. Replies go back through the same port.
+
+interface ParentPort {
+ on(event: 'message', listener: (event: { data: unknown }) => void): void
+ postMessage(message: unknown): void
+}
+
+export interface HelperPort {
+ post(message: unknown): void
+ onRequest(listener: (request: Request) => void): void
+}
+
+/** Resolves once the host has sent the init data. Throws outside a utility process. */
+export function connectHelper(): Promise<{ init: Init; port: HelperPort }> {
+ const parent = (process as unknown as { parentPort?: ParentPort }).parentPort
+ if (!parent) throw new Error('This helper must be started as an Electron utility process.')
+ return new Promise((resolve) => {
+ let init: Init | undefined
+ const queued: Request[] = []
+ let listener: ((request: Request) => void) | null = null
+ parent.on('message', ({ data }) => {
+ const message = data as { type?: string; data?: Init }
+ if (init === undefined && message.type === 'init') {
+ init = message.data as Init
+ resolve({
+ init,
+ port: {
+ post: (reply) => parent.postMessage(reply),
+ onRequest: (next) => {
+ listener = next
+ for (const request of queued.splice(0)) next(request)
+ }
+ }
+ })
+ return
+ }
+ if (listener) listener(data as Request)
+ else queued.push(data as Request)
+ })
+ })
+}
diff --git a/src/main/onnx-host/onnx-cuda-probe.ts b/src/main/onnx-host/onnx-cuda-probe.ts
new file mode 100644
index 000000000..55dc4823f
--- /dev/null
+++ b/src/main/onnx-host/onnx-cuda-probe.ts
@@ -0,0 +1,116 @@
+// Windows: ONNX Runtime's CUDA addon (from the performance pack) replaces the stock addon, which
+// carries DirectML and WebGPU. Loading it on a machine where CUDA cannot start would leave voice
+// and search on the CPU, so it is loaded only after one embedding has run on CUDA in a helper.
+// The result is kept per pack and driver version, so a driver update tests again.
+
+import fs from 'node:fs'
+import path from 'node:path'
+import { cachedNvidiaDriverVersion } from '../nvidia-driver'
+import {
+ matches,
+ onnxCudaVerified,
+ packRoot,
+ readRecord,
+ type ProbeRecord
+} from './onnx-cuda-verified'
+import { resolveTransformersPackage, modelReadDirectories } from '../model-paths'
+import { chosenModelsDir, modelsDir } from '../runtime-env'
+import { DEFAULT_EMBEDDER } from '../embedding-models'
+
+const PROBE_TIMEOUT_MS = 120_000
+
+function needsProbe(root: string): boolean {
+ return (
+ !matches(readRecord(path.join(root, 'onnx-cuda-verified.json')), root) &&
+ !matches(readRecord(path.join(root, 'onnx-cuda-failed.json')), root)
+ )
+}
+
+let running: Promise | null = null
+
+/** Run one embedding on CUDA in a helper and record the result. Windows only; true when CUDA ran.
+ * Waits until the default embedder is on disk, so an offline machine is not marked failed. */
+export function probeOnnxCuda(): Promise {
+ if (process.platform !== 'win32' || process.arch !== 'x64') return Promise.resolve(false)
+ const root = packRoot()
+ if (!root || !needsProbe(root)) return Promise.resolve(onnxCudaVerified().usable)
+ if (
+ !resolveTransformersPackage(DEFAULT_EMBEDDER.model, 'model.onnx', modelReadDirectories()) &&
+ !resolveTransformersPackage(
+ DEFAULT_EMBEDDER.model,
+ 'model_quantized.onnx',
+ modelReadDirectories()
+ )
+ ) {
+ return Promise.resolve(false)
+ }
+ running ??= runProbe(root).finally(() => {
+ running = null
+ })
+ return running
+}
+
+async function runProbe(root: string): Promise {
+ const [{ forkHelper }, { onnxHelperEnv }] = await Promise.all([
+ import('./utility-host'),
+ import('./onnx-runtime-env')
+ ])
+ const entry = path.join(__dirname, 'embeddings-worker.js')
+ const record: ProbeRecord = {
+ packRoot: root,
+ driver: cachedNvidiaDriverVersion(),
+ at: new Date().toISOString()
+ }
+ let helper: Awaited> | null = null
+ try {
+ helper = forkHelper(
+ entry,
+ {
+ modelsDir: modelsDir(),
+ chosenModelsDir: chosenModelsDir(),
+ modelReadDirectories: modelReadDirectories(),
+ devices: ['cuda']
+ },
+ { serviceName: 'offgrid-onnx-cuda-probe', label: 'GPU check', env: onnxHelperEnv('force') }
+ )
+ const started = helper
+ const device = await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error('The CUDA check timed out.')),
+ PROBE_TIMEOUT_MS
+ )
+ started.on(
+ 'message',
+ (response: { id: number; vectors?: unknown; device?: string; error?: string }) => {
+ if (response.id !== 1 || (!response.vectors && !response.error)) return
+ clearTimeout(timer)
+ if (response.error) reject(new Error(response.error))
+ else resolve(response.device ?? 'cpu')
+ }
+ )
+ started.on('exit', (code) => {
+ clearTimeout(timer)
+ reject(new Error(`The CUDA check stopped (exit code ${code}).`))
+ })
+ started.postMessage({ id: 1, texts: ['probe'], model: DEFAULT_EMBEDDER.model })
+ })
+ if (device !== 'cuda') throw new Error(`ONNX Runtime ran on ${device}, not CUDA.`)
+ fs.writeFileSync(path.join(root, 'onnx-cuda-verified.json'), JSON.stringify(record))
+ fs.rmSync(path.join(root, 'onnx-cuda-failed.json'), { force: true })
+ console.log('[ONNX] CUDA verified for voice and search')
+ return true
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ fs.writeFileSync(
+ path.join(root, 'onnx-cuda-failed.json'),
+ JSON.stringify({ ...record, error: message })
+ )
+ console.warn(
+ '[ONNX] CUDA check failed; voice and search keep DirectML, WebGPU or CPU:',
+ message
+ )
+ return false
+ } finally {
+ await helper?.terminate()
+ }
+}
diff --git a/src/main/onnx-host/onnx-cuda-verified.ts b/src/main/onnx-host/onnx-cuda-verified.ts
new file mode 100644
index 000000000..68e06c338
--- /dev/null
+++ b/src/main/onnx-host/onnx-cuda-verified.ts
@@ -0,0 +1,41 @@
+// Whether helpers may load the Windows ONNX CUDA addon: the record the CUDA check
+// (onnx-cuda-probe.ts) leaves beside the performance pack, per pack and driver version.
+
+import fs from 'node:fs'
+import path from 'node:path'
+import type { BackendReason } from '@offgrid/models'
+import { installedPerformancePackBin } from '../performance-pack'
+import { cachedNvidiaDriverVersion } from '../nvidia-driver'
+
+export interface ProbeRecord {
+ packRoot: string
+ driver: string | null
+ at: string
+ error?: string
+}
+
+export function packRoot(): string | null {
+ const bin = installedPerformancePackBin()
+ return bin ? path.dirname(bin) : null
+}
+
+export function readRecord(file: string): ProbeRecord | null {
+ try {
+ return JSON.parse(fs.readFileSync(file, 'utf8')) as ProbeRecord
+ } catch {
+ return null
+ }
+}
+
+export function matches(record: ProbeRecord | null, root: string): boolean {
+ return !!record && record.packRoot === root && record.driver === cachedNvidiaDriverVersion()
+}
+
+/** Whether helpers may load the CUDA addon on this machine (Windows only). */
+export function onnxCudaVerified(): { usable: boolean; reason?: BackendReason } {
+ if (process.platform !== 'win32') return { usable: false, reason: { code: 'no-device' } }
+ const root = packRoot()
+ if (!root) return { usable: false, reason: { code: 'needs-pack' } }
+ if (matches(readRecord(path.join(root, 'onnx-cuda-verified.json')), root)) return { usable: true }
+ return { usable: false, reason: { code: 'not-verified' } }
+}
diff --git a/src/main/onnx-host/onnx-runtime-env.ts b/src/main/onnx-host/onnx-runtime-env.ts
new file mode 100644
index 000000000..e3f8fe2e4
--- /dev/null
+++ b/src/main/onnx-host/onnx-runtime-env.ts
@@ -0,0 +1,43 @@
+// The environment a voice or search helper process starts with. CUDA libraries for ONNX Runtime
+// reach the helper here, at spawn, so the app never restarts itself to change its own library path,
+// and the CUDA 13 libraries never leak into the chat, image or speech engines.
+
+import fs from 'node:fs'
+import path from 'node:path'
+import { installedPerformancePackBin } from '../performance-pack'
+import { onnxCudaVerified } from './onnx-cuda-verified'
+
+/** The pack's ONNX CUDA library folder, when the pack is installed and has one. */
+export function installedOnnxCudaDir(): string | null {
+ const bin = installedPerformancePackBin()
+ if (!bin) return null
+ const libraries = path.join(bin, 'onnx-cuda')
+ const marker = process.platform === 'win32' ? 'onnxruntime.dll' : 'libcublasLt.so.13'
+ return fs.existsSync(path.join(libraries, marker)) ? libraries : null
+}
+
+/** `cuda: 'auto'` adds the CUDA libraries only where they are allowed to load; `'force'` is the
+ * Windows probe that decides whether they are. */
+export function onnxHelperEnv(cuda: 'auto' | 'force' = 'auto'): Record {
+ const env: Record = {}
+ for (const [key, value] of Object.entries(process.env)) if (value !== undefined) env[key] = value
+ const libraries = installedOnnxCudaDir()
+ if (!libraries) return env
+ if (process.platform === 'linux') {
+ const current = (env.LD_LIBRARY_PATH ?? '').split(':').filter(Boolean)
+ if (!current.includes(libraries)) env.LD_LIBRARY_PATH = [libraries, ...current].join(':')
+ return env
+ }
+ if (
+ process.platform === 'win32' &&
+ process.arch === 'x64' &&
+ (cuda === 'force' || onnxCudaVerified().usable)
+ ) {
+ // The patched onnxruntime-node binding loads the pack's CUDA addon only with this flag set
+ // (scripts/patch-windows-onnx-cuda.mjs); without it the stock addon keeps DirectML and WebGPU.
+ env.OFFGRID_ONNX_CUDA_VERIFIED = '1'
+ env.OFFGRID_PERFORMANCE_PACK_BIN = path.dirname(libraries)
+ env.PATH = `${libraries}${path.win32.delimiter}${env.PATH ?? ''}`
+ }
+ return env
+}
diff --git a/src/main/onnx-host/utility-host.ts b/src/main/onnx-host/utility-host.ts
new file mode 100644
index 000000000..dc8794e50
--- /dev/null
+++ b/src/main/onnx-host/utility-host.ts
@@ -0,0 +1,86 @@
+// ONNX Runtime work (voice, search) runs in Electron utility processes, not worker threads. A GPU
+// driver crash inside ONNX Runtime then ends the helper, which restarts on the next request; in a
+// worker thread it ended the whole app. The helper also gets its own environment, so CUDA library
+// paths reach ONNX Runtime without restarting the app or leaking into other child processes.
+//
+// The handle mirrors the small part of the Worker API the hosts use (postMessage, message and exit
+// events, terminate), so the hosts keep their request queues and protocols unchanged.
+
+import { utilityProcess, type UtilityProcess } from 'electron'
+import { onnxHelperEnv } from './onnx-runtime-env'
+
+/** Crashes allowed per helper in CRASH_WINDOW_MS before it stays down until restarted. */
+const CRASH_LIMIT = 3
+const CRASH_WINDOW_MS = 60_000
+
+export interface HelperProcess {
+ postMessage(message: unknown): void
+ on(event: 'message', listener: (message: never) => void): void
+ on(event: 'exit', listener: (code: number) => void): void
+ terminate(): Promise