diff --git a/README.md b/README.md index fc92448..c717dd5 100644 --- a/README.md +++ b/README.md @@ -20,23 +20,22 @@ The builder clones the committed `main` tree into an isolated directory, runs un ```bash npm install -git clone --depth 1 https://github.com/ggml-org/llama.cpp /root/model-spikes/llama.cpp -cmake -S /root/model-spikes/llama.cpp -B /root/model-spikes/llama.cpp/build -DCMAKE_BUILD_TYPE=Release -cmake --build /root/model-spikes/llama.cpp/build -j4 --target llama-server +# Installs the exact llama.cpp commit and verifies both GGUF SHA-256 receipts. +scripts/bootstrap_selfhost_smolvlm.sh install +scripts/bootstrap_selfhost_smolvlm.sh start +scripts/bootstrap_selfhost_smolvlm.sh health -hf download ggml-org/SmolVLM2-2.2B-Instruct-GGUF \ - 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 +# Run Timmy in another terminal. TIMMY_VISION_PROFILE=selfhost npm start # 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 @@ -120,7 +119,8 @@ Browser PWA ├── src/hermes-agent-service.js authenticated session-bound Hermes CLI adapter ├── src/vision-service.js server-side OpenAI-compatible provider adapter ├── 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 ├── localStorage saved ledger and attached photos └── llama.cpp / hosted API selected per server-side profile diff --git a/package.json b/package.json index dfaeef9..02346d3 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,11 @@ "private": true, "type": "module", "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:photo": "node tests/photo-first.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", "start": "node server.mjs" }, diff --git a/scripts/bootstrap_selfhost_smolvlm.sh b/scripts/bootstrap_selfhost_smolvlm.sh new file mode 100755 index 0000000..a6a33e1 --- /dev/null +++ b/scripts/bootstrap_selfhost_smolvlm.sh @@ -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 diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index 343c41a..7214b88 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -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 tap('[data-scan]', 450); 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 caption('Nothing uploads until explicit consent', 1100); await page.locator('#ai-consent').check(); diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index a23d2fe..f0a6822 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises'; const workflowPath = new URL('../.gitea/workflows/quality.yml', 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 packagePath = new URL('../package.json', import.meta.url); test('Gitea CI gates pull requests and main with the reproducible quality suite', async () => { 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); }); +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 () => { const script = await readFile(diffCheckPath, 'utf8'); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index 44b8302..5619d26 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -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, /One clear photo action\. Manual logging stays one tap away\./); 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, /Hermes Agent connected/); assert.match(demo, /Photos stay out of chat/); diff --git a/tests/selfhost-bootstrap.test.js b/tests/selfhost-bootstrap.test.js new file mode 100644 index 0000000..9f878be --- /dev/null +++ b/tests/selfhost-bootstrap.test.js @@ -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 }); +});