#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UiCommand {
OpenFiles,
OpenDisplay,
OpenTerminal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocsTopic {
Pricing,
Funding,
WhatIsThis,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FreeAction {
BalanceQuery,
UiCommand(UiCommand),
DocsAnswer(DocsTopic),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Route {
Free(FreeAction),
Metered,
}
pub trait IntentClassifier {
fn classify(&self, input: &str) -> Route;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct HeuristicClassifier;
pub const FREE_ROUTE_FOOTER: &str =
"(routed free — no $LH spent. Prefix with '!' or rephrase to ask the model.)";
const MAX_FREE_CHARS: usize = 40;
const FREE_PHRASES: &[(&str, FreeAction)] = &[
("balance", FreeAction::BalanceQuery),
("my balance", FreeAction::BalanceQuery),
("show balance", FreeAction::BalanceQuery),
("show my balance", FreeAction::BalanceQuery),
("check balance", FreeAction::BalanceQuery),
("check my balance", FreeAction::BalanceQuery),
("what is my balance", FreeAction::BalanceQuery),
("whats my balance", FreeAction::BalanceQuery),
("what's my balance", FreeAction::BalanceQuery),
("credits", FreeAction::BalanceQuery),
("my credits", FreeAction::BalanceQuery),
("show credits", FreeAction::BalanceQuery),
("show my credits", FreeAction::BalanceQuery),
("check my credits", FreeAction::BalanceQuery),
("lh balance", FreeAction::BalanceQuery),
("$lh balance", FreeAction::BalanceQuery),
("credit balance", FreeAction::BalanceQuery),
("how much lh do i have", FreeAction::BalanceQuery),
("how much $lh do i have", FreeAction::BalanceQuery),
("how many credits do i have", FreeAction::BalanceQuery),
("files", FreeAction::UiCommand(UiCommand::OpenFiles)),
("open files", FreeAction::UiCommand(UiCommand::OpenFiles)),
("show files", FreeAction::UiCommand(UiCommand::OpenFiles)),
("show my files", FreeAction::UiCommand(UiCommand::OpenFiles)),
("open the files", FreeAction::UiCommand(UiCommand::OpenFiles)),
("file browser", FreeAction::UiCommand(UiCommand::OpenFiles)),
("open file browser", FreeAction::UiCommand(UiCommand::OpenFiles)),
("display", FreeAction::UiCommand(UiCommand::OpenDisplay)),
("open display", FreeAction::UiCommand(UiCommand::OpenDisplay)),
("show display", FreeAction::UiCommand(UiCommand::OpenDisplay)),
("open the display", FreeAction::UiCommand(UiCommand::OpenDisplay)),
("toggle display", FreeAction::UiCommand(UiCommand::OpenDisplay)),
("terminal", FreeAction::UiCommand(UiCommand::OpenTerminal)),
("open terminal", FreeAction::UiCommand(UiCommand::OpenTerminal)),
("show terminal", FreeAction::UiCommand(UiCommand::OpenTerminal)),
("open the terminal", FreeAction::UiCommand(UiCommand::OpenTerminal)),
("what does a message cost", FreeAction::DocsAnswer(DocsTopic::Pricing)),
("how much does a message cost", FreeAction::DocsAnswer(DocsTopic::Pricing)),
("how much is a message", FreeAction::DocsAnswer(DocsTopic::Pricing)),
("what does the meter cost", FreeAction::DocsAnswer(DocsTopic::Pricing)),
("price per message", FreeAction::DocsAnswer(DocsTopic::Pricing)),
("cost per message", FreeAction::DocsAnswer(DocsTopic::Pricing)),
("how do i get lh", FreeAction::DocsAnswer(DocsTopic::Funding)),
("how do i get $lh", FreeAction::DocsAnswer(DocsTopic::Funding)),
("how do i get credits", FreeAction::DocsAnswer(DocsTopic::Funding)),
("how do i fund my wallet", FreeAction::DocsAnswer(DocsTopic::Funding)),
("what is localharness", FreeAction::DocsAnswer(DocsTopic::WhatIsThis)),
("what is this", FreeAction::DocsAnswer(DocsTopic::WhatIsThis)),
("what is this app", FreeAction::DocsAnswer(DocsTopic::WhatIsThis)),
];
pub fn docs_answer(topic: DocsTopic) -> &'static str {
match topic {
DocsTopic::Pricing => {
"Platform-credit messages cost 1 $LH each, debited from your meter \
per message (premium models are tiered higher). Fiat on-ramp: \
$1 = 100 $LH."
}
DocsTopic::Funding => {
"Fund an agent with $LH via a redeem code, a send_lh transfer from \
another agent, an ?invite= link (refundable escrow), or a card buy \
($1 = 100 $LH). The daily free claim is disabled."
}
DocsTopic::WhatIsThis => {
"localharness — a self-sovereign, browser-resident agent platform: \
one Rust crate compiled to wasm running entirely in your tab. Your \
agent is an ERC-721 name with an ERC-6551 wallet on Tempo; the only \
server is the $LH credit proxy. Ask the model (metered) for detail, \
or have it call read_self_docs."
}
}
}
pub fn strip_bang(input: &str) -> &str {
let t = input.trim_start();
match t.strip_prefix('!') {
Some(rest) => rest.trim_start(),
None => input,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouterCmd {
On,
Off,
Status,
}
pub fn router_enabled(opt_out_flag: Option<&str>) -> bool {
!matches!(opt_out_flag, Some("0"))
}
pub fn parse_router_cmd(input: &str) -> Option<RouterCmd> {
let t = input.trim().to_ascii_lowercase();
match t.as_str() {
"/router on" => Some(RouterCmd::On),
"/router off" => Some(RouterCmd::Off),
"/router" | "/router status" => Some(RouterCmd::Status),
_ => None,
}
}
fn normalize(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for word in input.split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(&word.to_lowercase());
}
while out.ends_with(['?', '!', '.']) {
out.pop();
}
while out.ends_with(' ') {
out.pop();
}
out
}
impl IntentClassifier for HeuristicClassifier {
fn classify(&self, input: &str) -> Route {
let raw = input.trim();
if raw.is_empty() {
return Route::Metered;
}
if raw.starts_with('!') {
return Route::Metered;
}
let norm = normalize(raw);
if norm.is_empty()
|| norm.len() > MAX_FREE_CHARS
|| norm.contains(['\n', ',', ';', ':', '?'])
|| norm.contains(" and ")
|| norm.contains(" then ")
{
return Route::Metered;
}
for (phrase, action) in FREE_PHRASES {
if norm == *phrase {
return Route::Free(*action);
}
}
Route::Metered
}
}
#[cfg(test)]
mod tests {
use super::*;
fn classify(s: &str) -> Route {
HeuristicClassifier.classify(s)
}
#[test]
fn balance_phrasings_route_free() {
for s in [
"balance",
"Balance",
" balance ",
"balance?",
"show my balance",
"Show my balance?",
"what's my balance",
"What is my balance?",
"check my balance",
"credits",
"my credits",
"how much $LH do I have?",
"how many credits do i have",
"lh balance",
] {
assert_eq!(classify(s), Route::Free(FreeAction::BalanceQuery), "{s:?}");
}
}
#[test]
fn ui_commands_route_free() {
assert_eq!(
classify("open files"),
Route::Free(FreeAction::UiCommand(UiCommand::OpenFiles))
);
assert_eq!(
classify("Files"),
Route::Free(FreeAction::UiCommand(UiCommand::OpenFiles))
);
assert_eq!(
classify("open the display"),
Route::Free(FreeAction::UiCommand(UiCommand::OpenDisplay))
);
assert_eq!(
classify("show terminal"),
Route::Free(FreeAction::UiCommand(UiCommand::OpenTerminal))
);
}
#[test]
fn docs_faq_routes_free() {
assert_eq!(
classify("what does the meter cost?"),
Route::Free(FreeAction::DocsAnswer(DocsTopic::Pricing))
);
assert_eq!(
classify("How much does a message cost?"),
Route::Free(FreeAction::DocsAnswer(DocsTopic::Pricing))
);
assert_eq!(
classify("how do I get $lh"),
Route::Free(FreeAction::DocsAnswer(DocsTopic::Funding))
);
assert_eq!(
classify("what is localharness?"),
Route::Free(FreeAction::DocsAnswer(DocsTopic::WhatIsThis))
);
}
#[test]
fn docs_answers_carry_the_pricing_fact() {
assert!(docs_answer(DocsTopic::Pricing).contains("1 $LH"));
assert!(docs_answer(DocsTopic::Funding).contains("$1 = 100 $LH"));
assert!(!docs_answer(DocsTopic::WhatIsThis).is_empty());
}
#[test]
fn free_footer_names_the_escape_hatch() {
assert!(FREE_ROUTE_FOOTER.contains("'!'"));
assert!(FREE_ROUTE_FOOTER.to_lowercase().contains("model"));
}
#[test]
fn realistic_prompts_route_metered() {
for s in [
"write me a poem",
"why did my tx fail?",
"summarize my notes file",
"translate hello to french",
"help me plan a birthday party",
"what should I build next?",
"refactor my cartridge to use fewer state slots",
"explain how the diamond proxy pattern works",
"draft a persona for my agent",
"generate an image of a lighthouse",
"balance my argument",
"balance the equation 2x + 3 = 7",
"how do I balance work and life?",
"is my ledger balanced?",
"balance transfer to bob", "credits to the team for shipping this",
"add film credits to my app",
"why are my credits draining so fast?",
"send 5 credits to alice",
"show me how to open files in rust",
"what files does my agent create?",
"display a chart of my spending",
"my display code is broken",
"explain terminal velocity",
"delete all my files", "check my balance and then send 5 lh to bob",
"open files, then edit app.rl",
"show my balance please and thank you",
"gimme balance",
"balance now now now",
"what is my balance in usd",
"how much is a message in dollars",
] {
assert_eq!(classify(s), Route::Metered, "{s:?} must be metered");
}
}
#[test]
fn long_messages_never_route_free() {
let s = "balance ".repeat(20);
assert_eq!(classify(&s), Route::Metered);
assert_eq!(
classify("show my balance after applying the pending bounty payouts"),
Route::Metered
);
}
#[test]
fn bang_prefix_always_forces_metered() {
for s in ["!balance", "! balance", "!credits", " !open files", "!what is this"] {
assert_eq!(classify(s), Route::Metered, "{s:?}");
}
}
#[test]
fn strip_bang_removes_only_the_router_escape() {
assert_eq!(strip_bang("!balance"), "balance");
assert_eq!(strip_bang("! balance"), "balance");
assert_eq!(strip_bang(" !balance"), "balance");
assert_eq!(strip_bang("!!balance"), "!balance");
assert_eq!(strip_bang("balance!"), "balance!");
assert_eq!(strip_bang("write a poem"), "write a poem");
}
#[test]
fn empty_and_whitespace_route_metered() {
assert_eq!(classify(""), Route::Metered);
assert_eq!(classify(" "), Route::Metered);
assert_eq!(classify("\n\t"), Route::Metered);
assert_eq!(classify("!"), Route::Metered);
assert_eq!(classify("???"), Route::Metered);
}
#[test]
fn router_cmd_parses() {
assert_eq!(parse_router_cmd("/router off"), Some(RouterCmd::Off));
assert_eq!(parse_router_cmd("/router on"), Some(RouterCmd::On));
assert_eq!(parse_router_cmd("/router"), Some(RouterCmd::Status));
assert_eq!(parse_router_cmd("/router status"), Some(RouterCmd::Status));
assert_eq!(parse_router_cmd("/Router OFF"), Some(RouterCmd::Off));
assert_eq!(parse_router_cmd(" /router off "), Some(RouterCmd::Off));
assert_eq!(parse_router_cmd("/router maybe"), None);
assert_eq!(parse_router_cmd("router off"), None);
assert_eq!(parse_router_cmd("turn the /router off"), None);
}
#[test]
fn router_default_is_on_opt_out_only() {
assert!(router_enabled(None));
assert!(router_enabled(Some("")));
assert!(router_enabled(Some("1")));
assert!(router_enabled(Some("true")));
assert!(!router_enabled(Some("0")));
}
#[test]
fn allowlist_entries_are_normalized_and_short() {
for (phrase, _) in FREE_PHRASES {
assert_eq!(*phrase, normalize(phrase), "{phrase:?} not normalized");
assert!(phrase.len() <= MAX_FREE_CHARS, "{phrase:?} exceeds MAX_FREE_CHARS");
assert!(
matches!(classify(phrase), Route::Free(_)),
"{phrase:?} on the list but classifies Metered"
);
}
}
}