2026-05-06 01:28:59 +00:00
# MeshAI
2026-07-04 00:47:11 -06:00
**An LLM-powered assistant for LoRa mesh networks — on Meshtastic * and * MeshCore, at the same time.**
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
MeshAI connects to your mesh, watches network health and the world around it in real time, answers questions over the air, and broadcasts the alerts that matter — weather, wildfire, road, seismic, RF, and mesh-health — with a full web dashboard to drive it all.
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
> ### 🤖 Built with AI ("vibecoded")
> In the interest of transparency: **MeshAI was vibecoded** — designed, built, debugged, and documented in close collaboration with LLM coding assistants. The architecture, most of the implementation, and this README were produced that way. It's a real, running project on a live mesh, but expect the pragmatic style, opinionated shortcuts, and occasional rough edges that come with the territory. Issues and PRs are welcome.
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00

2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
---
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
## Highlights
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
- **Dual-transport** — runs on **Meshtastic ** and **MeshCore ** simultaneously. Each mesh is first-class: independent connection, routing, and behavior, one shared brain.
- **Conversational bot** — DM it "how's the mesh?" or ask about weather, fires, roads, or a specific node, and get a data-driven answer over LoRa. The reply goes back on whichever mesh you asked from.
- **Per-mesh awareness** — it watches chat on each mesh separately (rolling short-term memory), so "what's happening on the mesh?" answers about * your * mesh. Private DMs stay private; curated knowledge stays separate.
- **Broadcast intelligence** — weather alerts, wildfire updates, road/traffic, seismic, RF/band conditions, and mesh-health notifications, formatted to fit LoRa and routed per-mesh, per-family.
- **Mesh health** — a 5-pillar health score with per-region breakdowns, infrastructure monitoring, coverage-gap analysis, and battery/solar tracking (Meshtastic).
- **Web dashboard** — a clean React UI to configure every transport, route every message type, watch a live activity feed, browse contacts, and tune the bot — no config-file spelunking required.
- **Knowledge base (RAG)** — optional hybrid retrieval over a large curated vector store for survival/comms/technical Q&A.
- **Multi-backend LLM** — Google Gemini, OpenAI, Anthropic, or any OpenAI-compatible local model (Ollama, LiteLLM, etc.).
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
---
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
## The dashboard
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
Everything is driven from the web UI, organized into **General ** , **Meshtastic ** , and **MeshCore ** sections — each mesh mirrors the other so there's nothing to relearn when you add the second transport.
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
**Live activity log** — every broadcast, on both meshes, with per-mesh badges and Sent/Skip status:
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00

2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
**Per-family routing** — decide exactly where each message type goes: broadcast vs. DM, which channel, which recipients — independently for each mesh:
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00

2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
**MeshCore contacts & companion** — the live roster from your MeshCore companion node, with names, types, last-heard, position, and optional telemetry polling:
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00

2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
**Data feeds** — turn environmental sources on/off and tune thresholds in one place:
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00

2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
**Nodes & health** — per-node infrastructure detail: battery, utilization, coverage, neighbors, hardware:
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00

