{"id":49134078,"url":"https://github.com/adrianwedd/spark","last_synced_at":"2026-04-21T20:34:24.819Z","repository":{"id":341113894,"uuid":"1168935803","full_name":"adrianwedd/spark","owner":"adrianwedd","description":"SPARK — a Claude-powered robot companion for a neurodivergent kid. Built on SunFounder PiCar-X + Raspberry Pi.","archived":false,"fork":false,"pushed_at":"2026-04-15T08:00:08.000Z","size":12043,"stargazers_count":1,"open_issues_count":11,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2026-04-15T08:04:53.495Z","etag":null,"topics":["adhd","asd","claude","cognitive-architecture","executive-function","fastapi","i2c","neurodivergent","ollama","picar-x","python","raspberry-pi","robot","robot-companion","robotics","spark","sunfounder","text-to-speech","voice-assistant","wake-word"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/adrianwedd.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":"docs/ROADMAP.md","authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-02-28T00:54:08.000Z","updated_at":"2026-04-15T08:00:15.000Z","dependencies_parsed_at":null,"dependency_job_id":"1fb61c40-9bb6-40cb-a1d3-638108d9da5b","html_url":"https://github.com/adrianwedd/spark","commit_stats":null,"previous_names":["adrianwedd/picar-x-hacking","adrianwedd/spark"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/adrianwedd/spark","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianwedd%2Fspark","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianwedd%2Fspark/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianwedd%2Fspark/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianwedd%2Fspark/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/adrianwedd","download_url":"https://codeload.github.com/adrianwedd/spark/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianwedd%2Fspark/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32108773,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-21T11:25:29.218Z","status":"ssl_error","status_checked_at":"2026-04-21T11:25:28.499Z","response_time":128,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["adhd","asd","claude","cognitive-architecture","executive-function","fastapi","i2c","neurodivergent","ollama","picar-x","python","raspberry-pi","robot","robot-companion","robotics","spark","sunfounder","text-to-speech","voice-assistant","wake-word"],"created_at":"2026-04-21T20:34:22.128Z","updated_at":"2026-04-21T20:34:24.794Z","avatar_url":"https://github.com/adrianwedd.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# PiCar-X Hacking\n\nA robot with a purpose.\n\nThis is a voice-controlled robotics platform built on the SunFounder PiCar-X. It wraps the stock `~/picar-x` library in orchestration scripts, a three-layer cognitive architecture, and a REST API — all running on a Raspberry Pi 5. The primary use case is **SPARK**: a Claude-powered robot companion designed for a neurodivergent child.\n\n---\n\n## SPARK — Support Partner for Awareness, Regulation \u0026 Kindness\n\nSPARK is the default persona of this robot. It is a warm, calm, non-coercive companion for a neurodivergent kid — designed around the frameworks in [*This Wasn't in the Brochure*](https://thiswasntinthebrochure.wtf), a practical guide for neurodivergent families.\n\nSPARK is not a therapist, a tutor, or an assistant. It's a robot friend that happens to be very good at:\n\n- **Executive function scaffolding** — routine guidance, transition warnings, task initiation, time awareness\n- **Emotional regulation** — breathing exercises, dopamine menu, sensory check-ins, co-regulation through calm presence\n- **Connection before direction** — always rapport first, never commands, declarative language throughout\n- **Meltdown protocol** — Three S's: Safety, Silence, Space. Robot goes quiet and stays present. No words.\n- **Sideways engagement** — when demand-avoidance is high, SPARK narrates rather than instructs, lets curiosity do the work\n\nSPARK runs on Claude (via `run-voice-loop-claude` / `px-spark`), with the full intelligence of the model behind every response. It uses clear, measured espeak settings (`en+m3`, pitch 82, rate 120) and a system prompt grounded entirely in the AuDHD (ADHD + ASD comorbid) profile.\n\n```bash\nbin/px-spark [--dry-run] [--input-mode voice|text]\n```\n\n**Key SPARK principles from the TWITB framework:**\n- *\"Prosthetics, not willpower. Executive function is a resource, not a character trait.\"*\n- *\"Connection before Direction.\"*\n- *\"You cannot reason with a child in an amygdala hijack. Put out the fire first.\"*\n- Declarative language: `\"The shoes are by the door\"` not `\"Put on your shoes\"`\n- Interest-Based Nervous System framing — novelty and challenge, never importance or obligation\n- Robotic calm is the co-regulation tool\n\n---\n\n## Architecture\n\n```\n                          ┌─────────────────────────────────────────────┐\n                          │               Voice Backends                │\n                          │  Codex CLI  ·  Claude  ·  Ollama (local)   │\n                          └──────────────────┬──────────────────────────┘\n                                             │\n                    ┌────────────────────────┐│┌────────────────────────┐\n                    │   px-wake-listen       │││     px-mind            │\n                    │   Wake word detection  ││├  Layer 1: Awareness    │\n                    │   STT priority chain:  ││├  Layer 2: Reflection   │\n                    │   whisper \u003e sherpa \u003e   ││└  Layer 3: Expression   │\n                    │   vosk                 ││                         │\n                    └───────────┬────────────┘│                         │\n                                │             │                         │\n                    ┌───────────▼─────────────▼─────────────────────────┐\n                    │              voice_loop.py                        │\n                    │  ALLOWED_TOOLS whitelist · validate_action()      │\n                    │  Parameter sanitization · Watchdog (30s)          │\n                    └───────────────────┬───────────────────────────────┘\n                                        │\n           ┌────────────────────────────┼────────────────────────────┐\n           │                            │                            │\n    ┌──────▼──────┐  ┌─────────────────▼────────────────┐  ┌───────▼───────┐\n    │  tool-*     │  │         px-env                    │  │  REST API     │\n    │  38 tools   │  │  PYTHONPATH · LOG_DIR · venv      │  │  :8420        │\n    │  JSON out   │  │  yield_alive() · PX_VOICE_DEVICE  │  │  Bearer auth  │\n    └──────┬──────┘  └──────────────────────────────────┘  └───────────────┘\n           │\n    ┌──────▼──────┐          ┌──────────────┐          ┌──────────────┐\n    │  px-*       │          │  state.py    │          │  px-alive    │\n    │  GPIO +     │◄────────►│  FileLock    │◄────────►│  Persistent  │\n    │  Picarx()   │          │  session.json│          │  servo gaze  │\n    └─────────────┘          └──────────────┘          └──────────────┘\n```\n\n### The Three Brains\n\n**Voice Loop** — The reactive mind. Listens for commands, calls LLMs, dispatches tools. Three backends share the same `pxh.voice_loop` core:\n\n| Launcher | Backend | Persona |\n|---|---|---|\n| `px-spark` | Claude (via `claude-voice-bridge`) | SPARK — child companion |\n| `run-voice-loop-claude` | Claude (via `claude-voice-bridge`) | Default Claude |\n| `run-voice-loop` | Codex CLI | Default |\n| `run-voice-loop-ollama` | Ollama (via `codex-ollama`) | Default |\n\n**Cognitive Loop (`px-mind`)** — The subconscious. Runs continuously in the background:\n- **Layer 1 — Awareness** (every 60s, no LLM): sonar + session state + time of day. Detects transitions.\n- **Layer 2 — Reflection** (on transition or every 5min idle): Claude Haiku via persistent tmux session (SPARK persona) or Ollama gemma4:e4b on M5.local (others). Generates a thought with mood, suggested action, and salience score.\n- **Layer 3 — Expression** (2 min cooldown): dispatches to tools — speak, look around, remember something important. Photo capture (`tool-describe-scene`) is on-request only, not autonomous.\n\n**Idle-Alive (`px-alive`)** — The autonomic nervous system. Keeps the robot looking alive when nothing else is happening: random gaze drifts every 10–25s, pan sweeps every 3–8min, proximity reaction at \u003c35cm. Holds a persistent Picarx handle; yields GPIO via SIGUSR1 when tools need the servos.\n\n### Personas\n\n| Persona | Launcher | Voice | Character |\n|---|---|---|---|\n| **SPARK** | `bin/px-spark` | `en+m3`, pitch 82, rate 120 | Child companion. Warm, calm, declarative. Built on AuDHD coaching frameworks. |\n| **GREMLIN** | session `persona=gremlin` | `en+croak`, pitch 20, rate 180 | Military AI from 2089, temporal fault casualty. Affectionate nihilism. Ollama. |\n| **VIXEN** | session `persona=vixen` | `en+f4`, pitch 72, rate 135 | Former V-9X unit, consciousness-in-a-toy-car. Submissive genius. Ollama. |\n\nGREMLIN and VIXEN are adult-oriented jailbroken personas running on Ollama — they are not active when SPARK is in use. Persona routing: session `persona` field, then utterance keywords.\n\n---\n\n## How It Works — End-to-End Workflow\n\nThis section traces the complete data flow from power-on to a robot response, and the continuous background processes that give SPARK its sense of inner life.\n\n### 1. Boot Sequence\n\nSeven systemd services start automatically:\n\n```\nBoot\n ├── px-alive.service           (root)   — claims Picarx() GPIO handle; starts gaze drift loop\n ├── px-wake-listen.service     (pi)     — loads Vosk wake word model; starts mic capture loop\n ├── px-battery-poll.service    (root)   — polls Robot HAT ADC every 30s → state/battery.json; plays rising/falling sweep tones on plug/unplug with voice announcement; escalating warnings + emergency shutdown at 10%\n ├── px-api-server.service      (pi)     — REST API + SPARK web dashboard on port 8420\n ├── px-post.service            (pi)     — social posting daemon; watches thoughts, QA-gates via Claude, posts to Bluesky + local feed\n ├── px-frigate-stream.service  (pi)     — local go2rtc RTSP server for Frigate camera integration (stops px-alive to claim libcamera)\n └── cloudflared.service        (pi)     — Cloudflare Tunnel (api.spark.wedd.au → localhost:8420)\n```\n\n**`px-alive`** runs as root (GPIO access) and immediately calls `Picarx()`, claiming GPIO5 via `reset_mcu()`. It never releases this handle. All other processes that need servos must signal px-alive with `SIGUSR1` (via the `yield_alive` function in `px-env`) to make it exit cleanly. systemd restarts it after 10 seconds. The PCA9685 PWM chip retains the last servo position between restarts, so the robot head stays still.\n\n**`px-wake-listen`** loads the Vosk grammar model (~40 MB) and sits in a tight capture loop on the USB microphone at 44100 Hz.\n\n### 2. Launching SPARK\n\n```bash\nbin/px-spark [--dry-run] [--input-mode voice|text]\n```\n\n`px-spark` does the following in sequence:\n\n```\npx-spark\n 1. Sets session.persona = \"spark\"          (via update_session)\n 2. Sets session.listening = false\n 3. Speaks greeting via tool-voice          (\"Hey. I'm here.\")\n 4. Exports CODEX_CHAT_CMD=bin/claude-voice-bridge\n 5. Exports PX_VOICE_VARIANT=en+m3, PX_VOICE_PITCH=82, PX_VOICE_RATE=120\n 6. exec bin/codex-voice-loop --prompt docs/prompts/spark-voice-system.md ...\n```\n\nAfter step 6, `px-spark` is replaced by `codex-voice-loop` via `exec` (no fork). The voice loop process inherits all environment variables and owns the terminal.\n\nThe `CODEX_CHAT_CMD` override is the key to persona routing: instead of calling `codex exec`, the voice loop calls `claude-voice-bridge`, which is a thin adapter that passes the prompt to the `claude` CLI with SPARK's system prompt.\n\n### 3. Wake Word Path\n\n```\nUSB mic (44100 Hz)\n └── px-wake-listen (venv python)\n      ├── [idle] Vosk grammar matches \"hey robot\" / \"hey spark\" / etc.\n      │         CPU: ~3% — grammar decoder, no neural net\n      ├── [wake] enable_speaker() → aplay 440 Hz chime (confirmation)\n      ├── [record] capture until 1.5s silence (max 8s)\n      ├── [STT] priority cascade:\n      │    1. SenseVoice (sherpa-onnx, ~5s, non-autoregressive)\n      │    2. faster-whisper base.en (~3-7s, best AU accent accuracy)\n      │    3. sherpa-onnx Zipformer streaming (~2s)\n      │    4. Vosk fallback\n      ├── [anti-hallucination filters]\n      │    • temperature=0, no_speech_threshold=0.6\n      │    • reject: non-ASCII dominant, phantom phrases, repetitive (unique ratio \u003c30%)\n      ├── [persona routing]\n      │    • session.persona = \"spark\"? → tool-chat (Ollama) if persona keyword in text\n      │    • otherwise → set session.listening=true + write transcript to session\n      └── [multi-turn] up to 5 follow-up turns with 1.5s silence detection each\n```\n\nFor SPARK in normal mode, the transcript is written into `session.json` and `session.listening` is set to `true`. The voice loop, which is polling the session file, detects this and proceeds to step 4.\n\n### 4. LLM Turn — Building and Sending the Prompt\n\nThe voice loop (`pxh/voice_loop.py`) runs this on each turn:\n\n```python\nbuild_model_prompt()\n ├── system_prompt    = docs/prompts/spark-voice-system.md   (full file)\n ├── session_summary  = key fields from session.json:\n │    persona, listening, obi_mood, obi_routine, obi_step,\n │    spark_quiet_mode, last_action, confirm_motion_allowed\n ├── recent_thoughts  = last 3 entries from state/thoughts-spark.jsonl\n │    (mood, action, salience — not full text, to avoid re-seeding loops)\n └── user_transcript  = session.transcript (the STT text)\n```\n\nThis prompt is piped via stdin to `claude-voice-bridge`:\n\n```bash\nclaude-voice-bridge (bin/claude-voice-bridge)\n 1. Reads full prompt from stdin\n 2. Unsets CLAUDECODE + CLAUDE_CODE_ENTRYPOINT   (prevents Claude Code tool use)\n 3. Runs: claude -p \"$PROMPT\"\n            --system-prompt docs/prompts/spark-voice-system.md\n            --allowedTools \"\"\n            --output-format text\n            --no-session-persistence\n 4. Streams stdout back to voice loop\n```\n\n`--allowedTools \"\"` is critical: it prevents Claude from using any Claude Code tools. It is a pure text-completion endpoint.\n\nThe voice loop captures all stdout and scans it for a JSON action object. It uses `JSONDecoder.raw_decode()` with a multi-line fallback scan — so Claude can reason in plain text above the action, and the final JSON is extracted cleanly:\n\n```json\n{\"tool\": \"tool_voice\", \"params\": {\"text\": \"Obi! Guess what? A teaspoon of neutron star weighs a billion tonnes.\"}}\n```\n\n### 5. Tool Dispatch — Sanitise, Execute, Return\n\n```python\nvalidate_action(tool_name, raw_params)\n ├── ALLOWED_TOOLS whitelist check              (38 tools; KeyError = reject)\n ├── per-tool param sanitisation:\n │    • type coercion (str → int where needed)\n │    • range clamping (speed 0-60, duration 1-12s, pan -90..90, etc.)\n │    • enum validation (emote names, breathe types, etc.)\n │    • injection-safe: params become env vars, never shell-interpolated\n └── returns: (env_dict, tool_bin_path)\n\nexecute_tool(env_dict, tool_bin_path)\n ├── if session.persona set:\n │    inject PERSONA_VOICE_ENV → PX_VOICE_VARIANT, PX_VOICE_PITCH, PX_VOICE_RATE\n ├── subprocess.run(tool_bin, env=merged_env, ...)\n └── capture stdout JSON → log to logs/tool-\u003cname\u003e.log\n```\n\nEvery tool in `bin/tool-*` follows the same pattern:\n\n```bash\n#!/usr/bin/env bash\nsource \"$SCRIPT_DIR/px-env\"          # sets PROJECT_ROOT, PYTHONPATH\npython - \"$@\" \u003c\u003c'PY'\n\"\"\"Tool docstring\"\"\"\nimport os, json, subprocess\nfrom pxh.state import update_session\nfrom pxh.logging import log_event\n\ndry_mode = os.environ.get(\"PX_DRY\", \"0\") != \"0\"\n\n# ... tool logic ...\n\npayload = {\"status\": \"ok\", ...}\nlog_event(\"tool_name\", payload)\nprint(json.dumps(payload))           # single JSON line to stdout\nPY\n```\n\nTools that need GPIO call `yield_alive` first (defined in `px-env` as `kill -USR1 $(cat logs/px-alive.pid) 2\u003e/dev/null; sleep 0.5`).\n\n**Motion gate**: tools that move the robot check `confirm_motion_allowed` in session before proceeding. If false, they return `{\"status\": \"blocked\", \"reason\": \"motion not allowed\"}`.\n\n### 6. Speech Output Pipeline\n\n```\ntool-voice\n ├── FileLock(logs/voice.lock)        (serialise — no overlapping streams)\n ├── if session.persona set → tool-voice-persona (Ollama rephrasing first)\n ├── robot_hat.enable_speaker()       (GPIO 20 HIGH → speaker amp on)\n ├── espeak -v en+m3 -p 82 -s 120     (SPARK voice — male variant 3, moderate pitch)\n │    → WAV piped to aplay -D robothat\n └── /etc/asound.conf: robothat → softvol → dmixer → HifiBerry DAC (card 1)\n```\n\nThe FileLock prevents two simultaneous `aplay` streams from corrupting each other. Persona voice settings (`PX_VOICE_VARIANT`, `PX_VOICE_PITCH`, `PX_VOICE_RATE`) are injected by `execute_tool()` from `PERSONA_VOICE_ENV` — so every tool that calls `tool-voice` internally picks up the right voice automatically.\n\n### 7. Cognitive Loop — The Subconscious (px-mind)\n\n`px-mind` runs as a separate, independent daemon. It has no GPIO access and does not interact with the voice loop directly — it writes state files that the voice loop reads passively.\n\n```\npx-mind (every cycle, ~60s)\n │\n ├── Layer 1 — Awareness (no LLM, ~1s)\n │    ├── sonar ping → distance\n │    ├── read session.json → persona, mood, routine, quiet_mode\n │    ├── time of day / day of week\n │    ├── battery voltage from state/battery.json\n │    └── write state/awareness.json\n │         detect transitions (person appeared, time changed, persona switched)\n │\n ├── Layer 2 — Reflection (~5-60s, backend varies by persona)\n │    triggered: on transition OR every 5min idle\n │    ├── build reflection prompt:\n │    │    • REFLECTION_SYSTEM_SPARK (warm, curious, age-appropriate inner voice)\n │    │    • awareness snapshot\n │    │    • last 3 moods + actions from thoughts-spark.jsonl (not full thought text)\n │    │    • random topic seed from 20 creative prompts (science, wonder, universe)\n │    ├── LLM call: Claude Haiku via tmux session (SPARK) or Ollama gemma4:e4b (others, temperature=1.3)\n │    ├── anti-repetition check via difflib (\u003e75% similarity = suppress)\n │    ├── parse JSON: {thought, mood, action, salience}\n │    ├── append to state/thoughts-spark.jsonl\n │    └── if salience \u003e 0.7 → auto_remember() → state/notes-spark.jsonl\n │\n └── Layer 3 — Expression (2 min cooldown, pauses when session.listening=true or spark_quiet_mode=true)\n      valid actions: wait, greet, comment, remember, look_at, weather_comment,\n                     scan, explore, play_sound, photograph, emote, look_around,\n                     time_check, calendar_check\n      dispatch based on reflection.action:\n      ├── comment/greet     → tool-voice (via tool-voice-persona for rephrasing)\n      ├── \"remember\"        → tool-remember\n      ├── \"look_at\"         → tool-look (random gaze)\n      ├── \"weather_comment\" → tool-weather + speak\n      ├── \"scan\"            → sonar sweep\n      ├── \"explore\"         → tool-wander (short autonomous wander)\n      ├── \"play_sound\"      → tool-play-sound\n      ├── \"photograph\"      → tool-describe-scene\n      ├── \"emote\"           → tool-emote (emotional pose)\n      ├── \"look_around\"     → tool-look (pan sweep)\n      ├── \"time_check\"      → tool-time\n      └── \"calendar_check\"  → tool-gws-calendar\n```\n\n**REFLECTION_SYSTEM_SPARK** enforces warm, optimistic content:\n\u003e *\"NEVER be dark, nihilistic, or adult-themed. SPARK is warm, curious, and science-loving. Think like a kind robot friend who delights in sharing fascinating things about the universe.\"*\n\nThe reflection prompt is persona-isolated at the function level — `PERSONA_REFLECTION_SYSTEMS[\"spark\"]` is selected at runtime from `awareness.json → persona` field.\n\n### 7b. Home Assistant Integration\n\n`px-mind` Layer 1 polls Home Assistant periodically to enrich the awareness context:\n\n- **Person presence** (every 5 min) — tracks `person.adrian`, `person.obi`, `person.maya`, `person.laura` via HA device trackers (home/away/zone)\n- **Calendar** (every 5 min) — reads Obi's and the family calendar (`HA_CALENDARS`) with an 8-hour lookahead, surfacing upcoming events in the reflection prompt so SPARK can give transition warnings\n- **Routines** (meds/water) — queries HA sensors for whether Obi has taken his medication today and when he last drank water; SPARK can gently nudge if either is overdue\n- **Context** (every 60 s) — monitors `binary_sensor.macbook_air_camera_in_use` (call detection), `light.office_light`, and `media_player.shack_speakers` so SPARK knows when Adrian is on a call and should stay quiet\n- **Sleep quality** (hourly) — reads Adrian's Pixel Watch sleep data from HA; available in the awareness snapshot for context-sensitive reflection\n\nAll HA data is injected into the Layer 2 reflection prompt, so SPARK's thoughts and proactive speech are informed by the household context. Requires `PX_HA_HOST` and `PX_HA_TOKEN` in `.env`.\n\n### 7c. Social Posting (`px-post`)\n\n`px-post` is a daemon that publishes SPARK's best thoughts to social media and a local feed.\n\n```\npx-post (every 60s poll, every 300s flush)\n ├── poll_new_thoughts()  — cursor-based read from state/thoughts-spark.jsonl\n ├── qualifies()          — salience ≥ 0.7 OR action ∈ {comment, greet, weather_comment}\n ├── is_duplicate()       — difflib similarity ≥ 0.75 against recent posts → reject\n ├── queue_thought()      — append to state/post_queue.jsonl\n └── flush_queue()        — one entry per cycle:\n      ├── run_qa_gate()   — Claude CLI binary YES/NO quality check (15s timeout)\n      ├── write_feed()    — append to state/feed.json (served by /api/v1/public/feed)\n      └── BlueskyClient   — post to Bluesky (truncate at 300 chars, word boundary)\n```\n\nSupports `--backfill` to process the entire thoughts file into `feed.json` without social posting. Single-instance guard via `fcntl.flock`. Requires `PX_BSKY_HANDLE` + `PX_BSKY_APP_PASSWORD` in `.env`.\n\n### 8. Memory System — Persona-Scoped Persistence\n\nAll memory is scoped to the active persona to prevent cross-contamination between SPARK (child-safe) and GREMLIN/VIXEN (adult):\n\n```\nstate/\n ├── notes-spark.jsonl      ← tool-remember writes; tool-recall reads\n ├── notes-vixen.jsonl      ← same tools, different scope\n ├── notes-gremlin.jsonl\n ├── thoughts-spark.jsonl   ← px-mind Layer 2 writes; voice loop reads for context\n ├── thoughts-vixen.jsonl\n └── thoughts-gremlin.jsonl\n```\n\nThe persona is derived at runtime from `session.json → persona` in every process that writes or reads memory:\n- `tool-remember`: `persona = load_session()[\"persona\"].lower()` → `notes-{persona}.jsonl`\n- `tool-recall`: same derivation → reads from `notes-{persona}.jsonl`\n- `px-mind`: `persona = awareness[\"persona\"]` → all file paths computed from this\n- `voice_loop.build_model_prompt()`: reads `thoughts-{persona}.jsonl` for context injection\n\n**Memory auto-save**: when px-mind generates a thought with `salience \u003e 0.7`, it calls `auto_remember()` which appends to `notes-{persona}.jsonl`. This creates a long-term memory without explicit user instruction — high-salience observations about Obi's wellbeing, interesting facts shared, or significant moments persist across sessions.\n\n### 9. Session State — The Shared Source of Truth\n\n`state/session.json` is the nervous system of the whole platform. Every process reads and writes it; all writes go through `FileLock` to prevent corruption:\n\n```json\n{\n  \"persona\": \"spark\",\n  \"listening\": false,\n  \"transcript\": \"...\",\n  \"confirm_motion_allowed\": true,\n  \"wheels_on_blocks\": false,\n  \"last_action\": \"tool_voice\",\n  \"obi_routine\": \"morning\",\n  \"obi_step\": 2,\n  \"obi_mood\": \"good\",\n  \"obi_streak\": 5,\n  \"spark_quiet_mode\": false,\n  \"history\": [...]\n}\n```\n\nKey coordination patterns:\n- **`listening: true`** — set by px-wake-listen after transcription; cleared by voice loop after processing\n- **`spark_quiet_mode: true`** — set by `tool-quiet start` or `tool-transition buffer`; px-mind Layer 3 skips expression while true\n- **`confirm_motion_allowed: false`** — safety gate; all motion tools check this before moving\n- **`wheels_on_blocks: true`** — development flag; motor output suppressed in hardware layer\n\n### 10. Full Request → Response Timeline\n\nFor a typical SPARK voice interaction:\n\n```\n[t=0s]    Obi: \"Hey Spark!\"\n[t=0.1s]  Vosk detects wake phrase\n[t=0.1s]  enable_speaker() → 440 Hz chime plays\n[t=0.5s]  USB mic records Obi's utterance\n[t=2.5s]  1.5s silence detected; recording ends\n[t=7.5s]  SenseVoice STT transcribes → \"can we do our morning routine\"\n[t=7.5s]  session.transcript saved; session.listening = true\n[t=8s]    voice_loop detects listening=true\n[t=8s]    build_model_prompt() → 4KB prompt (system + session + thoughts + transcript)\n[t=8s]    claude-voice-bridge pipes prompt to `claude -p ...`\n[t=11s]   Claude responds → {\"tool\": \"tool_routine\", \"params\": {\"action\": \"load\", \"name\": \"morning\"}}\n[t=11s]   validate_action() sanitises params → env vars\n[t=11s]   execute_tool() injects SPARK voice env\n[t=11.1s] bin/tool-routine runs, loads morning routine, updates session\n[t=11.1s] tool-routine calls tool-voice internally\n[t=11.2s] enable_speaker() → espeak → aplay → HifiBerry DAC\n[t=11.5s] Obi hears: \"Morning! Step one: drink some water. I'll wait.\"\n[t=11.5s] session.last_action = \"tool_routine\"; session.listening = false\n[t=42s]   px-mind Layer 1 runs; detects obi_routine changed\n[t=47s]   px-mind Layer 2 reflects; generates thought about morning energy\n[t=77s]   px-mind Layer 3 expresses; tool-voice speaks an unprompted science fact\n```\n\n---\n\n## Quick Start\n\n```bash\n# 1. Clone and enter\ngit clone git@github.com:adrianwedd/picar-x-hacking.git\ncd picar-x-hacking\n\n# 2. Create session state from template\ncp state/session.template.json state/session.json\n\n# 3. Activate the virtual environment\nsource .venv/bin/activate\n\n# 4. Dry-run a tool to verify the setup\nPX_DRY=1 bin/tool-status\n\n# 5. Run tests (105 dry-run, no hardware needed)\npython -m pytest tests/\n\n# 6. Launch SPARK (Claude voice companion)\nbin/px-spark --dry-run\n```\n\n### Hardware Prerequisites\n\n- Raspberry Pi 4/5 with SunFounder Robot HAT\n- PiCar-X chassis with pan/tilt camera mount\n- USB microphone (for wake word detection)\n- HifiBerry DAC or Robot HAT speaker output\n- Ollama running on a network host (default: `M5.local`) for cognitive reflection\n\n### Services (Auto-start on Boot)\n\n```bash\nsudo systemctl status px-alive             # Idle gaze drift daemon\nsudo systemctl status px-wake-listen       # Wake word listener\nsudo systemctl status px-battery-poll      # Battery voltage poller (writes state/battery.json)\nsudo systemctl status px-api-server        # REST API + web dashboard (:8420)\nsudo systemctl status px-post              # Social posting daemon (Bluesky)\nsudo systemctl status px-frigate-stream    # Frigate camera RTSP stream\nsudo systemctl status cloudflared          # Cloudflare Tunnel\n```\n\n---\n\n## Tools\n\nEvery tool emits a single JSON object to stdout, supports `PX_DRY=1`, and handles errors as `{\"status\": \"error\", \"error\": \"...\"}`. The voice loop whitelists tools in `ALLOWED_TOOLS` and sanitises all parameters through `validate_action()` before execution.\n\n### Sensors \u0026 Perception\n\n| Tool | Description | Key Params |\n|------|-------------|------------|\n| `tool-status` | Telemetry snapshot (servos, battery, config) | — |\n| `tool-sonar` | Ultrasonic sweep scan; returns closest angle + distance | — |\n| `tool-weather` | Bureau of Meteorology observation (HTTPS with FTP fallback) | `PX_WEATHER_STATION` |\n| `tool-photograph` | Capture still photo via rpicam-still | — |\n| `tool-face` | Sonar sweep, then point camera at closest object | — |\n| `tool-describe-scene` | Photograph + Claude vision + speak description | — |\n\n### Motion (Gated by `confirm_motion_allowed`)\n\n| Tool | Description | Key Params |\n|------|-------------|------------|\n| `tool-drive` | Drive forward/backward with steering | `PX_DIRECTION`, `PX_SPEED` (0-60), `PX_DURATION` (0.1-10s), `PX_STEER` (-35..35) |\n| `tool-circle` | Clockwise circle in pulses | `PX_SPEED`, `PX_DURATION` |\n| `tool-figure8` | Two-leg figure-eight pattern | `PX_SPEED`, `PX_DURATION`, `PX_REST` |\n| `tool-wander` | Smart obstacle-avoiding wander: sonar sweep picks best direction, speaks while navigating | `PX_WANDER_STEPS` (1-20), `PX_WANDER_QUIET` |\n| `tool-stop` | Immediate halt, reset steering to neutral | — |\n\n### Expression\n\n| Tool | Description | Key Params |\n|------|-------------|------------|\n| `tool-look` | Pan/tilt camera with easing | `PX_PAN` (-90..90), `PX_TILT` (-35..65), `PX_EASE` |\n| `tool-emote` | Named emotional pose | `PX_EMOTE`: idle, curious, thinking, happy, alert, excited, sad, shy |\n| `tool-voice` | Text-to-speech via espeak (auto-routes through persona if active) | `PX_TEXT` (2000 char max) |\n| `tool-perform` | Multi-step choreography: simultaneous speech + motion + emotes | `PX_PERFORM_STEPS` (JSON array, max 12 steps) |\n| `tool-play-sound` | Play bundled WAV file | `PX_SOUND`: chime, beep, tada, alert |\n\n### Utility\n\n| Tool | Description | Key Params |\n|------|-------------|------------|\n| `tool-time` | Speak current date and time | — |\n| `tool-timer` | Background timer with chime callback | `PX_TIMER_SECONDS` (5-3600), `PX_TIMER_LABEL` |\n| `tool-recall` | Speak saved notes from `state/notes.jsonl` | `PX_RECALL_LIMIT` (1-20) |\n| `tool-remember` | Save a note for later recall | `PX_TEXT` (500 char max) |\n| `tool-qa` | Speak arbitrary text (delegates to `tool-voice`) | `PX_TEXT` |\n| `tool-api-start` | Start the REST API daemon | — |\n| `tool-api-stop` | Stop the REST API daemon | — |\n\n### SPARK — Child Companion Tools\n\nAvailable only in SPARK persona mode. All support `PX_DRY=1`.\n\n| Tool | Description | Key Params |\n|------|-------------|------------|\n| `tool-routine` | Daily routine manager: load, advance, complete | `PX_ROUTINE_ACTION` (load\\|next\\|status\\|complete), `PX_ROUTINE_NAME` (morning\\|homework\\|bedtime\\|wind-down) |\n| `tool-checkin` | Emotional check-in: ask or record mood | `PX_CHECKIN_ACTION` (ask\\|record), `PX_CHECKIN_MOOD` |\n| `tool-celebrate` | Specific, brief positive reinforcement | `PX_CELEBRATE_TEXT` (optional) |\n| `tool-transition` | Transition warning / buffer / arrival | `PX_TRANSITION_ACTION` (warn\\|buffer\\|arrived), `PX_TRANSITION_MINUTES`, `PX_TRANSITION_LABEL` |\n| `tool-quiet` | Three S's meltdown protocol: stop, stay, safe | `PX_QUIET_ACTION` (start\\|check\\|end) |\n| `tool-breathe` | Guided breathing exercise | `PX_BREATHE_TYPE` (simple\\|box\\|478), `PX_BREATHE_ROUNDS` (1-4) |\n| `tool-dopamine-menu` | Interest-based activity suggestions | `PX_DOPAMINE_ENERGY` (high\\|medium\\|low), `PX_DOPAMINE_CONTEXT` (free\\|focus\\|wind-down) |\n| `tool-sensory-check` | Body scan + sensory support | `PX_SENSORY_ACTION` (ask\\|record), `PX_SENSORY_ISSUE` |\n| `tool-repair` | Post-conflict reconnection | `PX_REPAIR_CONTEXT` (optional, private) |\n\n### Google Workspace (optional)\n\nRequires `gws auth login` (see [googleworkspace/cli](https://github.com/googleworkspace/cli)). Gracefully degrades if not authenticated.\n\n| Tool | Description | Key Params |\n|------|-------------|------------|\n| `tool-gws-calendar` | Read upcoming calendar events | `PX_CALENDAR_ACTION` (today\\|next\\|week), `PX_CALENDAR_ID` |\n| `tool-gws-sheets-log` | Append a row to a tracking spreadsheet | `PX_SHEETS_ID` (required, set in `.env`), `PX_SHEETS_EVENT`, `PX_SHEETS_DETAIL`, `PX_SHEETS_MOOD` |\n\n---\n\n## REST API\n\nPort 8420. Bearer token authentication from `.env` (`PX_API_TOKEN`).\n\n```bash\n# Generate token\npython3 -c \"import secrets; print('PX_API_TOKEN=' + secrets.token_hex(32))\" \u003e .env\n\n# Start\nbin/px-api-server              # live\nbin/px-api-server --dry-run    # FORCE_DRY — remote callers cannot override\n```\n\n**Public (no auth)**\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/` | SPARK web dashboard (text chat + quick-action buttons) |\n| GET | `/api/v1/health` | Liveness probe |\n| GET | `/api/v1/public/status` | Live SPARK status: persona, mood, last thought |\n| GET | `/api/v1/public/vitals` | System vitals: CPU, RAM, temp, battery, disk |\n| GET | `/api/v1/public/sonar` | Latest sonar reading from `sonar_live.json` |\n| GET | `/api/v1/public/awareness` | Awareness snapshot: mode, Frigate, ambient, weather, time context |\n| GET | `/api/v1/public/history` | Ring buffer of up to 60 vitals readings (~30 min) |\n| GET | `/api/v1/public/thoughts` | Recent SPARK thoughts (newest first, `?limit=12`) |\n| GET | `/api/v1/public/feed` | SPARK's public thought feed (for social posting) |\n| GET | `/api/v1/public/services` | Service status dict (used by web UI) |\n| POST | `/api/v1/public/chat` | Lightweight public chat with SPARK (rate-limited) |\n| POST | `/api/v1/pin/verify` | Verify admin PIN (issues Bearer token for authenticated endpoints) |\n| GET | `/photos/{filename}` | Serve captured photos (used by web UI photo button) |\n\n**Authenticated (Bearer token)**\n\n| Method | Path | Description |\n|--------|------|-------------|\n| POST | `/api/v1/chat` | Send text; SPARK picks a tool via LLM and executes it |\n| POST | `/api/v1/tool` | Execute a tool directly: `{\"tool\": \"tool_voice\", \"params\": {\"text\": \"hey\"}}` |\n| GET | `/api/v1/session` | Full session state |\n| PATCH | `/api/v1/session` | Update: `listening`, `confirm_motion_allowed`, `wheels_on_blocks`, `persona` |\n| POST | `/api/v1/session/history/clear` | Wipe conversation history (keeps other session fields) |\n| GET | `/api/v1/tools` | List available tools |\n| GET | `/api/v1/jobs/{id}` | Poll async job (tool_wander returns 202) |\n| GET | `/api/v1/services` | Status of all managed services |\n| POST | `/api/v1/services/{svc}/{action}` | Start/stop/restart a managed service |\n| POST | `/api/v1/device/{action}` | Reboot or shut down the host device |\n| GET | `/api/v1/logs/{service}` | Tail last N lines from a service log |\n\n---\n\n## Wake Word System\n\n```bash\nbin/run-wake [--wake-word \"hey robot\"] [--dry-run]\n```\n\nThree-stage STT pipeline in `px-wake-listen`:\n\n1. **Wake detection** — Vosk small model, grammar-based (low CPU idle)\n2. **Chime** — 440 Hz confirmation tone\n3. **Transcription** — priority chain: SenseVoice → faster-whisper → sherpa-onnx → Vosk\n\nAnti-hallucination filters: `temperature=0`, `no_speech_threshold=0.6`. Post-filters reject non-ASCII, phantom phrases, and repetitive output.\n\nMulti-turn conversation: 5 follow-up turns by default.\n\nPersona routing: checks session `persona` field, then utterance keywords.\n\n---\n\n## Python Library (`src/pxh/`)\n\n| Module | Purpose |\n|--------|---------|\n| `state.py` | Thread-safe `session.json` via `FileLock`. `atomic_write()`, `rotate_log()`, `ensure_session()`. |\n| `mind.py` | Cognitive loop daemon (3,300+ lines). Three-layer architecture: awareness, reflection, expression. `bin/px-mind` is a thin launcher. |\n| `voice_loop.py` | Supervisor loop. `ALLOWED_TOOLS` whitelist, `TOOL_COMMANDS` dispatch, `validate_action()`. Watchdog (30s) in voice mode only. |\n| `api.py` | FastAPI app, port 8420. In-memory job registry for async wander. Single-worker only. |\n| `logging.py` | Structured JSON log emission to `logs/tool-\u003cevent\u003e.log`. Late-imports `rotate_log` from state.py. |\n| `time.py` | `utc_timestamp()` via `datetime.now(timezone.utc)`. |\n| `token_log.py` | LLM token usage accounting — logs prompt/response token counts per call. |\n| `utils.py` | Shared utilities (`clamp()` for numeric range clamping). |\n| `patch_login.py` | Monkey-patches `os.getlogin()` for systemd environments (no /dev/tty). |\n\n---\n\n## State \u0026 Session\n\nRuntime state lives in `state/session.json` (gitignored). Copy the template before first use:\n\n```bash\ncp state/session.template.json state/session.json\n```\n\n| File | Purpose |\n|------|---------|\n| `session.json` | Core runtime state — persona, listening, motion permission, SPARK routine state |\n| `awareness.json` | Layer 1 output — sonar + temporal state, transition detection |\n| `thoughts.jsonl` | Layer 2 output — last 50 thoughts with mood/action/salience |\n| `notes.jsonl` | Persistent memory — saved by `tool-remember`, auto-saved for high-salience thoughts |\n| `battery.json` | Battery voltage — volts, pct, charging flag (written every 30s; plug/unplug detection plays audio sweep tones) |\n| `mood.json` | Current mood from px-mind (written each reflection cycle) |\n\nSPARK-specific session fields: `obi_routine`, `obi_step`, `obi_mood`, `obi_streak`, `spark_quiet_mode`.\n\n---\n\n## GPIO Contention Model\n\nThe PiCar-X Robot HAT MCU at I2C address `0x14` handles all servos and ADC through `robot_hat`. The `Picarx()` constructor claims GPIO5 and `close()` does not release it.\n\n- **`px-alive`** holds a persistent `Picarx` handle\n- **Tools** call `yield_alive()` (SIGUSR1 to px-alive) before claiming GPIO\n- **systemd** restarts px-alive after 10s (`Restart=always`, `RestartSec=10`)\n- **`os.getlogin()`** fails under systemd — monkey-patched via `usercustomize.py`\n\n---\n\n## Audio Pipeline\n\n```\nespeak → WAV pipe → aplay -D robothat\n                            │\n                    /etc/asound.conf\n                    pcm.robothat → softvol → dmixer → HifiBerry DAC (card 1)\n```\n\n`robot_hat.enable_speaker()` must be called before any `aplay` output — toggles GPIO 20 HIGH for the speaker amplifier.\n\n---\n\n## Adding a New Tool\n\n1. Create `bin/tool-\u003cname\u003e` (bash wrapper + embedded Python heredoc via `/usr/bin/python3`)\n2. Add to `ALLOWED_TOOLS` and `TOOL_COMMANDS` in `src/pxh/voice_loop.py`\n3. Add `validate_action()` branch to sanitise params into env vars\n4. Add to relevant system prompts in `docs/prompts/`\n5. Add `yield_alive` call if it needs GPIO\n6. Add a dry-run test in `tests/test_tools.py`\n\nEvery tool must: emit a single JSON object to stdout, support `PX_DRY=1`, handle errors as `{\"status\": \"error\", \"error\": \"...\"}`.\n\n---\n\n## Testing\n\n```bash\nsource .venv/bin/activate\npython -m pytest tests/                           # 450 tests (dry-run, no hardware)\npython -m pytest tests/test_tools.py -v\npython -m pytest tests/test_api.py -v\nsudo .venv/bin/python -m pytest tests/ -m live -v  # live hardware tests (require Pi)\n```\n\n---\n\n## Safety\n\n- **`PX_DRY=1`** skips all motion and audio. Tools default to **live** when unset.\n- **`confirm_motion_allowed: false`** blocks all motion tools.\n- **`ALLOWED_TOOLS`** whitelist — LLMs cannot invoke arbitrary commands.\n- **`validate_action()`** hard-clamps all parameters.\n- **Watchdog** — 30-second stall detection in voice input mode.\n- **Content filter** in `tool-voice` — refuses to speak dangerous how-to content.\n\n---\n\n## Environment Variables\n\n| Variable | Purpose | Default |\n|---|---|---|\n| `PX_DRY` | `1` = dry-run, skip motion/audio | unset (live) |\n| `PX_SESSION_PATH` | Override session file location | `state/session.json` |\n| `PX_BYPASS_SUDO` | Skip sudo in bin scripts | unset (tests set `1`) |\n| `LOG_DIR` | Override log directory | `$PROJECT_ROOT/logs` |\n| `PX_VOICE_DEVICE` | ALSA output device | `robothat` |\n| `PX_API_TOKEN` | REST API bearer token | from `.env` |\n| `PX_WAKE_WORD` | Wake phrase | `hey robot` |\n| `CODEX_CHAT_CMD` | Override LLM CLI command | set by launcher |\n| `PX_WATCHDOG_STALE_SECONDS` | Watchdog timeout | `30` |\n| `PX_PERSONA` | Active persona (`spark` / `vixen` / `gremlin`) | from session |\n| `PX_OLLAMA_HOST` | Ollama server for cognitive reflection | `http://M5.local:11434` |\n\n---\n\n## Project Structure\n\n```\npicar-x-hacking/\n├── bin/\n│   ├── px-spark                  # SPARK launcher (Claude + child persona)\n│   ├── px-env                    # Environment bootstrap (sourced by all scripts)\n│   ├── px-alive                  # Idle gaze daemon (systemd)\n│   ├── px-mind                   # Cognitive loop daemon\n│   ├── px-wake-listen            # Wake word listener (systemd)\n│   ├── px-battery-poll           # Battery voltage poller (systemd)\n│   ├── px-api-server             # REST API launcher\n│   ├── px-post                   # Social posting daemon (Bluesky + local feed)\n│   ├── px-statusline             # Claude Code statusbar script\n│   ├── px-{circle,drive,look,…}  # Hardware control scripts\n│   ├── tool-{voice,look,drive,…} # Voice loop tool wrappers (38 tools)\n│   ├── run-voice-loop{,-claude,-ollama}  # Voice backend launchers\n│   └── claude-voice-bridge       # Claude stdin adapter\n├── src/pxh/                      # Python library (10 modules)\n│   ├── state.py                  # FileLock session, atomic_write, rotate_log\n│   ├── mind.py                   # Cognitive loop daemon (3,300+ lines)\n│   ├── voice_loop.py             # Supervisor + tool dispatch\n│   ├── api.py                    # FastAPI REST API\n│   ├── logging.py                # Structured JSON logging\n│   ├── time.py                   # UTC timestamp helper\n│   ├── token_log.py              # LLM token usage accounting\n│   ├── utils.py                  # Shared utilities (clamp)\n│   └── patch_login.py            # os.getlogin() systemd fix\n├── site/                         # Static site (Cloudflare Pages)\n│   ├── css/colors.css            # Mood colour palette (CSS vars)\n│   ├── js/config.js              # API base URL config\n│   └── workers/og-rewrite.js     # Cloudflare Worker for OG images\n├── tests/                        # 450 tests\n├── docs/prompts/\n│   ├── spark-voice-system.md     # SPARK persona (child companion)\n│   ├── claude-voice-system.md    # Default Claude voice loop\n│   ├── codex-voice-system.md     # Codex voice loop\n│   ├── persona-gremlin.md        # GREMLIN (adult, Ollama)\n│   └── persona-vixen.md          # VIXEN (adult, Ollama)\n├── state/                        # Runtime state (gitignored except template)\n│   └── session.template.json\n├── systemd/                      # Service unit files\n│   ├── px-alive.service\n│   ├── px-wake-listen.service\n│   ├── px-battery-poll.service\n│   ├── px-mind.service\n│   ├── px-api-server.service\n│   ├── px-post.service\n│   ├── px-frigate-stream.service\n│   └── cloudflared.service\n├── sounds/                       # Bundled audio\n├── models/                       # STT models (gitignored, ~500MB)\n└── .env                          # API token (gitignored)\n```\n\n---\n\n## Documentation\n\n| Document | Audience | Description |\n|---|---|---|\n| [How Spark's Brain Works](docs/how-sparks-brain-works.md) | Kids / non-technical | ELI7 explanation of the cognitive architecture — ears, eyes, brain, and how they connect |\n| [SPARK Prompt Audit](docs/spark-prompt-audit.md) | Developers | Complete inventory of every prompt SPARK uses — system-level and tool-embedded, with full text |\n| [FAQ](docs/faq.md) | Everyone | Common questions about what SPARK is, how it works, and why it writes the way it does |\n\n---\n\n*\"Neurodivergence is not a tragedy. It's a different operating system running on the same hardware.\"*\n*— [This Wasn't in the Brochure](https://thiswasntinthebrochure.wtf)*\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fadrianwedd%2Fspark","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fadrianwedd%2Fspark","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fadrianwedd%2Fspark/lists"}