Package pinned self-hosted SmolVLM2 bootstrap #52

Merged
rockachopa merged 1 commits from timmy/10-reproducible-smolvlm-bootstrap into main 2026-08-21 12:55:18 +00:00
7 changed files with 266 additions and 16 deletions

View File

@ -20,23 +20,22 @@ The builder clones the committed `main` tree into an isolated directory, runs un
```bash ```bash
npm install npm install
git clone --depth 1 https://github.com/ggml-org/llama.cpp /root/model-spikes/llama.cpp # Installs the exact llama.cpp commit and verifies both GGUF SHA-256 receipts.
cmake -S /root/model-spikes/llama.cpp -B /root/model-spikes/llama.cpp/build -DCMAKE_BUILD_TYPE=Release scripts/bootstrap_selfhost_smolvlm.sh install
cmake --build /root/model-spikes/llama.cpp/build -j4 --target llama-server scripts/bootstrap_selfhost_smolvlm.sh start
scripts/bootstrap_selfhost_smolvlm.sh health
hf download ggml-org/SmolVLM2-2.2B-Instruct-GGUF \ # Run Timmy in another terminal.
SmolVLM2-2.2B-Instruct-Q4_K_M.gguf \
mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf \
--local-dir /root/model-spikes/models/smolvlm2-2.2b
TIMMY_MODEL_PORT=8080 scripts/run_selfhost_smolvlm.sh
# In another terminal
TIMMY_VISION_PROFILE=selfhost npm start TIMMY_VISION_PROFILE=selfhost npm start
# open http://localhost:4173 # open http://localhost:4173
# When finished:
scripts/bootstrap_selfhost_smolvlm.sh stop
``` ```
The selected bootstrap model is `SmolVLM2-2.2B-Instruct` using the official Apache-2.0 GGUF conversion. The default self-hosted endpoint is loopback-only at `http://127.0.0.1:8080/v1`. Override paths, host, port, threads, model ID, or endpoint with the `TIMMY_MODEL_*` and `TIMMY_VISION_*` environment variables. The bootstrap defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/timmy-selfhost`, prints immutable source/model receipts with `receipt`, and binds only to `127.0.0.1:8080`. Downloads are rejected unless both pinned SHA-256 hashes match, and model weights remain outside Git. Set `TIMMY_SELFHOST_ROOT` to choose another private data directory; override host, port, threads, or build jobs with the documented `TIMMY_MODEL_*`/`TIMMY_BUILD_JOBS` environment variables.
The selected bootstrap model is `SmolVLM2-2.2B-Instruct` using the official Apache-2.0 GGUF conversion. It is a provisional labeling worker, not a diagnostic or clinically validated classifier.
## Run with a hosted provider ## Run with a hosted provider
@ -120,7 +119,8 @@ Browser PWA
├── src/hermes-agent-service.js authenticated session-bound Hermes CLI adapter ├── src/hermes-agent-service.js authenticated session-bound Hermes CLI adapter
├── src/vision-service.js server-side OpenAI-compatible provider adapter ├── src/vision-service.js server-side OpenAI-compatible provider adapter
├── src/vision-config.js hosted/self-hosted profiles and readiness probe ├── src/vision-config.js hosted/self-hosted profiles and readiness probe
├── scripts/run_selfhost_smolvlm.sh ├── scripts/bootstrap_selfhost_smolvlm.sh pinned install/start/health/stop lifecycle
├── scripts/run_selfhost_smolvlm.sh custom-path foreground launcher
├── scripts/ingest_training_photo.py ├── scripts/ingest_training_photo.py
├── localStorage saved ledger and attached photos ├── localStorage saved ledger and attached photos
└── llama.cpp / hosted API selected per server-side profile └── llama.cpp / hosted API selected per server-side profile

View File

@ -4,11 +4,11 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js", "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js",
"test:ui": "node tests/ui.acceptance.mjs", "test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs", "test:sleek": "node tests/sleek-chat.acceptance.mjs",
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py", "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py",
"check:diff": "bash scripts/check_diff.sh", "check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs" "start": "node server.mjs"
}, },

View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
set -euo pipefail
LLAMA_CPP_COMMIT="${TIMMY_LLAMA_CPP_COMMIT:-6d05498314db1b57f81c271080018aa2d0b89be9}"
MODEL_SHA256="${TIMMY_MODEL_SHA256:-0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58}"
MMPROJ_SHA256="${TIMMY_MMPROJ_SHA256:-ae07ea1facd07dd3230c4483b63e8cda96c6944ad2481f33d531f79e892dd024}"
HOST="${TIMMY_MODEL_HOST:-127.0.0.1}"
PORT="${TIMMY_MODEL_PORT:-8080}"
ROOT="${TIMMY_SELFHOST_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/timmy-selfhost}"
MODEL_DIR="$ROOT/models"
MODEL="$MODEL_DIR/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf"
MMPROJ="$MODEL_DIR/mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf"
SOURCE="$ROOT/llama.cpp"
BUILD="$SOURCE/build"
HF_BASE="https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main"
SERVER="$BUILD/bin/llama-server"
RUNTIME="$ROOT/run"
PIDFILE="$RUNTIME/llama-server.pid"
LOGFILE="$RUNTIME/llama-server.log"
verify_files() {
for file in "$MODEL" "$MMPROJ"; do
if [[ ! -f "$file" ]]; then
printf 'Missing required file: %s. Run %s install.\n' "$file" "$0" >&2
return 1
fi
done
printf '%s %s\n%s %s\n' "$MODEL_SHA256" "$MODEL" "$MMPROJ_SHA256" "$MMPROJ" | sha256sum --check --status
}
download_verified() {
local url="$1" target="$2" expected="$3" temporary="$2.download"
rm -f "$temporary"
curl --fail --location --retry 3 --output "$temporary" "$url"
printf '%s %s\n' "$expected" "$temporary" | sha256sum --check --status || {
rm -f "$temporary"
printf 'Downloaded file failed pinned SHA-256 verification: %s\n' "$target" >&2
return 1
}
mv "$temporary" "$target"
}
install_worker() {
command -v git >/dev/null
command -v cmake >/dev/null
command -v curl >/dev/null
mkdir -p "$ROOT" "$MODEL_DIR"
if [[ ! -d "$SOURCE/.git" ]]; then
git clone https://github.com/ggml-org/llama.cpp "$SOURCE"
fi
git -C "$SOURCE" fetch origin "$LLAMA_CPP_COMMIT"
git -C "$SOURCE" checkout --detach "$LLAMA_CPP_COMMIT"
cmake -S "$SOURCE" -B "$BUILD" -DCMAKE_BUILD_TYPE=Release -DLLAMA_CURL=OFF
cmake --build "$BUILD" --parallel "${TIMMY_BUILD_JOBS:-4}" --target llama-server
download_verified "$HF_BASE/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf" "$MODEL" "$MODEL_SHA256"
download_verified "$HF_BASE/mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf" "$MMPROJ" "$MMPROJ_SHA256"
verify_files
printf 'Pinned model files verified.\n'
}
health_worker() {
curl --fail --silent --show-error --retry 5 --retry-connrefused --retry-delay 1 --max-time 3 "http://$HOST:$PORT/v1/models" | grep -q 'SmolVLM2-2.2B-Instruct'
printf 'Self-hosted worker healthy at http://%s:%s/v1\n' "$HOST" "$PORT"
}
start_worker() {
verify_files
if [[ ! -x "$SERVER" ]]; then
printf 'Missing llama-server executable: %s. Run %s install.\n' "$SERVER" "$0" >&2
return 1
fi
mkdir -p "$RUNTIME"
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
health_worker
return
fi
rm -f "$PIDFILE"
nohup "$SERVER" \
--model "$MODEL" \
--mmproj "$MMPROJ" \
--alias SmolVLM2-2.2B-Instruct \
--host "$HOST" \
--port "$PORT" \
--ctx-size 4096 \
--parallel 1 \
--threads "${TIMMY_MODEL_THREADS:-4}" \
--no-webui >"$LOGFILE" 2>&1 &
printf '%s\n' "$!" > "$PIDFILE"
for _ in {1..30}; do
if health_worker >/dev/null 2>&1; then
printf 'Self-hosted worker started privately at http://%s:%s/v1\n' "$HOST" "$PORT"
return
fi
sleep 0.2
done
kill "$(cat "$PIDFILE")" 2>/dev/null || true
rm -f "$PIDFILE"
printf 'Worker did not become healthy; inspect %s\n' "$LOGFILE" >&2
return 1
}
stop_worker() {
if [[ ! -f "$PIDFILE" ]]; then
printf 'Self-hosted worker is not running.\n'
return
fi
local pid
pid="$(cat "$PIDFILE")"
kill "$pid" 2>/dev/null || true
for _ in {1..25}; do
if ! kill -0 "$pid" 2>/dev/null; then
rm -f "$PIDFILE"
printf 'Self-hosted worker stopped.\n'
return
fi
sleep 0.1
done
kill -9 "$pid" 2>/dev/null || true
rm -f "$PIDFILE"
printf 'Self-hosted worker stopped.\n'
}
case "${1:-}" in
receipt)
printf 'llama.cpp commit: %s\n' "$LLAMA_CPP_COMMIT"
printf 'SmolVLM2-2.2B-Instruct-Q4_K_M.gguf sha256: %s\n' "$MODEL_SHA256"
printf 'mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf sha256: %s\n' "$MMPROJ_SHA256"
printf 'bind address: %s:%s\n' "$HOST" "$PORT"
;;
verify)
verify_files
printf 'Pinned model files verified.\n'
;;
install)
install_worker
;;
start)
start_worker
;;
health)
health_worker
;;
stop)
stop_worker
;;
*)
printf 'Usage: %s {install|start|health|stop|receipt|verify}\n' "$0" >&2
exit 64
;;
esac

View File

@ -105,7 +105,7 @@ await caption('Automated checks replay this synthetic path before review', 1200)
await caption('One clear photo action. Manual logging stays one tap away.', 1500); await caption('One clear photo action. Manual logging stays one tap away.', 1500);
await tap('[data-scan]', 450); await tap('[data-scan]', 450);
await page.getByText(/Self-hosted model ready/i).waitFor(); await page.getByText(/Self-hosted model ready/i).waitFor();
await caption('The self-hosted vision route is ready', 1000); await caption('The pinned bootstrap verifies both model files before starting on private loopback', 1500);
await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await caption('Nothing uploads until explicit consent', 1100); await caption('Nothing uploads until explicit consent', 1100);
await page.locator('#ai-consent').check(); await page.locator('#ai-consent').check();

View File

@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises';
const workflowPath = new URL('../.gitea/workflows/quality.yml', import.meta.url); const workflowPath = new URL('../.gitea/workflows/quality.yml', import.meta.url);
const diffCheckPath = new URL('../scripts/check_diff.sh', import.meta.url); const diffCheckPath = new URL('../scripts/check_diff.sh', import.meta.url);
const pythonRequirementsPath = new URL('../requirements-test.txt', import.meta.url); const pythonRequirementsPath = new URL('../requirements-test.txt', import.meta.url);
const packagePath = new URL('../package.json', import.meta.url);
test('Gitea CI gates pull requests and main with the reproducible quality suite', async () => { test('Gitea CI gates pull requests and main with the reproducible quality suite', async () => {
const workflow = await readFile(workflowPath, 'utf8'); const workflow = await readFile(workflowPath, 'utf8');
@ -31,6 +32,12 @@ test('CI pins the Python image dependency required by the full unit suite', asyn
assert.match(requirements, /^Pillow==12\.3\.0$/m); assert.match(requirements, /^Pillow==12\.3\.0$/m);
}); });
test('default unit suite includes the self-host bootstrap contract', async () => {
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
assert.match(packageJson.scripts.test, /tests\/selfhost-bootstrap\.test\.js/);
});
test('diff hygiene checks only the pull request or latest commit range', async () => { test('diff hygiene checks only the pull request or latest commit range', async () => {
const script = await readFile(diffCheckPath, 'utf8'); const script = await readFile(diffCheckPath, 'utf8');

View File

@ -11,6 +11,7 @@ test('release demo visibly explains the CI-protected browser path without overst
assert.match(demo, /Automated checks replay this synthetic path before review/); assert.match(demo, /Automated checks replay this synthetic path before review/);
assert.match(demo, /One clear photo action\. Manual logging stays one tap away\./); assert.match(demo, /One clear photo action\. Manual logging stays one tap away\./);
assert.match(demo, /tests\/fixtures\/synthetic-type4\.jpg/); assert.match(demo, /tests\/fixtures\/synthetic-type4\.jpg/);
assert.match(demo, /The pinned bootstrap verifies both model files before starting on private loopback/);
assert.match(demo, /AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis/); assert.match(demo, /AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis/);
assert.match(demo, /Hermes Agent connected/); assert.match(demo, /Hermes Agent connected/);
assert.match(demo, /Photos stay out of chat/); assert.match(demo, /Photos stay out of chat/);

View File

@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const script = new URL('../scripts/bootstrap_selfhost_smolvlm.sh', import.meta.url).pathname;
test('self-host bootstrap reports immutable source and model receipts', () => {
const result = spawnSync('bash', [script, 'receipt'], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /llama\.cpp commit: [0-9a-f]{40}/);
assert.match(result.stdout, /SmolVLM2-2\.2B-Instruct-Q4_K_M\.gguf sha256: [0-9a-f]{64}/);
assert.match(result.stdout, /mmproj-SmolVLM2-2\.2B-Instruct-Q8_0\.gguf sha256: [0-9a-f]{64}/);
assert.match(result.stdout, /bind address: 127\.0\.0\.1:8080/);
});
test('self-host bootstrap rejects model files that do not match pinned hashes', () => {
const result = spawnSync('bash', [script, 'verify'], {
encoding: 'utf8',
env: { ...process.env, TIMMY_SELFHOST_ROOT: '/tmp/timmy-missing-bootstrap' },
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Run .* install|Missing required file/);
});
test('self-host bootstrap completes the pinned private worker lifecycle from an empty directory', async () => {
const sandbox = await mkdtemp(join(tmpdir(), 'timmy-bootstrap-'));
const bin = join(sandbox, 'bin');
const root = join(sandbox, 'worker');
spawnSync('mkdir', ['-p', bin]);
const commands = join(sandbox, 'commands.log');
const tools = {
git: `#!/bin/sh\nprintf 'git %s\\n' "$*" >> "$COMMAND_LOG"\nif [ "$1" = clone ]; then mkdir -p "$3/.git"; fi\n`,
cmake: `#!/bin/sh\nprintf 'cmake %s\\n' "$*" >> "$COMMAND_LOG"\nbuild=''; prev=''; for arg in "$@"; do [ "$prev" = --build ] && build="$arg"; prev="$arg"; done\nif [ -n "$build" ]; then mkdir -p "$build/bin"; printf '#!/bin/sh\\n' > "$build/bin/llama-server"; chmod +x "$build/bin/llama-server"; fi\n`,
curl: `#!/bin/sh\nprintf 'curl %s\\n' "$*" >> "$COMMAND_LOG"\nout=''; prev=''; for arg in "$@"; do { [ "$prev" = -o ] || [ "$prev" = --output ]; } && out="$arg"; prev="$arg"; done\ncase "$out" in *mmproj*) printf 'mmproj\\n' > "$out";; *) printf 'model\\n' > "$out";; esac\n`,
};
for (const [name, contents] of Object.entries(tools)) {
await writeFile(join(bin, name), contents);
await chmod(join(bin, name), 0o755);
}
const result = spawnSync('bash', [script, 'install'], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
COMMAND_LOG: commands,
TIMMY_SELFHOST_ROOT: root,
TIMMY_MODEL_SHA256: '98ad61a25e3683b6adf2474b01bbe1c27de6aad2ce3a80ff4140fe473c14e691',
TIMMY_MMPROJ_SHA256: 'ce52f16b076dcb922c667c08d62881c85fa26aca7cc60394050d5bb1153b7276',
},
});
assert.equal(result.status, 0, result.stderr);
const log = await readFile(commands, 'utf8');
assert.match(log, /git clone https:\/\/github\.com\/ggml-org\/llama\.cpp/);
assert.match(log, /checkout --detach 6d05498314db1b57f81c271080018aa2d0b89be9/);
assert.match(log, /curl .*SmolVLM2-2\.2B-Instruct-Q4_K_M\.gguf/);
assert.match(result.stdout, /Pinned model files verified/);
const fakeServer = join(root, 'llama.cpp', 'build', 'bin', 'llama-server');
await writeFile(fakeServer, `#!/usr/bin/env python3
import argparse, json
from http.server import BaseHTTPRequestHandler, HTTPServer
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--host'); parser.add_argument('--port', type=int)
args, _ = parser.parse_known_args()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps({'data': [{'id': 'SmolVLM2-2.2B-Instruct'}]}).encode()
self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers(); self.wfile.write(body)
def log_message(self, *_): pass
HTTPServer((args.host, args.port), Handler).serve_forever()
`);
await chmod(fakeServer, 0o755);
const lifecycleEnv = {
...process.env,
TIMMY_SELFHOST_ROOT: root,
TIMMY_MODEL_PORT: String(19000 + process.pid % 1000),
TIMMY_MODEL_SHA256: '98ad61a25e3683b6adf2474b01bbe1c27de6aad2ce3a80ff4140fe473c14e691',
TIMMY_MMPROJ_SHA256: 'ce52f16b076dcb922c667c08d62881c85fa26aca7cc60394050d5bb1153b7276',
};
for (const command of ['start', 'health', 'stop']) {
const commandResult = spawnSync('bash', [script, command], { encoding: 'utf8', env: lifecycleEnv });
assert.equal(commandResult.status, 0, `${command}: ${commandResult.stderr}`);
}
await rm(sandbox, { recursive: true, force: true });
});