Merge pull request 'feat(mesh): !archy command — node status over the mesh, no AI' (#72) from feat/mesh-archy-command into main

This commit is contained in:
ai 2026-07-09 12:48:28 +00:00
commit 6109074748
7 changed files with 414 additions and 23 deletions

View File

@ -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<MeshState>,
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<MeshState>, reply: &AssistReply, req_id: u64, answer: &str) {
pub(super) async fn send_reply(
state: &Arc<MeshState>,
reply: &AssistReply,
req_id: u64,
answer: &str,
) {
match reply {
AssistReply::Typed { contact_id } => {
let (text, _) = cap_reply(answer);

View File

@ -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 <question>` 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();

View File

@ -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 <question>` 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;

View File

@ -12,6 +12,7 @@ mod bitcoin;
mod decode;
pub(crate) mod dispatch;
mod frames;
mod node_cmd;
mod session;
use super::types::*;

View File

@ -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 <sub>`, 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<MeshState>,
) {
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);
}
}

141
docs/COMMANDS.md Normal file
View File

@ -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://<node>/bitcoin-status | jq '.blockchain_info.blocks'
```
**Session required** — everything else goes through JSON-RPC at `POST /rpc/v1`:
```bash
curl -s http://<node>/rpc/v1 -H 'Content-Type: application/json' \
-d '{"method":"auth.login","params":{"password":"…"}}' -c jar.txt
curl -s http://<node>/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.

View File

@ -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 },