2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
---
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
## Quick start
2026-05-12 21:02:17 -06:00
docs: point onboarding at the dashboard; drop 3 phantom keys from the seeded default (#147)
* fix(entrypoint): drop three phantom history keys from the seeded default
The config written on first boot -- what EVERY fresh Docker install starts
from -- seeded three keys that do not exist on HistoryConfig and are
silently discarded on load:
auto_cleanup: true
cleanup_interval_hours: 24
max_age_days: 30
HistoryConfig has only `database`, `max_messages_per_user`, and
`conversation_timeout`.
To be precise about the impact: history cleanup DOES work -- cleanup_expired()
is wired at main.py:237 and _prune_history() honours max_messages_per_user.
What never existed is the time-based retention model these keys describe (a
30-day age cutoff on a 24h interval). An operator setting max_age_days: 90
expecting 90-day retention was silently ignored.
Not implemented here -- whether time-based retention should exist is a
product decision, not a cleanup. This only stops the default config
promising it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: point onboarding at the dashboard, not the legacy example config
The documented setup path was `cp config.example.yaml config.yaml`, but that
file is ~40% incomplete: of the 20 top-level Config fields it covers 15,
omitting coverage (the universal bbox), danger_zones, generic_sources,
meshcore_context, commands, and the current notification model
(toggles/destinations/region_routes). Its own notifications header still says
the schema "will be replaced in v0.3 by the 8-toggle model" -- which shipped
long ago. Anyone following the project's own instructions landed on a
degraded surface with no signal a richer config existed.
It is also read by NOTHING at runtime: docker-entrypoint.sh sets
MESHAI_CONFIG=/data/config.yaml and writes its own inline default on first
boot. The Dockerfile still COPYs config.example.yaml into the image, so it is
kept and now labelled reference-only rather than a starting point.
- README: Docker quick-start seeds itself; configure via the dashboard. The
pip path still uses config.example.yaml (nothing seeds one there) but now
carries an honest note that it is a minimal bootstrap, not a reference.
Adds an Advanced section for the split /data/config/ layout and the
migrate_config_v03 path into it.
- docker-compose.yml: the comment claimed config lives at /data/config.yaml
as though that were the only layout; corrected to describe both, and note
secrets live in /data/secrets/.env.
- Dockerfile: document why config.example.yaml is still shipped.
Not done deliberately: config.example.yaml is NOT expanded to cover all 20
sections. A second hand-maintained schema is what caused this drift; the
dashboard is the authoritative surface. The legacy single-file loader is
untouched and still fully supported.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(readme): move dashboard-first onboarding rewrite to root README
work/README.md is about to become a symlink to root README.md on
docs/single-readme. Retarget the Docker/dashboard-first quick start
and config.example.yaml honesty note added in be867d23 from
work/README.md to root README.md, adapted to the root file's own
Quick start structure (cd meshai/work, /work/-prefixed curl URLs), so
none of it is silently dropped when that symlink lands.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:07:31 -06:00
The dashboard is the primary way to configure MeshAI — connection, LLM backend, both transports, feeds, and routing. You shouldn't need to hand-edit YAML for a normal setup.
### Docker (recommended)
```bash
mkdir -p meshai/data && cd meshai
curl -O https://raw.githubusercontent.com/zvx-echo6/meshai/main/work/docker-compose.yml
docker compose up -d
```
On first boot MeshAI writes a minimal starter config into the `meshai_data` volume (`/data/config.yaml` , plus an empty `/data/secrets/.env` ) and starts the dashboard — nothing to pre-seed. Open * * `http://localhost:8080` ** and configure everything from there.
As you save settings, MeshAI persists them back into `/data` as focused per-domain YAML files (`llm.yaml` , `meshtastic.yaml` , `notifications.yaml` , `env_feeds.yaml` , …) alongside `config.yaml` — the same multi-file config system MeshAI uses in production; the dashboard is the intended way to drive it. API keys and other secrets you enter in the dashboard are written only to `/data/secrets/.env` , never into the YAML.
### From source (pip)
2026-07-04 00:47:11 -06:00
```bash
git clone https://github.com/zvx-echo6/meshai.git
chore: remove duplicate top-level source tree (build only from work/) (#125)
Commit 2e1fb325 ("refactor: move source tree into work/, multi-stage Docker
build") COPIED the source tree into work/ but never removed the top-level
originals, so main has been tracking two full copies of the app ever since.
Only work/ is live:
- CI (.github/workflows/docker-publish.yml, the sole workflow) builds with
`context: work` / `file: work/Dockerfile`. It never references the
top-level meshai/, Dockerfile, tests/, or pyproject.toml.
- Production (CT 108) runs work/docker-compose.yml, whose build context is
work/ and whose Dockerfile does `COPY meshai/ ./meshai/` relative to it.
- work/ is self-contained: its own Dockerfile, docker-compose.yml,
pyproject.toml, requirements.txt, config.example.yaml, dashboard-frontend/
and tests/.
The top-level copy carried ZERO unique content. work/ is strictly newer
everywhere (migrations v19-v28 vs top-level's v18; whole subsystems the top
level never had: coverage, generic_http, satpass, wzdx, notifications/gating,
notifications/formatters, transport/). Every file that existed ONLY at the top
level is legacy code that work/ deliberately removed after the fork:
- meshai/cli/configurator.py removed from work/ in 5de683f7 (#45, legacy TUI configurator)
- meshai/commands/subscribe.py removed from work/ in 9e6a3715 / 04604624 (subscription backend)
- meshai/subscriptions.py removed from work/ in 9e6a3715 / 04604624 (subscription backend)
- meshai/notifications/scheduled/fire_digest.py
removed from work/ in b0b0697b (#107, fire digest feature)
- meshai/notifications/pipeline/severity_router.py
never existed in work/; superseded by notifications/gating + formatters
- tests/test_fire_digest_recency.py tests the removed fire digest
- tests/test_meshcore_per_family_routing.py
tests the top-level-only meshcore_channel impl, superseded in work/ by transport/
- dashboard-frontend/src/pages/Alerts.tsx
removed from work/ in 9e6a3715 (subscription backend)
- dashboard-frontend/src/pages/Environment.tsx.bak
stray backup file, never in any build
The duplication is an ACTIVE HAZARD, not just dead weight: a fix applied to the
top-level meshai/ passes review, gets merged, and then silently does nothing,
because neither CI nor prod ever reads that tree. This already happened --
ff3ded8c (#9, "independent per-family MeshCore channel") landed its config.py /
connector.py / channels.py / dispatcher.py changes in the DEAD top-level copy
while work/ got only the transport-layer half. (Nothing was lost there: work/
carries a full, later-generation implementation of the same feature wired
through config.py, connector.py, transport/base.py, transport/composite_transport.py,
transport/meshcore_transport.py and the dashboard routes.) Deleting the copy
makes that class of mistake impossible.
Removed (280 files): meshai/, tests/, dashboard-frontend/, config/, Dockerfile,
docker-compose.yml, docker-entrypoint.sh, config.example.yaml, pyproject.toml,
requirements.txt.
Also fixes README Quick start, which pointed at the now-deleted root paths
(main/docker-compose.yml, main/config.example.yaml -> main/work/...) and still
advertised `meshai --config`, the interactive TUI removed from work/ in 5de683f7.
Verification: work/ test suite unchanged before and after --
20 failed, 2240 passed, 72 skipped (identical failure list; all pre-existing).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:37:40 -06:00
cd meshai/work
chore: untrack dashboard build output (stale by 13 commits on the pip path) (#151)
* chore(dashboard): untrack committed frontend build output
work/meshai/dashboard/static/ held committed vite build artifacts
(index.html + hashed assets/*.js|css + copied public/ images) that were
last built 2026-07-07 (PR #87), 13 frontend commits ago. Docker builds
the frontend fresh and overwrites these on every image build, but the
pip install -e . path (documented in README Quick start) ships this
stale directory verbatim via [tool.setuptools.packages.find] include.
The root .gitignore already had a rule for this
("meshai/dashboard/static/") but it silently stopped matching when
2e1fb325 moved the source tree into work/ -- gitignore patterns
containing a "/" are anchored to the .gitignore's own directory, so the
unprefixed pattern only ever matched a repo-root meshai/dashboard/static/
that hasn't existed since that move. Fixed by prefixing with work/,
matching the existing work/data/secrets/.env convention elsewhere in
the file.
Every file under static/ is generated: assets/* and index.html come
from `vite build` (outDir: ../meshai/dashboard/static, emptyOutDir:
true, confirmed in dashboard-frontend/vite.config.ts), and the two
PNGs are vite's copy of dashboard-frontend/public/*.png (byte-identical
to the source). None of it is hand-authored, so the whole directory is
untracked rather than partially.
Blobs remain in git history; files remain on disk, just untracked.
* docs(readme): document the required frontend build for pip installs
work/meshai/dashboard/static/ is no longer committed (previous commit),
so the pip install -e . Quick start path now needs an explicit frontend
build step or the dashboard UI silently doesn't exist. Add it, with a
note clarifying the bot/API still work without it -- only the web UI is
affected. Docker is unaffected (already builds the frontend in its own
stage).
* fix(dashboard): log clearly when the built frontend is missing
Previously, if meshai/dashboard/static/ (or its index.html) was absent,
create_app() silently skipped mounting /assets and registering the
root/catch-all routes -- no log, no error. Any request to "/" would
just 404 with FastAPI's generic "Not Found", giving no clue why.
This was latent before (Docker always builds the frontend, and a git
checkout with the artifacts committed always had the directory), but
now that static/ is untracked, a fresh pip install without the frontend
build step will hit this path as its first real-world trigger. Add a
warning log naming the missing path and the exact build command, and
make clear the bot/API are unaffected -- only the dashboard UI is
absent.
* docs(readme): move frontend-build note to root README
work/README.md is about to become a symlink to root README.md on
docs/single-readme. Retarget the frontend-build documentation added in
56eba0f7 from work/README.md to root README.md so it isn't silently
dropped when that symlink lands.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
2026-07-17 14:14:16 -06:00
cd dashboard-frontend && npm ci && npm run build && cd .. # builds the web dashboard — required, see note
2026-07-04 00:47:11 -06:00
pip install -e .
docs: point onboarding at the dashboard; drop 3 phantom keys from the seeded default (#147)
* fix(entrypoint): drop three phantom history keys from the seeded default
The config written on first boot -- what EVERY fresh Docker install starts
from -- seeded three keys that do not exist on HistoryConfig and are
silently discarded on load:
auto_cleanup: true
cleanup_interval_hours: 24
max_age_days: 30
HistoryConfig has only `database`, `max_messages_per_user`, and
`conversation_timeout`.
To be precise about the impact: history cleanup DOES work -- cleanup_expired()
is wired at main.py:237 and _prune_history() honours max_messages_per_user.
What never existed is the time-based retention model these keys describe (a
30-day age cutoff on a 24h interval). An operator setting max_age_days: 90
expecting 90-day retention was silently ignored.
Not implemented here -- whether time-based retention should exist is a
product decision, not a cleanup. This only stops the default config
promising it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: point onboarding at the dashboard, not the legacy example config
The documented setup path was `cp config.example.yaml config.yaml`, but that
file is ~40% incomplete: of the 20 top-level Config fields it covers 15,
omitting coverage (the universal bbox), danger_zones, generic_sources,
meshcore_context, commands, and the current notification model
(toggles/destinations/region_routes). Its own notifications header still says
the schema "will be replaced in v0.3 by the 8-toggle model" -- which shipped
long ago. Anyone following the project's own instructions landed on a
degraded surface with no signal a richer config existed.
It is also read by NOTHING at runtime: docker-entrypoint.sh sets
MESHAI_CONFIG=/data/config.yaml and writes its own inline default on first
boot. The Dockerfile still COPYs config.example.yaml into the image, so it is
kept and now labelled reference-only rather than a starting point.
- README: Docker quick-start seeds itself; configure via the dashboard. The
pip path still uses config.example.yaml (nothing seeds one there) but now
carries an honest note that it is a minimal bootstrap, not a reference.
Adds an Advanced section for the split /data/config/ layout and the
migrate_config_v03 path into it.
- docker-compose.yml: the comment claimed config lives at /data/config.yaml
as though that were the only layout; corrected to describe both, and note
secrets live in /data/secrets/.env.
- Dockerfile: document why config.example.yaml is still shipped.
Not done deliberately: config.example.yaml is NOT expanded to cover all 20
sections. A second hand-maintained schema is what caused this drift; the
dashboard is the authoritative surface. The legacy single-file loader is
untouched and still fully supported.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(readme): move dashboard-first onboarding rewrite to root README
work/README.md is about to become a symlink to root README.md on
docs/single-readme. Retarget the Docker/dashboard-first quick start
and config.example.yaml honesty note added in be867d23 from
work/README.md to root README.md, adapted to the root file's own
Quick start structure (cd meshai/work, /work/-prefixed curl URLs), so
none of it is silently dropped when that symlink lands.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:07:31 -06:00
cp config.example.yaml config.yaml # minimal starting point, not exhaustive — see note below
2026-07-04 00:47:11 -06:00
meshai
2026-05-12 21:02:17 -06:00
```
chore: untrack dashboard build output (stale by 13 commits on the pip path) (#151)
* chore(dashboard): untrack committed frontend build output
work/meshai/dashboard/static/ held committed vite build artifacts
(index.html + hashed assets/*.js|css + copied public/ images) that were
last built 2026-07-07 (PR #87), 13 frontend commits ago. Docker builds
the frontend fresh and overwrites these on every image build, but the
pip install -e . path (documented in README Quick start) ships this
stale directory verbatim via [tool.setuptools.packages.find] include.
The root .gitignore already had a rule for this
("meshai/dashboard/static/") but it silently stopped matching when
2e1fb325 moved the source tree into work/ -- gitignore patterns
containing a "/" are anchored to the .gitignore's own directory, so the
unprefixed pattern only ever matched a repo-root meshai/dashboard/static/
that hasn't existed since that move. Fixed by prefixing with work/,
matching the existing work/data/secrets/.env convention elsewhere in
the file.
Every file under static/ is generated: assets/* and index.html come
from `vite build` (outDir: ../meshai/dashboard/static, emptyOutDir:
true, confirmed in dashboard-frontend/vite.config.ts), and the two
PNGs are vite's copy of dashboard-frontend/public/*.png (byte-identical
to the source). None of it is hand-authored, so the whole directory is
untracked rather than partially.
Blobs remain in git history; files remain on disk, just untracked.
* docs(readme): document the required frontend build for pip installs
work/meshai/dashboard/static/ is no longer committed (previous commit),
so the pip install -e . Quick start path now needs an explicit frontend
build step or the dashboard UI silently doesn't exist. Add it, with a
note clarifying the bot/API still work without it -- only the web UI is
affected. Docker is unaffected (already builds the frontend in its own
stage).
* fix(dashboard): log clearly when the built frontend is missing
Previously, if meshai/dashboard/static/ (or its index.html) was absent,
create_app() silently skipped mounting /assets and registering the
root/catch-all routes -- no log, no error. Any request to "/" would
just 404 with FastAPI's generic "Not Found", giving no clue why.
This was latent before (Docker always builds the frontend, and a git
checkout with the artifacts committed always had the directory), but
now that static/ is untracked, a fresh pip install without the frontend
build step will hit this path as its first real-world trigger. Add a
warning log naming the missing path and the exact build command, and
make clear the bot/API are unaffected -- only the dashboard UI is
absent.
* docs(readme): move frontend-build note to root README
work/README.md is about to become a symlink to root README.md on
docs/single-readme. Retarget the frontend-build documentation added in
56eba0f7 from work/README.md to root README.md so it isn't silently
dropped when that symlink lands.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
2026-07-17 14:14:16 -06:00
> **The frontend build step is required.** `meshai/dashboard/static/` is no longer
> committed to the repo, so skipping it means no dashboard UI is served (the bot and
> API still run fine). Docker users don't need this — the image builds the frontend
> automatically. Requires Node.js/npm.
docs: point onboarding at the dashboard; drop 3 phantom keys from the seeded default (#147)
* fix(entrypoint): drop three phantom history keys from the seeded default
The config written on first boot -- what EVERY fresh Docker install starts
from -- seeded three keys that do not exist on HistoryConfig and are
silently discarded on load:
auto_cleanup: true
cleanup_interval_hours: 24
max_age_days: 30
HistoryConfig has only `database`, `max_messages_per_user`, and
`conversation_timeout`.
To be precise about the impact: history cleanup DOES work -- cleanup_expired()
is wired at main.py:237 and _prune_history() honours max_messages_per_user.
What never existed is the time-based retention model these keys describe (a
30-day age cutoff on a 24h interval). An operator setting max_age_days: 90
expecting 90-day retention was silently ignored.
Not implemented here -- whether time-based retention should exist is a
product decision, not a cleanup. This only stops the default config
promising it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: point onboarding at the dashboard, not the legacy example config
The documented setup path was `cp config.example.yaml config.yaml`, but that
file is ~40% incomplete: of the 20 top-level Config fields it covers 15,
omitting coverage (the universal bbox), danger_zones, generic_sources,
meshcore_context, commands, and the current notification model
(toggles/destinations/region_routes). Its own notifications header still says
the schema "will be replaced in v0.3 by the 8-toggle model" -- which shipped
long ago. Anyone following the project's own instructions landed on a
degraded surface with no signal a richer config existed.
It is also read by NOTHING at runtime: docker-entrypoint.sh sets
MESHAI_CONFIG=/data/config.yaml and writes its own inline default on first
boot. The Dockerfile still COPYs config.example.yaml into the image, so it is
kept and now labelled reference-only rather than a starting point.
- README: Docker quick-start seeds itself; configure via the dashboard. The
pip path still uses config.example.yaml (nothing seeds one there) but now
carries an honest note that it is a minimal bootstrap, not a reference.
Adds an Advanced section for the split /data/config/ layout and the
migrate_config_v03 path into it.
- docker-compose.yml: the comment claimed config lives at /data/config.yaml
as though that were the only layout; corrected to describe both, and note
secrets live in /data/secrets/.env.
- Dockerfile: document why config.example.yaml is still shipped.
Not done deliberately: config.example.yaml is NOT expanded to cover all 20
sections. A second hand-maintained schema is what caused this drift; the
dashboard is the authoritative surface. The legacy single-file loader is
untouched and still fully supported.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(readme): move dashboard-first onboarding rewrite to root README
work/README.md is about to become a symlink to root README.md on
docs/single-readme. Retarget the Docker/dashboard-first quick start
and config.example.yaml honesty note added in be867d23 from
work/README.md to root README.md, adapted to the root file's own
Quick start structure (cd meshai/work, /work/-prefixed curl URLs), so
none of it is silently dropped when that symlink lands.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:07:31 -06:00
Unlike Docker, `meshai` won't create a config file for you — it needs one to exist before it will start. `config.example.yaml` bootstraps the basics (connection, LLM backend, bot behavior); once it's running, open `http://localhost:8080` and use the dashboard for everything else.
> **Note on `config.example.yaml`:** it documents the legacy single-file schema and is no longer complete — it predates `coverage`, `danger_zones`, `generic_sources`, `meshcore_context`, `commands`, and the current 8-family notification-routing model (`notifications.toggles` / `destinations` / `region_routes`). The legacy single-file loader still works and is fully supported, but the dashboard — backed by the full config schema — is the authoritative way to reach every setting. Don't treat this file as a complete reference.
### Advanced: the split `/data/config/` layout
Production and multi-operator deployments typically move to a fully split config directory — `/data/config/config.yaml` plus one file per domain, `local.yaml` for operator-identifying values, and `!include` orchestration — instead of the single flat file above. MeshAI loads this layout automatically whenever it's present. To convert an existing single-file install:
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
```bash
docs: point onboarding at the dashboard; drop 3 phantom keys from the seeded default (#147)
* fix(entrypoint): drop three phantom history keys from the seeded default
The config written on first boot -- what EVERY fresh Docker install starts
from -- seeded three keys that do not exist on HistoryConfig and are
silently discarded on load:
auto_cleanup: true
cleanup_interval_hours: 24
max_age_days: 30
HistoryConfig has only `database`, `max_messages_per_user`, and
`conversation_timeout`.
To be precise about the impact: history cleanup DOES work -- cleanup_expired()
is wired at main.py:237 and _prune_history() honours max_messages_per_user.
What never existed is the time-based retention model these keys describe (a
30-day age cutoff on a 24h interval). An operator setting max_age_days: 90
expecting 90-day retention was silently ignored.
Not implemented here -- whether time-based retention should exist is a
product decision, not a cleanup. This only stops the default config
promising it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: point onboarding at the dashboard, not the legacy example config
The documented setup path was `cp config.example.yaml config.yaml`, but that
file is ~40% incomplete: of the 20 top-level Config fields it covers 15,
omitting coverage (the universal bbox), danger_zones, generic_sources,
meshcore_context, commands, and the current notification model
(toggles/destinations/region_routes). Its own notifications header still says
the schema "will be replaced in v0.3 by the 8-toggle model" -- which shipped
long ago. Anyone following the project's own instructions landed on a
degraded surface with no signal a richer config existed.
It is also read by NOTHING at runtime: docker-entrypoint.sh sets
MESHAI_CONFIG=/data/config.yaml and writes its own inline default on first
boot. The Dockerfile still COPYs config.example.yaml into the image, so it is
kept and now labelled reference-only rather than a starting point.
- README: Docker quick-start seeds itself; configure via the dashboard. The
pip path still uses config.example.yaml (nothing seeds one there) but now
carries an honest note that it is a minimal bootstrap, not a reference.
Adds an Advanced section for the split /data/config/ layout and the
migrate_config_v03 path into it.
- docker-compose.yml: the comment claimed config lives at /data/config.yaml
as though that were the only layout; corrected to describe both, and note
secrets live in /data/secrets/.env.
- Dockerfile: document why config.example.yaml is still shipped.
Not done deliberately: config.example.yaml is NOT expanded to cover all 20
sections. A second hand-maintained schema is what caused this drift; the
dashboard is the authoritative surface. The legacy single-file loader is
untouched and still fully supported.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(readme): move dashboard-first onboarding rewrite to root README
work/README.md is about to become a symlink to root README.md on
docs/single-readme. Retarget the Docker/dashboard-first quick start
and config.example.yaml honesty note added in be867d23 from
work/README.md to root README.md, adapted to the root file's own
Quick start structure (cd meshai/work, /work/-prefixed curl URLs), so
none of it is silently dropped when that symlink lands.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:07:31 -06:00
docker compose exec meshai python -m meshai.scripts.migrate_config_v03
2026-05-12 21:02:17 -06:00
```
docs: point onboarding at the dashboard; drop 3 phantom keys from the seeded default (#147)
* fix(entrypoint): drop three phantom history keys from the seeded default
The config written on first boot -- what EVERY fresh Docker install starts
from -- seeded three keys that do not exist on HistoryConfig and are
silently discarded on load:
auto_cleanup: true
cleanup_interval_hours: 24
max_age_days: 30
HistoryConfig has only `database`, `max_messages_per_user`, and
`conversation_timeout`.
To be precise about the impact: history cleanup DOES work -- cleanup_expired()
is wired at main.py:237 and _prune_history() honours max_messages_per_user.
What never existed is the time-based retention model these keys describe (a
30-day age cutoff on a 24h interval). An operator setting max_age_days: 90
expecting 90-day retention was silently ignored.
Not implemented here -- whether time-based retention should exist is a
product decision, not a cleanup. This only stops the default config
promising it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: point onboarding at the dashboard, not the legacy example config
The documented setup path was `cp config.example.yaml config.yaml`, but that
file is ~40% incomplete: of the 20 top-level Config fields it covers 15,
omitting coverage (the universal bbox), danger_zones, generic_sources,
meshcore_context, commands, and the current notification model
(toggles/destinations/region_routes). Its own notifications header still says
the schema "will be replaced in v0.3 by the 8-toggle model" -- which shipped
long ago. Anyone following the project's own instructions landed on a
degraded surface with no signal a richer config existed.
It is also read by NOTHING at runtime: docker-entrypoint.sh sets
MESHAI_CONFIG=/data/config.yaml and writes its own inline default on first
boot. The Dockerfile still COPYs config.example.yaml into the image, so it is
kept and now labelled reference-only rather than a starting point.
- README: Docker quick-start seeds itself; configure via the dashboard. The
pip path still uses config.example.yaml (nothing seeds one there) but now
carries an honest note that it is a minimal bootstrap, not a reference.
Adds an Advanced section for the split /data/config/ layout and the
migrate_config_v03 path into it.
- docker-compose.yml: the comment claimed config lives at /data/config.yaml
as though that were the only layout; corrected to describe both, and note
secrets live in /data/secrets/.env.
- Dockerfile: document why config.example.yaml is still shipped.
Not done deliberately: config.example.yaml is NOT expanded to cover all 20
sections. A second hand-maintained schema is what caused this drift; the
dashboard is the authoritative surface. The legacy single-file loader is
untouched and still fully supported.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(readme): move dashboard-first onboarding rewrite to root README
work/README.md is about to become a symlink to root README.md on
docs/single-readme. Retarget the Docker/dashboard-first quick start
and config.example.yaml honesty note added in be867d23 from
work/README.md to root README.md, adapted to the root file's own
Quick start structure (cd meshai/work, /work/-prefixed curl URLs), so
none of it is silently dropped when that symlink lands.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:07:31 -06:00
This backs up the original `config.yaml` , splits it into `/data/config/` , extracts secrets to `/data/secrets/.env` , and verifies the new layout loads identically before finishing — restart the container afterward to pick it up. It's optional: the dashboard is fully functional against either layout. (Templates for building a split layout from scratch also ship in the repo's `work/config/` directory: `local.yaml.example` , `.env.example` .)
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
---
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
## Transports
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
MeshAI speaks two mesh protocols. **Meshtastic is always the base transport. ** **MeshCore turns on automatically the moment you set a MeshCore host ** — there's no separate on/off toggle to forget.
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
### Meshtastic
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
Connect over TCP (recommended) or serial:
2026-05-12 21:02:17 -06:00
```yaml
2026-07-04 00:47:11 -06:00
connection:
type: "tcp" # or "serial"
tcp_host: "192.168.1.100"
tcp_port: 4403
# serial_port: "/dev/ttyUSB0"
2026-05-12 21:02:17 -06:00
```
2026-07-04 00:47:11 -06:00
### MeshCore
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
MeshAI attaches to a MeshCore **companion ** (the pyMC / MeshCore companion frame server) over TCP and acts as a node on the MeshCore mesh:
2026-05-12 21:02:17 -06:00
```yaml
2026-07-04 00:47:11 -06:00
connection:
meshcore_host: "192.168.1.253" # blank = MeshCore off
meshcore_port: 5050
2026-05-12 21:02:17 -06:00
```
2026-07-04 00:47:11 -06:00
Once connected, MeshCore gets its own **Connection ** , **Routing ** , **Scheduled Broadcasts ** , **Contacts & Companion ** , and **Danger Zones ** pages in the dashboard — the same capabilities as Meshtastic, using MeshCore's own idioms (channels by name, contacts by pubkey). Messages are sized to fit whichever mesh they go out on.
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
---
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
## The conversational bot
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
DM MeshAI on either mesh and it answers with the LLM, using live mesh data, environmental feeds, and (optionally) a knowledge base. A few things it's careful about:
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
- **Answers on the mesh you asked from.** A MeshCore DM gets a MeshCore reply; a Meshtastic DM gets a Meshtastic reply. Each mesh's "answer DMs" switch is independent.
- **Per-mesh chat memory.** It keeps a short rolling window of recent channel chatter * per mesh * (configurable retention, default 14 days) so "what's happening on the mesh?" reflects the mesh you're on. Ask about the other mesh by name to cross over.
- **Three separate lanes.** Shared channel context, your private DM history, and the curated knowledge base never bleed into each other.
- **LoRa-fit replies.** Responses are chunked to a per-mesh character budget with sentence-aware splitting and continuation prompts.
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
### Commands
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
Alongside natural-language questions, a set of `!` commands are available (all toggleable, so they can defer to another service like MeshMonitor):
2026-05-12 21:02:17 -06:00
2026-07-04 00:47:11 -06:00
| Category | Commands |
|----------|----------|
| Mesh | `!health` · `!mesh` · `!status` · `!region [name]` · `!neighbors [node]` |
| Weather / RF | `!wx-alerts` · `!solar` · `!hf` · `!satpass` |
| Fire | `!fire` · `!hotspots` · `!ignitions` |
| Hazards | `!avalanche` · `!roads` / `!traffic` · `!rivers` / `!gauges` |
| Utility | `!help` · `!clear` |
2026-05-12 21:02:17 -06:00
---
2026-07-04 00:47:11 -06:00
## Mesh intelligence (Meshtastic)
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
MeshAI continuously aggregates mesh data and computes a **5-pillar health score ** :
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
| Pillar | Weight | Measures |
|--------|--------|----------|
| Infrastructure | 30% | Router/repeater uptime |
| Utilization | 25% | Channel busyness / RF congestion |
| Coverage | 20% | How many monitoring sources see each node |
| Behavior | 15% | Traffic patterns (noisy/misconfigured nodes) |
| Power | 10% | Battery health of infrastructure nodes |
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
Infrastructure nodes are tracked individually (battery, offline alerts, coverage, neighbors, hardware); client nodes coming and going is normal and ignored. Regions are fully configurable — local names, aliases, cities, and radius — with no hardcoded geography.
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
Data comes from one or more **Meshview ** instances and a **MeshMonitor ** instance, polled on a staggered schedule with built-in rate-limiting:
2026-05-06 01:28:59 +00:00
```yaml
mesh_sources:
2026-07-04 00:47:11 -06:00
- name: "meshview"
2026-05-06 01:28:59 +00:00
type: meshview
url: "http://192.168.1.100:8080"
enabled: true
- name: "meshmonitor"
type: meshmonitor
url: "http://192.168.1.100:3333"
api_token: "your-bearer-token"
enabled: true
```
2026-07-04 00:47:11 -06:00
---
## Environmental & hazard feeds
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
MeshAI pulls real-time situational data and turns it into LoRa broadcasts and query answers. Sources include **NWS weather alerts ** , **NIFC wildfire perimeters ** , **NASA FIRMS satellite fire detections ** , **USGS earthquakes ** , **USGS stream gauges ** , **road/traffic (511 / TomTom) ** , **NOAA space weather ** , and **avalanche/RF-propagation ** feeds.
2026-05-06 16:29:03 +00:00
2026-07-04 00:47:11 -06:00
Everything is switched on/off and tuned from the dashboard's **Data Feeds ** page — enable a source, set thresholds and geography, and route its output per-mesh on the **Routing ** page. Broadcast wording is tightened to fit a single LoRa packet without dropping the important details (e.g. affected towns on a weather alert).
2026-05-06 16:29:03 +00:00
2026-07-04 00:56:27 -06:00
### Native adapters vs. Central
Each hazard feed can get its data one of two ways, chosen per-feed with a `feed_source` switch:
- **`native` ** — MeshAI fetches the source's public API **directly ** (api.weather.gov, NIFC, USGS, NOAA SWPC, NASA FIRMS, TomTom, 511, avalanche centers). Self-contained — no extra infrastructure. This is the default and the original data path.
- **`central` ** — MeshAI subscribes to **Central ** , a companion service that pre-aggregates the same hazard data and republishes it as a **NATS JetStream ** firehose, so many bots/nodes can share one set of upstream API calls and geo/severity filtering instead of each hammering the source APIs.
```yaml
environmental:
central:
enabled: true
url: "nats://central.echo6.mesh:4222" # NATS server (tailnet-gated, no auth)
durable: "meshai-consumer" # durable consumer name prefix
region: "us.id" # server-side subject filtering
connect_timeout: 10
nws: { feed_source: central } # this feed comes from Central …
fires: { feed_source: native } # … this one is fetched directly
# …one feed_source per hazard adapter
```
2026-07-04 01:15:45 -06:00
Native and Central are **mutually exclusive per feed ** — flip any adapter between them independently. Two special cases: * * `satpass` ** is Central-only (there's no native predictor), and * * `ducting` ** (VHF tropo) is native-only (no Central equivalent). MeshAI keeps running whether or not Central is up: a **runtime ** drop auto-reconnects (durable consumers resume where they left off), and a **startup ** outage is logged and retried in the background rather than blocking boot — the LLM bot, both transports, mesh-health, and any `native` feeds all come up regardless; only the Central-sourced hazard feeds wait for Central to return. Feeds set to `native` don't depend on Central at all.
2026-07-04 00:56:27 -06:00
2026-07-04 00:47:11 -06:00
---
2026-05-06 16:29:03 +00:00
2026-07-04 00:47:11 -06:00
## Knowledge base (RAG)
2026-05-06 16:29:03 +00:00
2026-07-04 00:47:11 -06:00
Optional hybrid retrieval for survival, comms, medical, and technical Q&A.
- **Primary** — queries a **Qdrant ** hybrid store (dense `bge-m3` + sparse, Reciprocal Rank Fusion) over a large curated vector set, via a networked TEI embedding service. Nothing is copied locally.
- **Fallback** — a local **SQLite ** knowledge base (FTS5 keyword + `bge-small-en-v1.5` vectors) if the vector service is unreachable.
2026-05-06 16:29:03 +00:00
```yaml
knowledge:
enabled: true
2026-07-04 00:47:11 -06:00
backend: auto # qdrant | sqlite | auto
2026-05-06 16:29:03 +00:00
qdrant_host: "192.168.1.150"
qdrant_port: 6333
qdrant_collection: "recon_knowledge_hybrid"
tei_host: "192.168.1.150"
tei_port: 8090
top_k: 5
```
2026-07-04 00:47:11 -06:00
The curated channel chatter your bot observes is used only as short-term * context * — it is never written into the knowledge base.
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
---
2026-05-06 16:29:03 +00:00
2026-07-04 00:47:11 -06:00
## LLM configuration
2026-05-06 01:28:59 +00:00
```yaml
llm:
2026-07-04 00:47:11 -06:00
backend: "google" # google | openai | anthropic
2026-05-06 01:28:59 +00:00
api_key: "your-api-key"
docs: one README (root canonical) — the two had cross-drifted (#152)
* docs(readme): reconcile root README as the single source of truth
/README.md (renders on GitHub) and work/README.md (packaged by
work/pyproject.toml's readme = "README.md") had cross-drifted: each
had two of four correct lines. Root had the correct `cd meshai/work`
path and work/-prefixed curl URLs; work/README.md had the correct
gemini-3.1-flash-lite model (google retired the gemini-2.x lite tier
on this project's API key).
Pull the one missing fix (model name) into root/README.md. Verified
via diff that these were the only 4 lines (8 diff lines) that ever
differed between the two files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* build(work): eliminate work/README.md as a second source of truth
The hand-maintained duplicate is what caused the cross-drift fixed in
the previous commit. Replace work/README.md with a symlink to
../README.md so there is exactly one file to edit.
This required unhooking work/'s packaging from a physical README.md:
- setuptools' pyproject reader hard-rejects readme paths outside the
project dir (`_assert_local` in setuptools/config/expand.py) — so
`readme = "../README.md"` is not an option, tested and confirmed.
- The symlink resolves fine for local packaging (pip install -e .,
python -m build --sdist/--wheel all tested passing, PKG-INFO
correctly carries the root content through the symlink).
- It does NOT resolve for the Docker image build: work/Dockerfile's
`COPY README.md .` and both work/docker-compose.yml (context: .)
and .github/workflows/docker-publish.yml (context: work) pin the
build context to work/, which does not contain the symlink's
target. Confirmed with an isolated repro: Docker COPY on a symlink
whose target is outside the build context fails with "too many
links". Repointing the build context at the repo root would touch
every COPY path in the Dockerfile plus CI — out of scope here and
not worth it for a README.
Chose the minimal fix instead: drop `readme = "README.md"` from
work/pyproject.toml (meshai isn't published to PyPI — no publish
workflow exists, only GHCR image publishing — so there's no
long_description to lose in practice) and drop the now-unnecessary
`COPY README.md .` from work/Dockerfile. Confirmed a dangling
same-named symlink left in the build context, never COPYed, does not
break context transfer.
Tested end-to-end: a full `docker build -f work/Dockerfile work`
against the real Dockerfile succeeded (frontend build, apt deps, pip
install -e ., fastembed model fetch), and the resulting image imports
meshai and reports correct `pip show` metadata with no README
involved.
Note for docs/onboarding-via-gui (PR #147) and
chore/untrack-dashboard-static: both edited work/README.md, the file
GitHub never rendered. That target is gone; their Quick-start content
needs to be re-applied to root README.md when those branches rebase.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:15:39 -06:00
model: "gemini-3.1-flash-lite"
2026-05-06 01:28:59 +00:00
```
2026-07-04 00:47:11 -06:00
Any OpenAI-compatible endpoint works for local models — point `base_url` at Ollama (`http://localhost:11434/v1` ), LiteLLM (`http://localhost:4000/v1` ), or Open WebUI.
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
---
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
## Architecture
2026-05-06 01:28:59 +00:00
```
2026-07-04 00:47:11 -06:00
┌─────────────────────────────┐
Meshtastic ────────▶│ │◀──────── MeshCore
(TCP / serial) │ CompositeTransport │ (companion / pyMC TCP)
│ per-mesh routing + sizing │
└──────────────┬──────────────┘
│
┌─────────────────────────────┼─────────────────────────────┐
▼ ▼ ▼
┌───────────┐ ┌─────────────────┐ ┌──────────────┐
│ Router │ │ Notification │ │ Mesh Data │
│ LLM / cmd │ │ Pipeline │ │ Store + │
│ DM gating │ │ weather · fire │ │ Health │
│ per-mesh │ │ road · seismic │ │ Engine │
│ context │ │ RF · mesh-health│ │ 5-pillar │
└─────┬─────┘ └────────┬─────────┘ └──────┬───────┘
│ │ │
┌────▼─────┐ ┌──────────────┐ │ ┌──────────────┐ │
│ LLM │ │ Knowledge │ │ │ Env / Central │◀─────┘
│ backend │ │ Qdrant/FTS5 │ │ │ feed adapters │
└──────────┘ └──────────────┘ ▼ └──────────────┘
┌──────────┐
│ Responder│ ACK-paced, LoRa-fit,
│ + Chunker│ routed per mesh
└──────────┘
│
Web Dashboard (React) ── configure everything
2026-05-06 01:28:59 +00:00
```
2026-07-04 00:47:11 -06:00
---
2026-05-06 01:28:59 +00:00
2026-07-04 00:47:11 -06:00
## Running as a service
2026-05-06 01:28:59 +00:00
```ini
# /etc/systemd/system/meshai.service
[Unit]
2026-07-04 00:47:11 -06:00
Description=MeshAI
2026-05-06 01:28:59 +00:00
After=network.target
[Service]
Type=simple
User=your-user
WorkingDirectory=/path/to/meshai
ExecStart=/usr/bin/python3 -m meshai
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
2026-07-04 00:47:11 -06:00
sudo systemctl enable --now meshai
2026-05-06 01:28:59 +00:00
```
2026-07-04 00:47:11 -06:00
Every deployment is designed to survive a reboot; the dashboard's connection settings drive both transports.
---
## Playing nice with other services
- **advBBS** — MeshAI coexists on the same Meshtastic node; BBS protocol traffic (sync, RAP, mail) is auto-filtered (`bot.filter_bbs_protocols: true` ).
- **MeshMonitor** — MeshAI reads MeshMonitor's auto-responder patterns to avoid duplicate replies, and uses its API as a mesh-intelligence data source.
- **MeshCore companion** — MeshAI attaches as its own companion identity so it can share the radio without evicting other companion clients.
---
2026-05-06 01:28:59 +00:00
## Acknowledgments
2026-07-04 00:47:11 -06:00
- [Meshtastic ](https://meshtastic.org/ ) — the mesh platform it started on
- [MeshCore ](https://meshcore.io/ ) & [pyMC ](https://github.com/rightup/pyMC_core ) — the second transport
- [MeshMonitor ](https://github.com/Yeraze/meshmonitor ) by Yeraze — monitoring integration & data source
- [advBBS ](https://github.com/zvx-echo6/advbbs ) — coexistence design
- [Qdrant ](https://github.com/qdrant/qdrant ) · [sqlite-vec ](https://github.com/asg017/sqlite-vec ) · [fastembed ](https://github.com/qdrant/fastembed ) — retrieval stack
- The LLM coding assistants that vibecoded most of this
2026-05-06 01:28:59 +00:00
## License
2026-07-04 00:47:11 -06:00
MIT
2026-05-06 01:28:59 +00:00
## Author
2026-07-04 00:47:11 -06:00
K7ZVX — matt@echo6 .co