Phase 4 of FIRMS+WFIGS fusion. Foundation: every direct LLM DM
mentioning a fire/weather/quake/avalanche/flood/etc. keyword was
failing silently in prod with UnboundLocalError because router.py
referenced scope_type before assigning it. With that path restored,
two new features land: a twice-daily fire-digest scheduled broadcast
(LLM-rendered) and a ?status <fire_name> on-demand mesh-DM intent.
BUG-FIX ROOT CAUSE (Job Zero):
router.py:745 ("if should_inject_mesh and scope_type == 'env'") read
`scope_type` -- a local variable bound only at line 761 inside an
unrelated `if self.source_manager and self.mesh_reporter` block.
Python's lexical scoping made scope_type a local of the whole
generate_llm_response function, so reading it before the assignment
raised UnboundLocalError on every env-keyword DM. The exception
propagated to main.py's outer except, no response went out, bot
appeared dead on fire/weather/quake/avalanche/flood queries.
Evidence (synthetic in-process trace against the live container's
config + GoogleBackend):
"are there any fires near me?" -> UnboundLocalError (pre-fix)
-> real LLM answer (post-fix)
"Yes, there are a few active
fires reported in the region.
Salmon River: 4,200 acres, 78%
contained. Cache Peak: 1,847
acres, 23% contained. ..."
"what's the weather?" -> UnboundLocalError (pre-fix)
-> "I do not have current weather
information. I can tell you
about active fires, stream gauge
levels, space weather, or band
conditions if you'd like." (post-fix)
"hi there" -> normal LLM answer in both cases
Fix: hoist `scope_type, scope_value = self._detect_mesh_scope(query)`
to right after `should_inject_mesh` is computed; remove the
now-duplicate detection inside the source_manager block.
Secondary mitigation: tightened the "do not invent commands" prompt
with an explicit "if no list appears above, you have NO commands"
clause. The prior prompt told the LLM "answer based on the command
list provided below" without always providing one, so the LLM
hallucinated plausible-sounding !commands (the "use ! commands"
canned-looking response Matt was seeing on non-env queries).
PHASE 4 FEATURES:
1. Fire-digest scheduler (meshai/notifications/scheduled/fire_digest.py).
Modeled after BandConditionsScheduler. Runs in the pipeline's
start_pipeline coroutine alongside band_conditions + reminders.
On each slot (default 06:00 + 18:00 America/Boise):
- Queries active fires (tombstoned_at IS NULL) + last 24h passes.
- Builds a prompt asking for a single mesh-wire summary <= 200
chars.
- Calls the LLM (Google/Anthropic/OpenAI per config).
- Falls back to a terse "Fires today (N): Cache Peak 1847 ac;
Twin Peaks 320 ac; +N more" line when the LLM is unavailable.
- Dispatches via dispatcher.dispatch_scheduled_broadcast (same
path band_conditions uses).
Idempotency: v16.sql adds fire_digest_broadcasts(slot_epoch PK,
sent_at, summary, source). INSERT OR IGNORE pattern blocks the same
slot firing twice (matters when container restarts mid-day).
2. ?status <fire_name> on-demand intent (router.py).
Before falling through to the LLM, route() now checks for a leading
"?status" / "status:" sigil or natural-language triggers like
"how is X fire?". On match:
- _lookup_fire_fuzzy walks fires by exact -> startswith ->
contains -> word-overlap (skipping a trailing " fire" word so
"cache peak fire" matches "Cache Peak"). Active fires rank
above tombstoned ones.
- _build_fire_status_context composes a small context block
(name, acres, containment, county/state, last 3 passes with
drift).
- The query is REWRITTEN into an LLM prompt with that context
inlined; the rest of the normal LLM path (chunking, history,
summary persistence) runs unchanged.
Live verification: "?status Cache Peak" -> "The Cache Peak fire is
1,847 acres and 23% contained. It's located in Probe / ID.";
"?status Salmon" -> word-overlap matches "Salmon River" ->
"The Salmon River fire is 4,200 acres and 78% contained, located
in Probe / ID."
3. adapter_config rows (GUI-editable per CONFIG-vs-CODE rule):
fires.digest_enabled = true (master toggle)
fires.digest_schedule = ["06:00", "18:00"]
fires.digest_timezone = "America/Boise"
fires.digest_max_chars = 200
Schema (v16.sql):
- fire_digest_broadcasts(slot_epoch INTEGER PK, sent_at, summary,
source) with source in {'llm', 'fallback_terse', 'skipped_no_fires'}.
- Index on sent_at for ops queries.
Tests (tests/test_fire_tracker_phase4.py, 10 cases all green):
- Regression guard: scope_type appears as an assignment BEFORE the
env_reporter check (prevents the UnboundLocalError from coming back).
- adapter_config seeds all 4 digest keys with expected defaults.
- render_digest returns ('', 'no_fires') when no active fires.
- render_digest falls back to terse line when LLM is None; wire fits cap.
- render_digest with a stub LLM returns ('<llm text>', 'llm').
- _lookup_fire_fuzzy: exact, "X fire" trim, word-overlap, no-match.
- _maybe_rewrite_status_query: builds context-bearing prompt; returns
None on non-status queries.
Combined suite: 60 passed in 3.81s across phase1+phase2+phase3+phase4
+or-arch+include-roundtrip.
Live verification on CT108 after rebuild:
- v16 migration applied (schema_meta=16, no Traceback in 3 min).
- FireDigestScheduler started: enabled=True schedule=['06:00','18:00']
tz=America/Boise.
- LLM DM probe (real Gemini) returns real answers on env queries
(Bug A fixed end-to-end).
- ?status Cache Peak + ?status Salmon return fire-specific summaries.
- render_digest with real LLM returns source=llm + non-empty wire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes audit doc Section C. The LLM can now answer "any fires near me?",
"how are band conditions?", "why didnt I hear about that quake?"
without any tool-use / MCP / SQL pass-through -- via the same prompt-
injection contract mesh_reporter uses.
env_reporter (meshai/notifications/env_reporter.py):
- EnvReporter class with build_env_summary / build_fires_detail /
build_alerts_detail / build_quakes_detail / build_traffic_detail /
build_gauges_detail / build_swpc_detail / build_drop_audit / build_all
- Reads from fires + firms_pixels + nws_alerts + quake_events +
traffic_events + gauge_readings + swpc_events +
band_conditions_broadcasts + event_log + dispatcher_state
- Each build_*_detail() checks adapter_meta.include_in_llm_context for
the relevant adapter(s) before reading; turning the meta off via
/api/adapter-meta drops that adapters block out of the LLM prompt
- Defensive: missing meta row defaults to True (include); DB-unavailable
returns empty string; per-block 3000-char cap
- Module-level env_reporter singleton for the router
Router wiring (meshai/router.py):
- Extended _MESH_KEYWORDS dispatcher with _ENV_KEYWORDS_TO_SUBTYPE
mapping (fire/quake/flood/warning/storm/road/swpc/etc -> coarse
subtype). "flood" intentionally precedes "warning" so
"river flood warning" routes to gauges, not alerts
- _detect_env_subtype helper at module level (also test-importable)
- _is_mesh_question now also fires for env keywords -- single detector
per Matt s spec
- _detect_mesh_scope returns ("env", subtype) when an env keyword
matches, taking precedence over the node/region branches
- generate_llm_response: when scope_type == "env", appends
env_reporter.build_all() + env_reporter.build_drop_audit(hours=1)
to the system prompt. Wrapped in try/except so a reporter fault
never blocks the LLM call
Tests:
- tests/test_env_reporter.py (18 cases): meta gate, every build_*
method shape, build_all combines blocks, all-off produces empty
- tests/test_router_env_scope.py (18 cases): parametrized subtype
detection across fires/quakes/alerts/gauges/traffic/swpc, word-
boundary check (firearm != fire), synthetic-probe end-to-end
(seed fires table -> env_reporter emits a fires block with the
seeded row)
Test count: 761 -> 797 (+36 new, 0 regressions).
One-time renormalization pass under the .gitattributes added in the
previous commit. Every tracked text file now uses LF. No semantic
changes — verified via git diff --cached --ignore-all-space showing
zero real differences. Future diffs will only show real content
changes.
This commit will appear huge in git log --stat but represents zero
behavior change. Use git log --follow --ignore-all-space or
git blame -w when archaeologically tracing through this commit.
- Timezone now configurable (default America/Boise)
- Router prompt generates region name instructions from config
- Any operator can run MeshAI for their region without code changes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- WFIGS ArcGIS fire perimeter polling with proximity alerts
- Avalanche.org advisory polling (seasonal, SNFAC)
- !fire and !avy commands
- Distance-based severity for fires near mesh infrastructure
- Dashboard environment page integration
- Alert engine fires on fires within 50km of mesh area
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Packet dedup: track seen packet IDs across all sources. Same packet from
Meshview + MeshMonitor counted once, not twice. Fixes inflated counts.
Portnum: numeric values (3, 4, 71) mapped to names (Position, NodeInfo, Neighbors).
LLM prompt: guidance on normal vs abnormal packet rates per type.
- build_lora_compact returns list[str] instead of str
- Each line is a separate LoRa message (no chunking needed)
- main.py handles list responses from commands
- _try_compute_distance supports partial names (TVM Pearl → TVM Pearl Relay)
- Ambiguous names detected (TVM → asks which node)
- Max message size: 54 bytes (well under 228 byte limit)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
!health: 🔵 perfect, 🟢 healthy, 🟠 warning, 🔴 critical — no /100 scores.
Each line ends with period for separate LoRa messages.
Uses long_name to avoid emoji shortnames (📡 → TVM Tablerock Relay).
Distance from AIDA shown on every infra node in Tier 1.
Router detects how far questions and injects computed distance.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Replace build_lora_compact with personality version using emojis
- Add _region_lora_compact helper for region-specific display
- Skip empty regions in 3 loops (build_tier1_summary, utilization, list_regions_compact)
- Update AIDA identity: she IS a physical node (!27780c47 AIDA-N2)
- AIDA now knows she has real GPS coordinates and radio connections
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add max_response_tokens config (8192) to LLMConfig
- Use config value in router.py instead of hardcoded 500
- Update base.py default from 300 to 8192
- Lets LLM generate full responses; chunker handles size limits
Fixes truncated responses like Here are three nodes in the freq
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Coverage:
- Single-gateway infra nodes named as critical risks per region
- Client single-gw nodes counted but not individually named
- Mesh-wide single-gw infra summary
Monitoring rules by node type:
- Infrastructure: full detail - battery, offline, coverage, neighbors, hardware
- Clients causing problems: named - high util, top senders
- Clients otherwise: counted per region, not individually tracked
- POWER breakdown now infra-only
Commands:
- Removed hardcoded command list from config.py system_prompt
- Dynamic command list in router.py from dispatcher (only enabled commands)
- MeshMonitor commands no longer listed as MeshAI commands
- !help overhaul: grouped by category, per-command detailed help
- LLM explicitly told to only mention listed commands
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tier 1 now includes:
- Every infrastructure node BY NAME per region with status/battery/util/gateways
- Problem nodes section: offline infra, critical battery, high util, coverage risks
- Per-region coverage with gateway counts and single-gw counts
- Environmental data per region
- All 5 pillars with weights
- Expanded recommendations with specifics (10 max, up from 5)
- LLM prompt simplified: data speaks for itself
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Added CRITICAL instruction to keep sentences under 150 chars
- Chunker now splits long sentences at word boundaries instead of truncating
- No words lost when splitting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Line 763: REGION_CONTEXT.get() → self._region_context() (same method used elsewhere)
- Deleted _CITY_TO_REGION hardcoded dict
- Scope detection now uses config aliases/cities from RegionAnchor
- Fixed Sun Valley/Ketchum geography (was Central ID, should be South Central ID)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Extended RegionAnchor with local_name, description, aliases, cities
- Moved region geographic context from hardcoded Python to config.yaml
- Added 7-day stale node purge in _do_refresh (556 → 267 nodes)
- Fixed coverage lookup: str(node_num) → node_num (int key)
- Added bidirectional neighbor lookup for better region assignment
- Dynamic geography building in router from config
- Reporter reads region context from config instead of hardcoded dict
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Magic Valley, Treasure Valley, Panhandle, Dixie — local names in all output
- Southern Idaho correctly maps to Magic Valley, not lumped with Boise/IF
- Unlocated nodes excluded from coverage gap recommendations
- LLM response rules override brevity for mesh questions
- Node detail shows region with local context
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- router.py: Fixed region scope detection to match longest region name first
- router.py: Added region abbreviations (SCID, SWID, etc.) for quick matching
- router.py: Added city name mapping (Boise -> South Western ID, etc.)
- router.py: Fixed node longname matching (case-insensitive substring)
- router.py: Added follow-up message context tracking (_user_mesh_context)
- router.py: Added more mesh keywords (noisy, traffic, packets, etc.)
- mesh_reporter.py: Added disambiguation for duplicate shortnames in region detail
- mesh_health.py: Added util_data_available flag to track packet data presence
- mesh_health.py: Passes has_packet_data through score computation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
New files:
- mesh_reporter.py: MeshReporter class for prompt injection
- build_tier1_summary(): ~500-800 token mesh health summary
- build_region_detail(): Detailed region breakdown
- build_node_detail(): Single node info with recommendations
- build_recommendations(): Optimization suggestions
- build_lora_compact(): Short format for LoRa messages
- list_regions_compact(): Region list with scores
- commands/health.py: !health and !region commands
- !health: Quick mesh summary (no LLM)
- !region [name]: Region info or list all regions
Modified files:
- router.py: Mesh question detection and prompt injection
- _is_mesh_question(): Keyword/phrase matching
- _detect_mesh_scope(): Node/region/mesh scope detection
- Inject Tier 1/2 data for mesh questions
- Add mesh awareness instructions to LLM
- main.py: Create MeshReporter, pass to dispatcher/router
- commands/dispatcher.py: Register health/region commands
- mesh_health.py: Fix role type (int -> str)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements mesh intelligence with geo clustering, four-pillar health scoring,
and auto-naming regions from GPS data.
New: geo.py, mesh_health.py
Modified: config.py, main.py, router.py, configurator.py, config.example.yaml
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add MeshviewSource class for fetching nodes, edges, stats from Meshview API
- Add MeshMonitorDataSource class for fetching nodes, channels, telemetry,
traceroutes, network stats, topology, packets, solar from MeshMonitor API
- Add MeshSourceManager for managing multiple sources with aggregation
- Add MeshSourceConfig dataclass and mesh_sources list to config
- Integrate source_manager into main.py with periodic refresh
- Add source_manager parameter to MessageRouter (for future Phase 3)
- Add Mesh Sources TUI menu with add/edit/remove/test functionality
- Update config.example.yaml with mesh_sources section
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Build system prompt dynamically using bot.name and bot.owner from config
- Reorder prompt: identity -> static prompt -> MeshMonitor (conditional) -> mesh context
- MeshMonitor description only injected when meshmonitor.enabled is true
- Update default system_prompt to static parts only (commands, architecture, rules)
- Fix meshmonitor.py to handle trigger arrays (not just strings)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add MeshMonitorSync class that reads trigger patterns from a JSON file
and compiles them to regex. The router checks incoming messages against
these patterns and ignores messages that MeshMonitor will handle.
- New meshai/meshmonitor.py: Pattern compilation and file watching
- MeshMonitorConfig dataclass with enabled, triggers_file, inject_into_prompt
- Router integration: ignore matching messages, inject commands into prompt
- Main loop refresh: watch triggers file for changes without restart
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
All three LLM backends (Google, OpenAI, Anthropic) now wrap API calls
in asyncio.wait_for() using config.timeout (default 30s). Previously
Gemini could hang indefinitely with grounding+AFC enabled.
Router catches TimeoutError with user-friendly "request timed out" message.
Empty context buffer now injects "[No recent mesh traffic observed yet.]"
so the LLM knows the capability exists even when buffer is empty.
Default system prompt updated to mention mesh awareness.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New context.py module: ring buffer (50K hard cap, ~25MB ceiling) passively
records all channel broadcasts. Observations are formatted with relative
timestamps and injected into the system prompt when generating LLM responses.
Only public channel traffic is observed; DMs to the bot are excluded (already
in per-user history). Bot's own node ID is auto-added to ignore list.
Config: context.enabled, observe_channels, ignore_nodes, max_age, max_context_items
TUI: new Context settings submenu (menu item 7)
Hourly prune removes expired observations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
MeshAI is now DM-only. Removed all unreachable channel response
paths, @mention detection, ChannelsConfig, and channel TUI menu.
Fixed restart mechanism with integrated watcher and SIGKILL fallback.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mesh messages are short enough that summarization is unnecessary
with Gemini Flash's 1M token context window.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These modules were wired up but never actually functional in the
running bot. Strips all imports and usage from main.py and router.py.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 6a: Change healthcheck in Dockerfile and docker-compose.yml to verify
the PID file exists and the process is alive (kill -0) instead of
testing SQLite connectivity, which only proves the DB file exists
- 6b: In DMs, skip !commands so MeshMonitor or other bots handle them.
MeshAI only responds conversationally in DMs (no bang commands)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 5a: Import html.escape and apply to all values rendered into the
HTML template in _serve_status_page() — uptime, counts, status text,
node counts, errors. Prevents XSS via crafted node names or errors.
- 5b: Add basic prompt injection detection to _clean_query() with
configurable safety.prompt_injection_guard (default: on). Detects
patterns like "ignore all previous", "you are now", "system prompt:",
etc. Truncates query before the injection phrase and logs a warning.
Not foolproof but better than nothing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 2a: SafetyFilter + UserFilter — check user access before processing,
filter LLM responses through SafetyFilter before sending
- 2b: RateLimiter — check rate limits before processing, record
messages after successful response delivery
- 2c: PersonalityManager — pass to MessageRouter, used for system
prompt generation instead of raw config.llm.system_prompt
- 2d: WebhookClient — start/stop in lifecycle, fire events on
message_received, response_sent, error, startup, shutdown
- 2e: WebStatusServer — start/stop in lifecycle, record messages,
responses, and errors in StatusData
- 2f: AnnouncementScheduler — start/stop in lifecycle, uses
connector.send_message as callback
- 2g: FallbackBackend — wrap primary backend when config.llm.fallback
is configured, otherwise use primary directly
- 2h: CommandDispatcher — pass prefix, disabled_commands, and
custom_commands from config to create_dispatcher()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Features:
- Multi-backend LLM support (OpenAI, Anthropic, Google)
- Rolling summary memory for token optimization (~70-80% reduction)
- Per-user conversation history with SQLite persistence
- Bang commands (!help, !ping, !reset, !status, !weather)
- Meshtastic integration via serial or TCP
- Message chunking for mesh network constraints (150 char limit)
- Rate limiting to prevent network congestion
- Rich TUI configurator
- Docker support
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>