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

This commit was merged in pull request #72.
This commit is contained in:
ai
2026-07-09 12:48:28 +00:00
7 changed files with 414 additions and 23 deletions
+7 -2
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);
+37 -20
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();
+24 -1
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;
@@ -12,6 +12,7 @@ mod bitcoin;
mod decode;
pub(crate) mod dispatch;
mod frames;
mod node_cmd;
mod session;
use super::types::*;
@@ -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);
}
}