#!/usr/bin/env bash # photo-to-memory.sh — OCR a document/photo and store the text in Neuron memory # # Uses GLM-OCR (0.9B, MIT) via mlx-vlm on Apple Silicon. # Model auto-downloads ~1.59 GB to ~/.cache/huggingface/ on first run. # # Usage: # ./tools/photo-to-memory.sh [--dry-run] [--prompt "custom prompt"] # # Prerequisites: # pip install -U mlx-vlm # # Examples: # ./tools/photo-to-memory.sh ~/Desktop/receipt.jpg # ./tools/photo-to-memory.sh ~/Documents/contract.png --dry-run # ./tools/photo-to-memory.sh scan.jpg --prompt "Extract all text from this receipt" set -euo pipefail # ── Config ───────────────────────────────────────────────────────────────────── SOUL_URL="${SOUL_URL:-http://localhost:7770}" GLM_MODEL="${GLM_MODEL:-mlx-community/GLM-OCR-8bit}" MAX_TOKENS="${MAX_TOKENS:-4096}" DEFAULT_PROMPT="Extract all text from this document. Preserve structure including tables, headers, and lists. Output plain text." # ── Colours ──────────────────────────────────────────────────────────────────── RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m' CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; RESET=$'\033[0m' log() { printf "%s%s%s\n" "$CYAN" "$*" "$RESET"; } ok() { printf "%s✓ %s%s\n" "$GREEN" "$*" "$RESET"; } warn() { printf "%s⚠ %s%s\n" "$YELLOW" "$*" "$RESET"; } die() { printf "%s✗ %s%s\n" "$RED" "$*" "$RESET" >&2; exit 1; } # ── Parse args ───────────────────────────────────────────────────────────────── IMAGE_PATH="" DRY_RUN=0 CUSTOM_PROMPT="" while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; --prompt) CUSTOM_PROMPT="$2"; shift 2 ;; --model) GLM_MODEL="$2"; shift 2 ;; --help|-h) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; -*) die "Unknown option: $1" ;; *) [[ -n "$IMAGE_PATH" ]] && die "Only one image file at a time" IMAGE_PATH="$1" shift ;; esac done [[ -z "$IMAGE_PATH" ]] && die "Usage: $0 [--dry-run] [--prompt \"...\"]" [[ -f "$IMAGE_PATH" ]] || die "File not found: $IMAGE_PATH" PROMPT="${CUSTOM_PROMPT:-$DEFAULT_PROMPT}" FILENAME=$(basename "$IMAGE_PATH") ABS_PATH=$(realpath "$IMAGE_PATH") # ── Check runtime ─────────────────────────────────────────────────────────────── if ! python3 -c "import mlx_vlm" 2>/dev/null; then warn "mlx-vlm not installed. Installing now..." pip install -q -U mlx-vlm || die "pip install mlx-vlm failed — run manually: pip install -U mlx-vlm" fi # ── Run GLM-OCR ───────────────────────────────────────────────────────────────── log "Running GLM-OCR on: $FILENAME" log "Model: $GLM_MODEL" [[ "$DRY_RUN" -eq 1 ]] && warn "Dry-run mode — will not post to Neuron" # GLM-OCR output goes to stdout; capture it # First run downloads ~1.59 GB — this is expected and cached thereafter. OCR_TEXT=$(python3 -m mlx_vlm.generate \ --model "$GLM_MODEL" \ --max-tokens "$MAX_TOKENS" \ --temperature 0.0 \ --prompt "$PROMPT" \ --image "$ABS_PATH" \ 2>/dev/null) || die "GLM-OCR failed. Check that mlx-vlm is installed and the image is readable." CHAR_COUNT=${#OCR_TEXT} log "OCR complete — extracted ${CHAR_COUNT} characters" if [[ "$CHAR_COUNT" -lt 5 ]]; then warn "Very short output — the image may be blank or unreadable" fi # ── Preview ───────────────────────────────────────────────────────────────────── printf "\n%s--- OCR output preview (first 400 chars) ---%s\n" "$BOLD" "$RESET" printf "%s\n" "${OCR_TEXT:0:400}" [[ "$CHAR_COUNT" -gt 400 ]] && printf "%s... [+%d more chars]%s\n" "$YELLOW" $((CHAR_COUNT - 400)) "$RESET" printf "\n" # ── Post to Neuron soul ───────────────────────────────────────────────────────── if [[ "$DRY_RUN" -eq 1 ]]; then ok "Dry-run complete — would POST ${CHAR_COUNT} chars to ${SOUL_URL}/api/neuron/memory" exit 0 fi log "Posting to Neuron soul at ${SOUL_URL} ..." PAYLOAD=$(python3 -c " import json, sys content = sys.argv[1] label = sys.argv[2] tags = ['photo-import', 'ocr', 'glm-ocr'] print(json.dumps({'content': content, 'label': label, 'tags': tags})) " "$OCR_TEXT" "Photo: ${FILENAME}") HTTP_STATUS=$(curl -s -o /tmp/photo-to-memory-response.json -w "%{http_code}" \ -X POST "${SOUL_URL}/api/neuron/memory" \ -H "Content-Type: application/json" \ -d "$PAYLOAD") if [[ "$HTTP_STATUS" =~ ^2 ]]; then NODE_ID=$(python3 -c " import json, sys try: d = json.load(open('/tmp/photo-to-memory-response.json')) print(d.get('id', d.get('node_id', 'unknown'))) except Exception: print('unknown') ") ok "Memory node created: ${NODE_ID}" ok "Label: Photo: ${FILENAME}" ok "Tags: photo-import, ocr, glm-ocr" else BODY=$(cat /tmp/photo-to-memory-response.json 2>/dev/null || echo "(no body)") die "Soul returned HTTP ${HTTP_STATUS}: ${BODY}" fi