diff --git a/core/archipelago/src/mesh/listener/assist.rs b/core/archipelago/src/mesh/listener/assist.rs index cbd5e892..0ecd23f4 100644 --- a/core/archipelago/src/mesh/listener/assist.rs +++ b/core/archipelago/src/mesh/listener/assist.rs @@ -171,7 +171,7 @@ pub(super) async fn run_assist( /// A bare radio packet can CLAIM any key or DID, so without that proof the /// allowlist and trust list are spoofable; only the explicit "anyone on the /// mesh" policy (`trusted_only == false`) admits an unauthenticated asker. -async fn is_sender_allowed( +pub(super) async fn is_sender_allowed( state: &Arc, sender_contact_id: u32, authenticated: bool, @@ -283,7 +283,12 @@ fn cap_reply(answer: &str) -> (String, bool) { } /// Send a successful answer via the requested reply path. -async fn send_reply(state: &Arc, reply: &AssistReply, req_id: u64, answer: &str) { +pub(super) async fn send_reply( + state: &Arc, + reply: &AssistReply, + req_id: u64, + answer: &str, +) { match reply { AssistReply::Typed { contact_id } => { let (text, _) = cap_reply(answer); diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index babc3c77..cba2f7bf 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -430,30 +430,47 @@ pub(super) async fn store_plain_message_with_encryption( state.status.write().await.messages_received += 1; let _ = state.event_tx.send(MeshEvent::MessageReceived(msg)); + // Where a plain-text answer goes: a private NATIVE DM to the asker whenever + // we know its radio pubkey (so it does NOT land on the public channel and a + // stock meshcore client can read it); we only fall back to a channel reply + // if the sender has no resolvable pubkey (rare). + let plain_reply = || async { + let peers = state.peers.read().await; + peers + .get(&contact_id) + .and_then(|p| p.pubkey_hex.clone()) + .filter(|h| h.len() >= 12) + .and_then(|h| hex::decode(&h[..12]).ok()) + .filter(|b| b.len() == 6) + .map(|b| { + let mut pre = [0u8; 6]; + pre.copy_from_slice(&b); + super::assist::AssistReply::RadioDm { dest_prefix: pre } + }) + .unwrap_or(super::assist::AssistReply::ChannelText { channel: 0 }) + }; + + // `!archy [sub]` — node status straight from this node's caches. No model is + // involved, so it stays available with the AI assistant switched off; the + // trust gate in run_node_cmd still applies. + if let Some(rest) = super::node_cmd::strip_archy_trigger(text) { + let reply = plain_reply().await; + let req_id = state.next_id().await; + let rest = rest.to_string(); + let name = peer_name.to_string(); + let st = Arc::clone(state); + tokio::spawn(async move { + // Bare plain-text carries no signature — not authenticated. + super::node_cmd::run_node_cmd(rest, req_id, contact_id, name, false, reply, st).await; + }); + } // Mesh-AI assistant (issue #50): a plain `!ai`/`!ask ` is answered // by this node's local model when the assistant is on. The trust/rate gate - // lives in run_assist. The reply goes back as a private NATIVE DM to the - // asker whenever we know its radio pubkey (so it does NOT land on the public - // channel and a stock meshcore client can read it); we only fall back to a - // channel reply if the sender has no resolvable pubkey (rare). - if state.assistant.read().await.enabled { + // lives in run_assist. + else if state.assistant.read().await.enabled { if let Some(prompt) = strip_ai_trigger(text) { if !prompt.is_empty() { - let reply = { - let peers = state.peers.read().await; - peers - .get(&contact_id) - .and_then(|p| p.pubkey_hex.clone()) - .filter(|h| h.len() >= 12) - .and_then(|h| hex::decode(&h[..12]).ok()) - .filter(|b| b.len() == 6) - .map(|b| { - let mut pre = [0u8; 6]; - pre.copy_from_slice(&b); - super::assist::AssistReply::RadioDm { dest_prefix: pre } - }) - .unwrap_or(super::assist::AssistReply::ChannelText { channel: 0 }) - }; + let reply = plain_reply().await; let req_id = state.next_id().await; let prompt = prompt.to_string(); let name = peer_name.to_string(); diff --git a/core/archipelago/src/mesh/listener/dispatch.rs b/core/archipelago/src/mesh/listener/dispatch.rs index 7e469461..a23a81aa 100644 --- a/core/archipelago/src/mesh/listener/dispatch.rs +++ b/core/archipelago/src/mesh/listener/dispatch.rs @@ -755,13 +755,36 @@ pub(crate) async fn handle_typed_envelope_direct( ) .await; + // `!archy [sub]` typed in the normal 1:1 chat — deterministic node + // status, answered from local caches with no model in the loop, so + // it stays available with the assistant off. The trust gate is the + // same `is_sender_allowed` the assistant uses. + if let Some(rest) = super::node_cmd::strip_archy_trigger(&text) { + let req_id = state.next_id().await; + let rest = rest.to_string(); + let name = sender_name.to_string(); + let cid = sender_contact_id; + let st = Arc::clone(state); + tokio::spawn(async move { + super::node_cmd::run_node_cmd( + rest, + req_id, + cid, + name, + authenticated, + super::assist::AssistReply::ChatText { contact_id: cid }, + st, + ) + .await; + }); + } // Mesh-AI assistant (issue #50): a `!ai`/`!ask ` typed in // the normal 1:1 chat triggers this node's assistant, with the // answer sent back as a chat bubble in the same thread. The typed // DM carries the peer's federation identity (via sender_contact_id), // so the `trusted_only` gate in run_assist resolves correctly — // unlike the bare channel-text path, which only knows the radio key. - if state.assistant.read().await.enabled { + else if state.assistant.read().await.enabled { if let Some(prompt) = super::decode::strip_ai_trigger(&text) { if !prompt.is_empty() { let req_id = state.next_id().await; diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 2cede9b6..ad77f6cc 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -12,6 +12,7 @@ mod bitcoin; mod decode; pub(crate) mod dispatch; mod frames; +mod node_cmd; mod session; use super::types::*; diff --git a/core/archipelago/src/mesh/listener/node_cmd.rs b/core/archipelago/src/mesh/listener/node_cmd.rs new file mode 100644 index 00000000..0a87ca90 --- /dev/null +++ b/core/archipelago/src/mesh/listener/node_cmd.rs @@ -0,0 +1,197 @@ +//! Mesh `!archy` command — answers node-status questions from this node's own +//! cached state, with no model in the loop. +//! +//! `!ai` sends the question to an LLM; `!archy` never does. Every answer here is +//! read straight from the same status caches the HTTP endpoints serve, so it is +//! deterministic, costs no tokens, and works with the assistant switched off. +//! Airtime is scarce, so replies are single-frame terse. + +use super::assist::{is_sender_allowed, send_reply, AssistReply}; +use super::MeshState; +use crate::{bitcoin_status, electrs_status}; +use std::sync::Arc; +use tracing::{info, warn}; + +/// A parsed `!archy` sub-command. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum NodeCmd { + Status, + Bitcoin, + Electrs, + Version, + Help, +} + +impl NodeCmd { + fn parse(rest: &str) -> Self { + match rest.trim().to_ascii_lowercase().as_str() { + "" | "status" => Self::Status, + "btc" | "bitcoin" | "node" | "sync" => Self::Bitcoin, + "electrs" | "electrum" => Self::Electrs, + "version" | "ver" => Self::Version, + _ => Self::Help, + } + } +} + +/// Recognise a `!archy [subcommand]` prefix (case-insensitive) and return the +/// trimmed remainder, or `None` if the text isn't an archy command. +/// +/// Accepts both a bare `!archy` and `!archy `, so the trailing space that +/// `strip_ai_trigger` requires is deliberately not required here. +pub(super) fn strip_archy_trigger(text: &str) -> Option<&str> { + const P: &str = "!archy"; + let t = text.trim_start(); + if t.len() < P.len() || !t[..P.len()].eq_ignore_ascii_case(P) { + return None; + } + let rest = &t[P.len()..]; + // `!archyfoo` is not the command; require end-of-text or a separator. + if !rest.is_empty() && !rest.starts_with(char::is_whitespace) { + return None; + } + Some(rest.trim()) +} + +/// Entry point: gate the asker, format the answer from local caches, reply. +/// +/// Uses the same trust policy as the AI assistant (`is_sender_allowed`), so a +/// `trusted_only` node still only answers authenticated or allowlisted peers. +/// It does NOT require the assistant to be enabled — this path never calls a +/// model, so turning the LLM off shouldn't take node status with it. +pub(super) async fn run_node_cmd( + rest: String, + req_id: u64, + asker_contact_id: u32, + sender_name: String, + authenticated: bool, + reply: AssistReply, + state: Arc, +) { + let asker = asker_contact_id; + + if !is_sender_allowed(&state, asker, authenticated).await { + warn!( + from = asker, + name = %sender_name, + "!archy denied — sender not permitted by assistant policy" + ); + // Silent on the wire, matching the assistant's denial behaviour. + return; + } + + let cmd = NodeCmd::parse(&rest); + info!(from = asker, req_id, ?cmd, "Answering !archy over mesh"); + let answer = match cmd { + NodeCmd::Status => status_line().await, + NodeCmd::Bitcoin => bitcoin_line().await, + NodeCmd::Electrs => electrs_line().await, + NodeCmd::Version => version_line(), + NodeCmd::Help => { + "archy: !archy [status|btc|electrs|version]. Node status, no AI.".to_string() + } + }; + + send_reply(&state, &reply, req_id, &answer).await; +} + +fn version_line() -> String { + format!("Archipelago OS v{}", env!("CARGO_PKG_VERSION")) +} + +/// Pull `blocks`/`headers`/`connections` out of the cached Core RPC blobs. +async fn bitcoin_facts() -> Option<(u64, u64, u64, bool)> { + let s = bitcoin_status::get_bitcoin_status().await; + let chain = s.blockchain_info.as_ref()?; + let blocks = chain.get("blocks")?.as_u64()?; + let headers = chain.get("headers")?.as_u64()?; + let ibd = chain + .get("initialblockdownload") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let peers = s + .network_info + .as_ref() + .and_then(|n| n.get("connections")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + Some((blocks, headers, peers, ibd)) +} + +async fn bitcoin_line() -> String { + match bitcoin_facts().await { + Some((blocks, headers, peers, ibd)) => { + if !ibd && blocks == headers { + format!("BTC: synced, block {blocks}, {peers} peers.") + } else { + format!("BTC: syncing {blocks}/{headers}, {peers} peers.") + } + } + None => "BTC: status unavailable.".to_string(), + } +} + +async fn electrs_line() -> String { + let e = electrs_status::get_electrs_sync_status().await; + if let Some(err) = e.error.as_deref() { + return format!("Electrum: error ({err})."); + } + if e.status == "ready" || e.status == "synced" { + format!("Electrum: synced at {}.", e.indexed_height) + } else { + format!( + "Electrum: {} {:.1}% ({}/{}).", + e.status, e.progress_pct, e.indexed_height, e.bitcoin_height + ) + } +} + +/// One-frame overview: OS version + chain + electrum, kept under the plain-text +/// channel cap so a stock meshcore client sees the whole thing. +async fn status_line() -> String { + let btc = match bitcoin_facts().await { + Some((blocks, headers, peers, ibd)) if !ibd && blocks == headers => { + format!("BTC synced {blocks} ({peers}p)") + } + Some((blocks, headers, peers, _)) => format!("BTC {blocks}/{headers} ({peers}p)"), + None => "BTC n/a".to_string(), + }; + let e = electrs_status::get_electrs_sync_status().await; + let elec = if e.status == "ready" || e.status == "synced" { + "electrum synced".to_string() + } else { + format!("electrum {:.0}%", e.progress_pct) + }; + format!("Archipelago OS v{}: {btc}, {elec}.", env!("CARGO_PKG_VERSION")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trigger_matches_bare_and_sub() { + assert_eq!(strip_archy_trigger("!archy"), Some("")); + assert_eq!(strip_archy_trigger(" !ARCHY "), Some("")); + assert_eq!(strip_archy_trigger("!archy btc"), Some("btc")); + assert_eq!(strip_archy_trigger("!Archy electrs "), Some("electrs")); + } + + #[test] + fn trigger_rejects_non_commands() { + assert_eq!(strip_archy_trigger("!archyfoo"), None); + assert_eq!(strip_archy_trigger("!ai what is archy"), None); + assert_eq!(strip_archy_trigger("archy"), None); + assert_eq!(strip_archy_trigger("tell me !archy"), None); + } + + #[test] + fn subcommands_parse() { + assert_eq!(NodeCmd::parse(""), NodeCmd::Status); + assert_eq!(NodeCmd::parse("status"), NodeCmd::Status); + assert_eq!(NodeCmd::parse("BTC"), NodeCmd::Bitcoin); + assert_eq!(NodeCmd::parse("electrum"), NodeCmd::Electrs); + assert_eq!(NodeCmd::parse("ver"), NodeCmd::Version); + assert_eq!(NodeCmd::parse("wat"), NodeCmd::Help); + } +} diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 00000000..87a9a224 --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,141 @@ +# Talking to your node + +Every way to ask an Archipelago node a question: over the LoRa mesh, by voice, and over HTTP. + +Two rules worth internalising before the tables: + +- **`!ai` asks a language model. `!archy` never does.** `!archy` reads the same status caches the HTTP endpoints serve, so its answers are deterministic, cost nothing, and keep working with the assistant switched off. +- **Mesh command prefixes are exact strings** (case-insensitive). **Voice phrases are not** — they are matched by Home Assistant's intent parser, so the examples below are representative, not literal. + +--- + +## 1. Mesh commands + +Sent as ordinary text over the mesh — either as plain channel/DM text from a stock meshcore or Meshtastic client, or typed into a 1:1 chat in the Archipelago UI. + +### `!archy` — node status, no AI + +| Command | Aliases | Answers with | +|---|---|---| +| `!archy` | `!archy status` | OS version, chain tip, peer count, electrum progress | +| `!archy btc` | `bitcoin`, `node`, `sync` | Sync state, block height, peer count | +| `!archy electrs` | `electrum` | Electrum index progress | +| `!archy version` | `ver` | Archipelago OS version | +| anything else | — | A one-line usage hint | + +Examples of what comes back: + +``` +!archy → Archipelago OS v1.7.99-alpha: BTC synced 957295 (12p), electrum 86%. +!archy btc → BTC: synced, block 957295, 12 peers. +!archy electrs → Electrum: syncing 86.2% (824785/957295). +!archy version → Archipelago OS v1.7.99-alpha +!archy wat → archy: !archy [status|btc|electrs|version]. Node status, no AI. +``` + +`!archyfoo` is not a command — the prefix must be followed by whitespace or end-of-message. + +### `!ai` / `!ask` — language model + +``` +!ai what is the halving schedule +!ask how do I open a lightning channel +``` + +Requires the assistant to be **enabled** (`assistant_enabled` in `mesh-config.json`). Backend is `ollama` (default, `qwen2.5-coder`) or `claude` (`claude-haiku-4-5-20251001`), set by `assistant_backend`. The model is told to reply in at most two short sentences, because airtime is scarce. + +### Who is allowed to ask + +Both commands share one gate — `is_sender_allowed()` in `mesh/listener/assist.rs`. Evaluated in order: + +1. **Blocked contact** → always denied. +2. **On `assistant_allowed_contacts`** → allowed, even without a signature. This is the deliberate opt-in for keyless phone clients. +3. **`assistant_trusted_only == false`** → anyone on the mesh may ask. +4. **`assistant_trusted_only == true`** → the asker must be *authenticated* **and** carry federation `Trusted` status. + +"Authenticated" means the message carried an Ed25519 signature that verified against the sender's known identity key, **or** it arrived over the federation (Tor) transport, which verifies upstream. **Bare plain-text radio messages are never authenticated** — so on a `trusted_only` node, a stock meshcore client can only get an answer by being on the allowlist. + +Denials are silent on the wire (no airtime is spent saying "no"); the asker is recorded so an operator can allow them from the UI. + +The one asymmetry: **`!ai` additionally requires `assistant_enabled`; `!archy` does not**, because it never calls a model. Turning the LLM off should not take node status with it. + +### Where the answer goes + +| You asked from | Reply arrives as | +|---|---| +| Plain radio text, your pubkey is known | A private unicast DM (not the public channel) | +| Plain radio text, pubkey unresolvable | A broadcast on channel 0 | +| 1:1 chat in the Archipelago UI | A chat bubble in the same thread | +| The `AssistQuery` widget | Ordered, reassembled `AssistResponse` chunks | + +Size caps: 480 characters per answer overall, 200 for plain-text channel/DM replies, and a hard 160-byte LoRa frame limit underneath both. + +--- + +## 2. Voice + +A [PineVoice](https://pine64.org) satellite speaker, wake word **"Hey Jarvis"** (or press the centre button). Speech-to-text is Whisper, text-to-speech is Piper — both run locally on the node. Nothing leaves the box. + +Phrases are matched by intent, so wording is flexible. These are examples, not exact strings: + +| Ask something like | You hear | +|---|---| +| "What is Archipelago OS?" · "What is this node running?" | A one-sentence description plus the running version | +| "What version am I running?" | Version and uptime | +| "Is my node synced?" · "How is my Bitcoin node doing?" · "Bitcoin node status" | Sync state, block height, peer count | +| "What's the current block height?" · "How many blocks do we have?" | The chain tip | +| "Is the electrum server synced?" · "Electrum status" | Index progress | + +Optional and alternative words are part of the templates, so "how is *the* node doing" and "how is *my* Bitcoin node" both land on the same intent. + +### How voice is wired + +The speaker is a Wyoming satellite. Home Assistant runs it through an Assist pipeline: wake word on-device → audio streamed to Whisper → intent matched → Piper speaks the answer. + +| Piece | Location (on the node) | +|---|---| +| Whisper + Piper services | `~/.config/containers/systemd/wyoming-{whisper,piper}.container` | +| Wyoming entries, Assist pipeline | `home-assistant/.storage/{core.config_entries,assist_pipeline.pipelines}` | +| Sensors + spoken answers | `home-assistant/configuration.yaml` (`rest:` and `intent_script:`) | +| Phrasings | `home-assistant/custom_sentences/en/archipelago.yaml` | + +Home Assistant reaches the node's own HTTP API at `host.containers.internal` — **not** the node's LAN IP, which under rootless podman's pasta networking resolves back to the container itself. + +Adding a phrase means editing `custom_sentences/en/archipelago.yaml`; adding an *answer* means adding an `intent_script` entry (and a `rest:` sensor if it needs new data). + +--- + +## 3. HTTP + +Served by nginx on port 80, proxying the backend on `127.0.0.1:5678`. + +**No authentication required** (5-second cache): + +| Endpoint | Returns | +|---|---| +| `GET /health` | Status, uptime, version, services | +| `GET /bitcoin-status` | `getblockchaininfo` + `getnetworkinfo` + `getindexinfo` | +| `GET /electrs-status` | Index height, progress, onion address | + +```bash +curl -s http:///bitcoin-status | jq '.blockchain_info.blocks' +``` + +**Session required** — everything else goes through JSON-RPC at `POST /rpc/v1`: + +```bash +curl -s http:///rpc/v1 -H 'Content-Type: application/json' \ + -d '{"method":"auth.login","params":{"password":"…"}}' -c jar.txt +curl -s http:///rpc/v1 -b jar.txt -H 'Content-Type: application/json' \ + -d '{"method":"system.stats","params":{}}' +``` + +Login returns a `session` cookie. Read-only methods (`system.stats`, `system.get-metrics`, `bitcoin.getinfo`, `monitoring.current`, `bitcoin.relay-status`, `tor.status`) are CSRF-exempt, so the cookie alone is enough; state-changing calls also need the `X-CSRF-Token` header. If TOTP is enabled, follow the login with `auth.login.totp`. + +--- + +## Adding a command + +- **New `!archy` sub-command** — add a variant to `NodeCmd` and a match arm in `run_node_cmd`, both in `mesh/listener/node_cmd.rs`. Keep answers under 200 characters so a stock client sees the whole thing in one frame. +- **New mesh prefix** — add a `strip_*_trigger` alongside `strip_archy_trigger`, then hook it in `decode.rs` (plain radio text) and `dispatch.rs` (typed 1:1 chat). Both paths must be wired or the command only works from one of them. +- **New voice phrase or answer** — see the voice table above. diff --git a/docs/meshroller-integration-design.md b/docs/meshroller-integration-design.md index 27bd31bd..0ad7fff0 100644 --- a/docs/meshroller-integration-design.md +++ b/docs/meshroller-integration-design.md @@ -108,6 +108,13 @@ run the same gated `run_assist`. This means **any meshcore client** (even a bare Meshtastic-style sender) can ask, while typed `AssistQuery` is the rich path our own UI uses. Trigger + enable are config. +A sibling command, **`!archy`**, answers node-status questions from the local +status caches with no model in the loop (`mesh/listener/node_cmd.rs`). It reuses +this design's trust gate (`is_sender_allowed`) and reply routing verbatim, but +deliberately does *not* require `assistant_enabled` — turning the LLM off should +not take node status with it. See [COMMANDS.md](COMMANDS.md) for the full +user-facing command surface. + ### 1.6 UI events (`types.rs`) ```rust AssistQueryReceived { from_contact_id: u32, prompt: String },