From 228234633bfff6b3b775df1b3ae4c0361f62f697 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 11:31:13 -0700 Subject: [PATCH 1/2] chat: the platform verbs reach chat, and the vocabularies are mapped (plan 5.4, 3.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 5.4 — chat ran a subset of the platform: nothing in it could see or steer a formation, answer an approval by name, audit memory, or change safety or model configuration, while the README and colony-canvas §9.1 promised "the same command router". - `springtale_runtime::operations::platform` is the one verb registry: name, description, group, read_only, argument schema. Twenty verbs across formation, approvals, memory, safety and AI configuration. - `HandlerContext` and `Bot` carry an optional `RuntimeState` (the daemon injects it; headless and test bots say so rather than pretending), and five builtin handlers go through existing runtime operations only. Formation names resolve exact, then case-insensitive prefix, and an ambiguous prefix is an error rather than a guess. - The drum rule holds: the registry has no assign verb, inspection is exactly the read-only set, and a test asserts both the way the CLI suite asserts it over its clap tree. - The NLU learns the same verbs: one `IntentDoc` per platform verb, phrased from the sentence templates, with the `{formation}` slot's gazetteer built from the live roster read at match time. A recognised verb runs through the same builtin handler the slash command hits. - Sentence templates move to per-locale files, hassil-style. `en` is the existing English catalogue; ar, es, fr, ja, pt, th and tl are stubs that fall back to English until translated. Plan 3.9 — the three vocabularies stay, but the table was missing. GLOSSARY.md gains the canvas/formation-verb/runtime mapping, and the interface fields that said swarm and meant formation are renamed on both sides: `DataProvider.formations()` returning `FormationInfo[]`, and `selectedFormationId`. The formation verbs are untouched — they are the mechanics. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- Cargo.lock | 1 + apps/springtaled/src/runtime/boot/bot.rs | 3 + crates/springtale-bot/Cargo.toml | 3 + .../src/conversation/catalog/mod.rs | 2 + .../src/conversation/catalog/platform.rs | 178 ++++++++++++ .../src/conversation/catalog/snapshot.rs | 25 ++ .../src/conversation/dispatch.rs | 60 +++++ .../springtale-bot/src/conversation/engine.rs | 39 ++- crates/springtale-bot/src/conversation/mod.rs | 2 + .../src/conversation/sentences/ar.yaml | 9 + .../src/conversation/sentences/catalog.rs | 137 ++++++++++ .../src/conversation/sentences/en.yaml | 52 ++++ .../src/conversation/sentences/es.yaml | 9 + .../src/conversation/sentences/fr.yaml | 9 + .../src/conversation/sentences/ja.yaml | 9 + .../src/conversation/sentences/mod.rs | 5 + .../src/conversation/sentences/pt.yaml | 9 + .../src/conversation/sentences/th.yaml | 9 + .../src/conversation/sentences/tl.yaml | 9 + .../springtale-bot/src/handler/builtin/ai.rs | 72 +++++ .../src/handler/builtin/approvals.rs | 79 ++++++ .../src/handler/builtin/formation.rs | 143 ++++++++++ .../src/handler/builtin/memory.rs | 53 ++++ .../springtale-bot/src/handler/builtin/mod.rs | 23 ++ .../src/handler/builtin/resolve.rs | 110 ++++++++ .../src/handler/builtin/safety.rs | 79 ++++++ crates/springtale-bot/src/handler/registry.rs | 15 ++ crates/springtale-bot/src/runtime/handlers.rs | 1 + .../springtale-bot/src/runtime/lifecycle.rs | 19 ++ .../springtale-runtime/src/operations/mod.rs | 1 + .../src/operations/platform/mod.rs | 10 + .../src/operations/platform/registry.rs | 254 ++++++++++++++++++ .../src/operations/platform/verb.rs | 103 +++++++ docs/GLOSSARY.md | 23 ++ tauri/apps/dashboard/src/App.tsx | 2 +- tauri/apps/desktop/src/Colony.tsx | 2 +- tauri/packages/ui/src/colony/controller.ts | 6 +- tauri/packages/ui/src/colony/mappers.ts | 8 +- tauri/packages/ui/src/dashboard/context.ts | 24 +- tauri/packages/ui/src/dashboard/model.ts | 4 +- tauri/packages/ui/src/dashboard/types.ts | 12 +- tauri/packages/ui/src/index.ts | 2 +- 42 files changed, 1584 insertions(+), 31 deletions(-) create mode 100644 crates/springtale-bot/src/conversation/catalog/platform.rs create mode 100644 crates/springtale-bot/src/conversation/dispatch.rs create mode 100644 crates/springtale-bot/src/conversation/sentences/ar.yaml create mode 100644 crates/springtale-bot/src/conversation/sentences/catalog.rs create mode 100644 crates/springtale-bot/src/conversation/sentences/en.yaml create mode 100644 crates/springtale-bot/src/conversation/sentences/es.yaml create mode 100644 crates/springtale-bot/src/conversation/sentences/fr.yaml create mode 100644 crates/springtale-bot/src/conversation/sentences/ja.yaml create mode 100644 crates/springtale-bot/src/conversation/sentences/mod.rs create mode 100644 crates/springtale-bot/src/conversation/sentences/pt.yaml create mode 100644 crates/springtale-bot/src/conversation/sentences/th.yaml create mode 100644 crates/springtale-bot/src/conversation/sentences/tl.yaml create mode 100644 crates/springtale-bot/src/handler/builtin/ai.rs create mode 100644 crates/springtale-bot/src/handler/builtin/approvals.rs create mode 100644 crates/springtale-bot/src/handler/builtin/formation.rs create mode 100644 crates/springtale-bot/src/handler/builtin/memory.rs create mode 100644 crates/springtale-bot/src/handler/builtin/resolve.rs create mode 100644 crates/springtale-bot/src/handler/builtin/safety.rs create mode 100644 crates/springtale-runtime/src/operations/platform/mod.rs create mode 100644 crates/springtale-runtime/src/operations/platform/registry.rs create mode 100644 crates/springtale-runtime/src/operations/platform/verb.rs diff --git a/Cargo.lock b/Cargo.lock index 6c83638a..19adcb12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5722,6 +5722,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml", "springtale-ai", "springtale-connector", "springtale-cooperation", diff --git a/apps/springtaled/src/runtime/boot/bot.rs b/apps/springtaled/src/runtime/boot/bot.rs index cc692762..d8894661 100644 --- a/apps/springtaled/src/runtime/boot/bot.rs +++ b/apps/springtaled/src/runtime/boot/bot.rs @@ -116,6 +116,9 @@ pub(super) async fn init_bot( let bot = springtale_bot::BotBuilder::new() .recipe_deployer(recipe_deployer) + // Plan 5.4 — chat reaches the platform verbs through the same + // runtime every other surface calls. + .runtime(runtime.clone()) .store(runtime.store.clone()) .registry(runtime.registry.clone()) .engine(runtime.engine.clone()) diff --git a/crates/springtale-bot/Cargo.toml b/crates/springtale-bot/Cargo.toml index 183b01d9..dbc12b8b 100644 --- a/crates/springtale-bot/Cargo.toml +++ b/crates/springtale-bot/Cargo.toml @@ -23,6 +23,9 @@ async-trait = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# Sentence templates ship as per-locale YAML (plan 5.4); already pinned +# at the workspace root for the recipe/template loaders. +serde_yaml = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/crates/springtale-bot/src/conversation/catalog/mod.rs b/crates/springtale-bot/src/conversation/catalog/mod.rs index 09aa92e5..4dd9892d 100644 --- a/crates/springtale-bot/src/conversation/catalog/mod.rs +++ b/crates/springtale-bot/src/conversation/catalog/mod.rs @@ -1,5 +1,7 @@ //! Recipe catalogue projected for the conversational engine. +pub mod platform; pub mod snapshot; +pub use platform::platform_docs; pub use snapshot::{CatalogSnapshot, IntentDoc, SlotKindTag, SlotSpec}; diff --git a/crates/springtale-bot/src/conversation/catalog/platform.rs b/crates/springtale-bot/src/conversation/catalog/platform.rs new file mode 100644 index 00000000..352896b9 --- /dev/null +++ b/crates/springtale-bot/src/conversation/catalog/platform.rs @@ -0,0 +1,178 @@ +//! Platform verbs projected as intent documents (plan 5.4). +//! +//! The NLU already scores recipes as [`IntentDoc`]s; the platform verbs +//! become documents of the same shape, so "hold the research squad" +//! ranks against `formation.pause` exactly the way "morning weather" +//! ranks against a recipe. Two things differ from a recipe document: +//! the token bag is phrased from the per-locale sentence templates +//! rather than a recipe name, and the `{formation}` slot's gazetteer is +//! built from the live formation list at match time — never hard-coded. + +use springtale_runtime::operations::platform::{PlatformVerb, platform_verbs}; +use springtale_runtime::operations::recipes::types::{ + FieldKind, FieldVisibility, InputField, RecipeCategory, SelectOption, +}; + +use super::snapshot::{IntentDoc, SlotKindTag, SlotSpec}; +use crate::conversation::nlu::gazetteer::Gazetteer; +use crate::conversation::nlu::normalize::tokenize; +use crate::conversation::sentences; + +/// Document id prefix, so a platform document can never collide with a +/// recipe id and `IntentDoc::platform_verb` is the only thing routing +/// reads. +pub const PLATFORM_PREFIX: &str = "platform:"; + +/// Build one [`IntentDoc`] per platform verb. +/// +/// `formation_names` is the live roster read from the store at match +/// time; it fills the `{formation}` slot's gazetteer. +pub fn platform_docs(locale: &str, formation_names: &[String]) -> Vec { + let catalog = sentences::for_locale(locale); + platform_verbs() + .iter() + .map(|v| project_verb(v, catalog.phrases(v.name), formation_names)) + .collect() +} + +fn project_verb(verb: &PlatformVerb, phrases: &[String], formation_names: &[String]) -> IntentDoc { + // The verb's own words are the strongest signal; the sentence + // templates carry the synonyms ("pause", "hold", "stop for now"). + let mut name_stems: Vec = tokenize(&verb.name.replace(['.', '_'], " ")) + .into_iter() + .map(|t| t.stem) + .collect(); + for phrase in phrases { + // Slot markers are not words the user says. + let bare = strip_slots(phrase); + name_stems.extend(tokenize(&bare).into_iter().map(|t| t.stem)); + } + name_stems.sort(); + name_stems.dedup(); + + let desc_stems = tokenize(verb.description) + .into_iter() + .map(|t| t.stem) + .collect(); + + let mut slots = Vec::new(); + if verb.takes_formation() { + slots.push(formation_slot(formation_names)); + } + + IntentDoc { + recipe_id: format!("{PLATFORM_PREFIX}{}", verb.name), + name: verb.name.to_owned(), + description: verb.description.to_owned(), + // Not a recipe category in any real sense — platform documents are + // routed by `platform_verb`, never by category. + category: RecipeCategory::Custom, + ai_required: false, + name_stems, + tag_stems: vec![verb.group.as_str().to_owned()], + desc_stems, + slots, + platform_verb: Some(verb.name), + } +} + +/// `pause {formation}` → `pause`. +fn strip_slots(phrase: &str) -> String { + let mut out = String::with_capacity(phrase.len()); + let mut depth = 0usize; + for ch in phrase.chars() { + match ch { + '{' => depth += 1, + '}' => depth = depth.saturating_sub(1), + c if depth == 0 => out.push(c), + _ => {} + } + } + out +} + +/// The `{formation}` slot — a `Select` over the live formation names, so +/// the existing gazetteer extractor fills it from the sentence. +fn formation_slot(formation_names: &[String]) -> SlotSpec { + let options: Vec = formation_names + .iter() + .map(|n| SelectOption { + value: n.clone(), + label: n.clone(), + }) + .collect(); + let gazetteer = Gazetteer::from_options( + options + .iter() + .map(|o| (o.value.clone(), o.label.clone())) + .collect::>(), + ); + SlotSpec { + field: InputField { + id: "formation".to_owned(), + label: "formation".to_owned(), + kind: FieldKind::Select { options }, + visibility: FieldVisibility::Required, + default: None, + hint: None, + }, + tag: SlotKindTag::Select, + gazetteer: Some(gazetteer), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + fn names() -> Vec { + vec!["Research Squad".to_owned(), "Watchtower".to_owned()] + } + + #[test] + fn test_one_document_per_platform_verb() { + let docs = platform_docs("en", &names()); + assert_eq!(docs.len(), platform_verbs().len()); + assert!(docs.iter().all(|d| d.platform_verb.is_some())); + } + + #[test] + fn test_formation_slot_reads_the_live_names() { + let docs = platform_docs("en", &names()); + let pause = docs + .iter() + .find(|d| d.platform_verb == Some("formation.pause")) + .expect("pause doc"); + let slot = pause.slot("formation").expect("formation slot"); + let g = slot.gazetteer.as_ref().expect("gazetteer"); + assert!(g.match_in("hold the research squad please").is_some()); + // A name that is not in the live roster does not match. + assert!(g.match_in("hold the kitchen brigade").is_none()); + } + + #[test] + fn test_synonyms_from_the_sentence_file_reach_the_token_bag() { + let docs = platform_docs("en", &names()); + let pause = docs + .iter() + .find(|d| d.platform_verb == Some("formation.pause")) + .expect("pause doc"); + assert!(pause.name_stems.iter().any(|s| s.starts_with("hold"))); + } + + /// The drum rule again, this time over what chat can *understand*. + #[test] + fn test_no_platform_document_assigns_work_to_a_member() { + for doc in platform_docs("en", &names()) { + assert!(!doc.name.contains("assign"), "{} assigns", doc.name); + assert!( + !doc.slots + .iter() + .any(|s| s.id() == "member" || s.id() == "agent"), + "{} takes a member slot", + doc.name + ); + } + } +} diff --git a/crates/springtale-bot/src/conversation/catalog/snapshot.rs b/crates/springtale-bot/src/conversation/catalog/snapshot.rs index 9f417cd3..8490e5d3 100644 --- a/crates/springtale-bot/src/conversation/catalog/snapshot.rs +++ b/crates/springtale-bot/src/conversation/catalog/snapshot.rs @@ -78,6 +78,12 @@ pub struct IntentDoc { /// Stemmed tokens from the description. pub desc_stems: Vec, pub slots: Vec, + /// Set on the documents built from `platform::platform_docs` — the + /// dotted platform verb this document stands for (plan 5.4). `None` + /// for recipes. Routing reads this and nothing else: a document with + /// a verb runs a command, a document without one starts a setup + /// frame. + pub platform_verb: Option<&'static str>, } impl IntentDoc { @@ -137,6 +143,24 @@ impl CatalogSnapshot { Self { intents } } + /// The catalogue plus one document per platform verb (plan 5.4). + /// `formation_names` is the live roster, read at match time so the + /// `{formation}` slot list is never hard-coded. + pub fn build_with_platform( + recipes: Vec, + locale: &str, + formation_names: &[String], + ) -> Self { + let mut intents: Vec = recipes.into_iter().map(project_recipe).collect(); + intents.extend(super::platform::platform_docs(locale, formation_names)); + Self { intents } + } + + /// The document for a dotted platform verb, if it is in the snapshot. + pub fn find_verb(&self, verb: &str) -> Option<&IntentDoc> { + self.intents.iter().find(|d| d.platform_verb == Some(verb)) + } + pub fn find(&self, recipe_id: &str) -> Option<&IntentDoc> { self.intents.iter().find(|d| d.recipe_id == recipe_id) } @@ -189,6 +213,7 @@ fn project_recipe(r: Recipe) -> IntentDoc { tag_stems, desc_stems, slots, + platform_verb: None, } } diff --git a/crates/springtale-bot/src/conversation/dispatch.rs b/crates/springtale-bot/src/conversation/dispatch.rs new file mode 100644 index 00000000..234553f2 --- /dev/null +++ b/crates/springtale-bot/src/conversation/dispatch.rs @@ -0,0 +1,60 @@ +//! Running a platform verb the NLU recognised (plan 5.4). +//! +//! The language understanding and the `/formation` command must not +//! drift apart, so a recognised verb is executed by the same builtin +//! handler the slash command hits — the sentence is turned into the +//! argument line and dispatched, nothing more. + +use crate::conversation::catalog::IntentDoc; +use crate::conversation::dialogue::slots; +use crate::handler::registry::{HandlerContext, HandlerResult}; +use crate::runtime::lifecycle::Bot; +use crate::state::session::SessionKey; + +/// Build the argument line for a platform document from the utterance. +/// +/// `formation.pause` + "hold the research squad" → `pause Research Squad`. +pub fn argument_line(doc: &IntentDoc, text: &str) -> Option { + let verb = doc.platform_verb?; + let sub = verb.split_once('.').map(|(_, s)| s).unwrap_or(verb); + let mut line = String::from(sub); + for fill in slots::extract_all(text, doc) { + if fill.slot_id == "formation" { + line.push(' '); + line.push_str(&fill.display); + } + } + Some(line) +} + +/// Execute a recognised platform verb through its builtin handler. +/// `None` when the document is not a platform verb, the handler is not +/// registered, or the sentence did not name what the verb needs. +pub async fn run(bot: &Bot, key: &SessionKey, doc: &IntentDoc, text: &str) -> Option { + let verb = doc.platform_verb?; + let command = verb.split_once('.').map(|(c, _)| c).unwrap_or(verb); + let args = argument_line(doc, text)?; + // A verb that needs a formation but got none is not a match — fall + // through so the user gets the ordinary "which one?" path rather + // than a silently wrong target. + if doc.slot("formation").is_some() && args.split_whitespace().count() < 2 { + return None; + } + let handler = bot.handlers.get(command)?; + let ctx = HandlerContext { + user_id: key.user_id.clone(), + channel_id: key.channel_id.clone(), + source_connector: "chat".to_owned(), + store: bot.store.clone(), + registry: bot.registry.clone(), + engine: bot.engine.clone(), + capability_bridge: bot.capability_bridge.clone(), + sentinel: bot.sentinel.clone(), + formation_tier: None, + runtime: bot.runtime.clone(), + }; + match handler.handle(&args, &ctx).await { + Ok(HandlerResult { response }) => Some(response), + Err(e) => Some(e.to_string()), + } +} diff --git a/crates/springtale-bot/src/conversation/engine.rs b/crates/springtale-bot/src/conversation/engine.rs index 9690adc7..184660ae 100644 --- a/crates/springtale-bot/src/conversation/engine.rs +++ b/crates/springtale-bot/src/conversation/engine.rs @@ -61,6 +61,14 @@ pub async fn try_start( let Some(doc) = catalog.find(&cand.recipe_id).cloned() else { return Ok(None); }; + // A platform verb is run, not set up: no slot-filling frame, + // no deploy. Same handler the slash command reaches. + if doc.platform_verb.is_some() { + match super::dispatch::run(bot, key, &doc, text).await { + Some(reply) => return Ok(Some(reply)), + None => return Ok(None), + } + } start_frame(&mut session, &doc, text, now) } IntentDecision::Ambiguous(cands) => { @@ -123,7 +131,36 @@ pub(super) async fn build_catalog(bot: &Bot) -> Result Vec { + let Some(rt) = bot.runtime.as_ref() else { + return Vec::new(); + }; + match springtale_runtime::operations::formations::list_formations(rt).await { + Ok(list) => list.into_iter().map(|f| f.name).collect(), + Err(e) => { + tracing::warn!(error = %e, "formation roster unavailable for chat slots"); + Vec::new() + } + } } /// Choose the right hand-off message: a security-framed one for secrets diff --git a/crates/springtale-bot/src/conversation/mod.rs b/crates/springtale-bot/src/conversation/mod.rs index 27eeb4c8..fb24da21 100644 --- a/crates/springtale-bot/src/conversation/mod.rs +++ b/crates/springtale-bot/src/conversation/mod.rs @@ -18,10 +18,12 @@ pub mod augment; pub mod catalog; pub mod deploy; pub mod dialogue; +pub mod dispatch; pub mod engine; pub mod error; pub mod nlg; pub mod nlu; +pub mod sentences; pub use deploy::{DeployError, RecipeDeployer, SharedDeployer}; pub use engine::{capability_reply, continue_active, try_start}; diff --git a/crates/springtale-bot/src/conversation/sentences/ar.yaml b/crates/springtale-bot/src/conversation/sentences/ar.yaml new file mode 100644 index 00000000..3ed34b88 --- /dev/null +++ b/crates/springtale-bot/src/conversation/sentences/ar.yaml @@ -0,0 +1,9 @@ +# STUB — ar sentence templates for the platform verbs (plan 5.4). +# +# Only `en` is populated today. This file exists so the layout is the +# one the plan asks for (one file per locale the UI speaks) and so a +# translator has somewhere to write. `verbs` being empty means the ar +# chat falls back to the English phrasings; nothing breaks, but nothing +# is translated either. +locale: ar +verbs: {} diff --git a/crates/springtale-bot/src/conversation/sentences/catalog.rs b/crates/springtale-bot/src/conversation/sentences/catalog.rs new file mode 100644 index 00000000..c4fd285d --- /dev/null +++ b/crates/springtale-bot/src/conversation/sentences/catalog.rs @@ -0,0 +1,137 @@ +//! Sentence templates, one file per locale. +//! +//! hassil's shape, in Rust: a verb is recognised by matching the +//! utterance against sentence templates such as `pause {formation}`, +//! where `{formation}` is a slot filled from the user's own formation +//! names — read at match time, never hard-coded. The templates live in +//! `{locale}.yaml` beside this file, one per language +//! `packages/ui/src/i18n/locales` speaks. +//! +//! Only `en` is populated today; the other seven are stubs, and a +//! locale with no phrases falls back to English. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use serde::Deserialize; + +/// The phrases for one verb. +#[derive(Debug, Clone, Deserialize)] +pub struct VerbSentences { + #[serde(default)] + pub phrases: Vec, +} + +/// One locale's sentence file. +#[derive(Debug, Clone, Deserialize)] +pub struct SentenceCatalog { + pub locale: String, + #[serde(default)] + pub verbs: HashMap, +} + +impl SentenceCatalog { + /// Phrases for a dotted verb name, falling back to English when this + /// locale has not been translated yet. + pub fn phrases(&self, verb: &str) -> &[String] { + match self.verbs.get(verb) { + Some(v) if !v.phrases.is_empty() => &v.phrases, + _ if self.locale != "en" => english().phrases(verb), + _ => &[], + } + } +} + +/// Locales shipped with a sentence file. `en` is real; the rest are +/// stubs awaiting translation. +pub const LOCALES: &[&str] = &["en", "ar", "es", "fr", "ja", "pt", "th", "tl"]; + +const EN: &str = include_str!("en.yaml"); +const AR: &str = include_str!("ar.yaml"); +const ES: &str = include_str!("es.yaml"); +const FR: &str = include_str!("fr.yaml"); +const JA: &str = include_str!("ja.yaml"); +const PT: &str = include_str!("pt.yaml"); +const TH: &str = include_str!("th.yaml"); +const TL: &str = include_str!("tl.yaml"); + +fn source(locale: &str) -> &'static str { + match locale { + "ar" => AR, + "es" => ES, + "fr" => FR, + "ja" => JA, + "pt" => PT, + "th" => TH, + "tl" => TL, + _ => EN, + } +} + +/// Parsed catalogues, built once per process. +fn cache() -> &'static HashMap { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| { + let mut map = HashMap::new(); + for locale in LOCALES { + match serde_yaml::from_str::(source(locale)) { + Ok(cat) => { + map.insert((*locale).to_owned(), cat); + } + Err(e) => { + tracing::error!(locale = %locale, error = %e, "sentence file failed to parse"); + } + } + } + map + }) +} + +/// The English catalogue — the fallback for every untranslated locale. +pub fn english() -> &'static SentenceCatalog { + static EMPTY: OnceLock = OnceLock::new(); + cache().get("en").unwrap_or_else(|| { + EMPTY.get_or_init(|| SentenceCatalog { + locale: "en".to_owned(), + verbs: HashMap::new(), + }) + }) +} + +/// The catalogue for a locale, English when the locale is unknown. +pub fn for_locale(locale: &str) -> &'static SentenceCatalog { + cache().get(locale).unwrap_or_else(english) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use springtale_runtime::operations::platform::platform_verbs; + + #[test] + fn test_every_locale_file_parses() { + for locale in LOCALES { + assert_eq!(for_locale(locale).locale, **locale, "locale {locale}"); + } + } + + #[test] + fn test_english_covers_every_platform_verb() { + for verb in platform_verbs() { + assert!( + !english().phrases(verb.name).is_empty(), + "verb `{}` has no English sentence template", + verb.name + ); + } + } + + #[test] + fn test_stub_locale_falls_back_to_english() { + assert_eq!( + for_locale("fr").phrases("formation.pause"), + english().phrases("formation.pause") + ); + } +} diff --git a/crates/springtale-bot/src/conversation/sentences/en.yaml b/crates/springtale-bot/src/conversation/sentences/en.yaml new file mode 100644 index 00000000..e7d0315d --- /dev/null +++ b/crates/springtale-bot/src/conversation/sentences/en.yaml @@ -0,0 +1,52 @@ +# English sentence templates for the platform verbs (plan 5.4). +# +# One block per verb in `springtale_runtime::operations::platform`. +# `phrases` are the ways a person says the verb; `{formation}` is a slot +# filled at match time from the live formation list, never hard-coded — +# the same shape hassil uses for Home Assistant's `{area}`. +# +# This file is the English catalogue the chat NLU was already carrying in +# Rust; the other seven locales sit beside it, one file per language the +# UI speaks (packages/ui/src/i18n/locales). +locale: en +verbs: + formation.list: + phrases: ["list formations", "show formations", "what formations", "which squads", "show the colony"] + formation.get: + phrases: ["show {formation}", "how is {formation}", "status of {formation}", "tell me about {formation}"] + formation.deploy: + phrases: ["deploy {formation}", "start {formation}", "launch {formation}", "send out {formation}"] + formation.pause: + phrases: ["pause {formation}", "hold {formation}", "stop {formation} for now", "freeze {formation}"] + formation.resume: + phrases: ["resume {formation}", "unpause {formation}", "carry on with {formation}", "start {formation} again"] + formation.dissolve: + phrases: ["dissolve {formation}", "disband {formation}", "break up {formation}", "shut down {formation}"] + formation.rally: + phrases: ["rally {formation}", "regroup {formation}", "focus {formation}", "call {formation} together"] + formation.intent: + phrases: ["change the intent of {formation}", "set {formation} to {intent}", "what is {formation} doing"] + formation.guard: + phrases: ["guard {formation}", "toggle the guard on {formation}", "protect {formation}"] + formation.add_member: + phrases: ["add {connector} to {formation}", "put {connector} in {formation}"] + formation.remove_member: + phrases: ["remove {connector} from {formation}", "take {connector} out of {formation}"] + approvals.list: + phrases: ["list approvals", "what is waiting for approval", "show the approval queue", "anything pending"] + approvals.approve: + phrases: ["approve {id}", "allow {id}", "say yes to {id}"] + approvals.deny: + phrases: ["deny {id}", "reject {id}", "say no to {id}"] + memory.audit: + phrases: ["audit memory", "what do you remember", "show what is stored", "memory audit"] + memory.compact: + phrases: ["compact memory", "trim memory", "forget the old messages"] + safety.get: + phrases: ["show safety settings", "what is the safety config", "safety status"] + safety.set: + phrases: ["set safety {key} to {value}", "change the safety setting {key}"] + ai.get: + phrases: ["which model are you using", "show the ai adapter", "what ai is configured"] + ai.set: + phrases: ["use {adapter}", "switch the model to {adapter}", "set the ai adapter to {adapter}"] diff --git a/crates/springtale-bot/src/conversation/sentences/es.yaml b/crates/springtale-bot/src/conversation/sentences/es.yaml new file mode 100644 index 00000000..a1981061 --- /dev/null +++ b/crates/springtale-bot/src/conversation/sentences/es.yaml @@ -0,0 +1,9 @@ +# STUB — es sentence templates for the platform verbs (plan 5.4). +# +# Only `en` is populated today. This file exists so the layout is the +# one the plan asks for (one file per locale the UI speaks) and so a +# translator has somewhere to write. `verbs` being empty means the es +# chat falls back to the English phrasings; nothing breaks, but nothing +# is translated either. +locale: es +verbs: {} diff --git a/crates/springtale-bot/src/conversation/sentences/fr.yaml b/crates/springtale-bot/src/conversation/sentences/fr.yaml new file mode 100644 index 00000000..5c194a74 --- /dev/null +++ b/crates/springtale-bot/src/conversation/sentences/fr.yaml @@ -0,0 +1,9 @@ +# STUB — fr sentence templates for the platform verbs (plan 5.4). +# +# Only `en` is populated today. This file exists so the layout is the +# one the plan asks for (one file per locale the UI speaks) and so a +# translator has somewhere to write. `verbs` being empty means the fr +# chat falls back to the English phrasings; nothing breaks, but nothing +# is translated either. +locale: fr +verbs: {} diff --git a/crates/springtale-bot/src/conversation/sentences/ja.yaml b/crates/springtale-bot/src/conversation/sentences/ja.yaml new file mode 100644 index 00000000..dd5cfacf --- /dev/null +++ b/crates/springtale-bot/src/conversation/sentences/ja.yaml @@ -0,0 +1,9 @@ +# STUB — ja sentence templates for the platform verbs (plan 5.4). +# +# Only `en` is populated today. This file exists so the layout is the +# one the plan asks for (one file per locale the UI speaks) and so a +# translator has somewhere to write. `verbs` being empty means the ja +# chat falls back to the English phrasings; nothing breaks, but nothing +# is translated either. +locale: ja +verbs: {} diff --git a/crates/springtale-bot/src/conversation/sentences/mod.rs b/crates/springtale-bot/src/conversation/sentences/mod.rs new file mode 100644 index 00000000..3246d6e9 --- /dev/null +++ b/crates/springtale-bot/src/conversation/sentences/mod.rs @@ -0,0 +1,5 @@ +//! Per-locale sentence templates for the platform verbs (plan 5.4). + +pub mod catalog; + +pub use catalog::{LOCALES, SentenceCatalog, english, for_locale}; diff --git a/crates/springtale-bot/src/conversation/sentences/pt.yaml b/crates/springtale-bot/src/conversation/sentences/pt.yaml new file mode 100644 index 00000000..c2d22c17 --- /dev/null +++ b/crates/springtale-bot/src/conversation/sentences/pt.yaml @@ -0,0 +1,9 @@ +# STUB — pt sentence templates for the platform verbs (plan 5.4). +# +# Only `en` is populated today. This file exists so the layout is the +# one the plan asks for (one file per locale the UI speaks) and so a +# translator has somewhere to write. `verbs` being empty means the pt +# chat falls back to the English phrasings; nothing breaks, but nothing +# is translated either. +locale: pt +verbs: {} diff --git a/crates/springtale-bot/src/conversation/sentences/th.yaml b/crates/springtale-bot/src/conversation/sentences/th.yaml new file mode 100644 index 00000000..d65cf79c --- /dev/null +++ b/crates/springtale-bot/src/conversation/sentences/th.yaml @@ -0,0 +1,9 @@ +# STUB — th sentence templates for the platform verbs (plan 5.4). +# +# Only `en` is populated today. This file exists so the layout is the +# one the plan asks for (one file per locale the UI speaks) and so a +# translator has somewhere to write. `verbs` being empty means the th +# chat falls back to the English phrasings; nothing breaks, but nothing +# is translated either. +locale: th +verbs: {} diff --git a/crates/springtale-bot/src/conversation/sentences/tl.yaml b/crates/springtale-bot/src/conversation/sentences/tl.yaml new file mode 100644 index 00000000..e725adab --- /dev/null +++ b/crates/springtale-bot/src/conversation/sentences/tl.yaml @@ -0,0 +1,9 @@ +# STUB — tl sentence templates for the platform verbs (plan 5.4). +# +# Only `en` is populated today. This file exists so the layout is the +# one the plan asks for (one file per locale the UI speaks) and so a +# translator has somewhere to write. `verbs` being empty means the tl +# chat falls back to the English phrasings; nothing breaks, but nothing +# is translated either. +locale: tl +verbs: {} diff --git a/crates/springtale-bot/src/handler/builtin/ai.rs b/crates/springtale-bot/src/handler/builtin/ai.rs new file mode 100644 index 00000000..118306be --- /dev/null +++ b/crates/springtale-bot/src/handler/builtin/ai.rs @@ -0,0 +1,72 @@ +//! `/ai` — read and change the model configuration (plan 5.4). +//! +//! Colony-level only. Per-formation and per-agent adapters stay in the +//! settings surfaces that scope them (see the product model): chat sets +//! the default every bot inherits. + +use async_trait::async_trait; +use springtale_runtime::operations::config::{AiTarget, configure_ai_adapter, get_config}; + +use crate::error::BotError; +use crate::handler::registry::{Handler, HandlerContext, HandlerResult, runtime_or_err}; + +pub struct AiHandler; + +const USAGE: &str = "Usage: /ai get | /ai set "; + +#[async_trait] +impl Handler for AiHandler { + async fn handle(&self, args: &str, ctx: &HandlerContext) -> Result { + let rt = runtime_or_err(ctx)?; + let parts: Vec<&str> = args.split_whitespace().collect(); + let response = match parts.as_slice() { + [] | ["get"] => { + let cfg = get_config(&*ctx.store, &AiTarget::Colony.key()) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + let adapter = cfg + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("none (NoopAdapter)"); + let model = cfg.get("model").and_then(|v| v.as_str()).unwrap_or("—"); + format!("AI adapter: {adapter} · model: {model}") + } + ["set", adapter] => { + let adapter = match *adapter { + "none" | "noop" => "noop", + a @ ("ollama" | "openai" | "anthropic") => a, + other => { + return Ok(HandlerResult { + response: format!("'{other}' is not an adapter.\n{USAGE}"), + }); + } + }; + // Keep whatever else is configured (model, host, key + // reference) and change only the adapter type. + let mut cfg = get_config(&*ctx.store, &AiTarget::Colony.key()) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + if !cfg.is_object() { + cfg = serde_json::json!({}); + } + if let Some(map) = cfg.as_object_mut() { + map.insert("type".to_owned(), serde_json::json!(adapter)); + } + configure_ai_adapter(rt, AiTarget::Colony, cfg) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("AI adapter is now {adapter}.") + } + _ => USAGE.to_owned(), + }; + Ok(HandlerResult { response }) + } + + fn description(&self) -> &str { + "Read or change the AI adapter" + } + + fn is_builtin(&self) -> bool { + true + } +} diff --git a/crates/springtale-bot/src/handler/builtin/approvals.rs b/crates/springtale-bot/src/handler/builtin/approvals.rs new file mode 100644 index 00000000..53bdc2b4 --- /dev/null +++ b/crates/springtale-bot/src/handler/builtin/approvals.rs @@ -0,0 +1,79 @@ +//! `/approvals` — see and answer the approval queue from chat (plan 5.4). +//! +//! The same gate the inline approval card resolves +//! (`capability_bridge.approval_gate()`), so a request answered by name +//! here and one answered by tapping the card land identically. + +use async_trait::async_trait; + +use crate::error::BotError; +use crate::handler::registry::{Handler, HandlerContext, HandlerResult}; + +pub struct ApprovalsHandler; + +const USAGE: &str = "Usage: /approvals [list|approve |deny ]"; + +#[async_trait] +impl Handler for ApprovalsHandler { + async fn handle(&self, args: &str, ctx: &HandlerContext) -> Result { + let Some(gate) = ctx.capability_bridge.approval_gate() else { + return Ok(HandlerResult { + response: "No approval gate is wired on this instance.".to_owned(), + }); + }; + let parts: Vec<&str> = args.split_whitespace().collect(); + let response = match parts.as_slice() { + [] | ["list"] => { + let pending = gate.pending().await; + if pending.is_empty() { + "Nothing is waiting for approval.".to_owned() + } else { + let rows: Vec = pending + .iter() + .map(|r| { + format!("• {} — {} wants {:?}", r.id, r.connector_name, r.capability) + }) + .collect(); + rows.join("\n") + } + } + ["approve", id] | ["deny", id] => { + let approved = parts[0] == "approve"; + let uuid = uuid::Uuid::parse_str(id) + .map_err(|_| BotError::Handler(format!("'{id}' is not an approval id")))?; + let decision = if approved { + springtale_runtime::approval::ApprovalDecision::Approved { + approver: format!("owner ({})", ctx.user_id), + approved_at: chrono::Utc::now(), + } + } else { + springtale_runtime::approval::ApprovalDecision::Denied { + reason: "denied from chat".to_owned(), + denied_at: chrono::Utc::now(), + } + }; + match gate + .resolve( + springtale_runtime::approval::ApprovalRequestId(uuid), + decision, + ) + .await + { + Ok(()) if approved => "Approved — running it now.".to_owned(), + Ok(()) => "Denied — nothing was run.".to_owned(), + Err(_) => "That approval already closed (or expired).".to_owned(), + } + } + _ => USAGE.to_owned(), + }; + Ok(HandlerResult { response }) + } + + fn description(&self) -> &str { + "See and answer the approval queue" + } + + fn is_builtin(&self) -> bool { + true + } +} diff --git a/crates/springtale-bot/src/handler/builtin/formation.rs b/crates/springtale-bot/src/handler/builtin/formation.rs new file mode 100644 index 00000000..2bf81afc --- /dev/null +++ b/crates/springtale-bot/src/handler/builtin/formation.rs @@ -0,0 +1,143 @@ +//! `/formation` — steer a formation from chat (plan 5.4). +//! +//! The four orchestration groups plus inspection, and nothing else: +//! there is deliberately no `assign` sub-command. You steer a +//! formation; you never hand work to a named member (the drum rule). +//! Every branch goes through an existing runtime operation. + +use async_trait::async_trait; +use springtale_runtime::operations::formations as f; + +use super::resolve::resolve_formation; +use crate::error::BotError; +use crate::handler::registry::{Handler, HandlerContext, HandlerResult, runtime_or_err}; + +pub struct FormationHandler; + +const USAGE: &str = "Usage: /formation [list|get|deploy|pause|resume|dissolve|rally|intent|guard|add|rm] [value]"; + +#[async_trait] +impl Handler for FormationHandler { + async fn handle(&self, args: &str, ctx: &HandlerContext) -> Result { + let rt = runtime_or_err(ctx)?; + let parts: Vec<&str> = args.split_whitespace().collect(); + let response = match parts.as_slice() { + [] | ["list"] => { + let list = f::list_formations(rt) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + if list.is_empty() { + "No formations yet.".to_owned() + } else { + let rows: Vec = list + .iter() + .map(|x| { + format!( + "• {} — {} · {} · {} member(s) · {}", + x.name, x.status, x.intent, x.member_count, x.momentum_label + ) + }) + .collect(); + rows.join("\n") + } + } + ["get", rest @ ..] => { + let (id, name) = resolve_formation(rt, &rest.join(" ")).await?; + let d = f::get_formation(rt, &id) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!( + "{name} — {} · intent {} · momentum {} · members: {}", + d.info.status, + d.info.intent, + d.info.momentum_label, + if d.info.members.is_empty() { + "none".to_owned() + } else { + d.info.members.join(", ") + } + ) + } + ["deploy", rest @ ..] => { + let (id, name) = resolve_formation(rt, &rest.join(" ")).await?; + f::deploy_formation(rt, &id) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("{name} deployed.") + } + ["pause", rest @ ..] => { + let (id, name) = resolve_formation(rt, &rest.join(" ")).await?; + f::pause_formation(rt, &id) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("{name} paused.") + } + ["resume", rest @ ..] => { + let (id, name) = resolve_formation(rt, &rest.join(" ")).await?; + f::resume_formation(rt, &id) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("{name} resumed.") + } + ["dissolve", rest @ ..] => { + let (id, name) = resolve_formation(rt, &rest.join(" ")).await?; + f::dissolve_formation(rt, &id) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("{name} dissolved.") + } + ["rally", rest @ ..] => { + let (id, name) = resolve_formation(rt, &rest.join(" ")).await?; + f::rally_formation(rt, &id) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("Rally sent to {name}.") + } + ["intent", name] => { + let (id, name) = resolve_formation(rt, name).await?; + let next = f::cycle_intent(rt, &id) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("{name}'s intent is now {next}.") + } + ["intent", name, value] => { + let (id, name) = resolve_formation(rt, name).await?; + f::update_intent(rt, &id, value) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("{name}'s intent set to {value}.") + } + ["guard", rest @ ..] => { + let (id, name) = resolve_formation(rt, &rest.join(" ")).await?; + let on = springtale_runtime::operations::config::toggle_formation_guard(rt, &id) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("{name}'s guard is {}.", if on { "on" } else { "off" }) + } + ["add", name, connector] => { + let (id, name) = resolve_formation(rt, name).await?; + f::add_member(rt, &id, connector) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("Added {connector} to {name}.") + } + ["rm", name, connector] => { + let (id, name) = resolve_formation(rt, name).await?; + f::remove_member(rt, &id, connector) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("Removed {connector} from {name}.") + } + _ => USAGE.to_owned(), + }; + Ok(HandlerResult { response }) + } + + fn description(&self) -> &str { + "Steer a formation — list, get, deploy, pause, resume, dissolve, rally, intent, guard, add, rm" + } + + fn is_builtin(&self) -> bool { + true + } +} diff --git a/crates/springtale-bot/src/handler/builtin/memory.rs b/crates/springtale-bot/src/handler/builtin/memory.rs new file mode 100644 index 00000000..1e94215f --- /dev/null +++ b/crates/springtale-bot/src/handler/builtin/memory.rs @@ -0,0 +1,53 @@ +//! `/memory` — audit and compact what the bot remembers (plan 5.4). + +use async_trait::async_trait; +use springtale_runtime::operations::memory; + +use crate::error::BotError; +use crate::handler::registry::{Handler, HandlerContext, HandlerResult}; + +pub struct MemoryHandler; + +/// Rows kept per session by `/memory compact` when no limit is given. +const DEFAULT_KEEP: usize = 50; + +const USAGE: &str = "Usage: /memory [audit|compact [rows-to-keep]]"; + +#[async_trait] +impl Handler for MemoryHandler { + async fn handle(&self, args: &str, ctx: &HandlerContext) -> Result { + let parts: Vec<&str> = args.split_whitespace().collect(); + let response = match parts.as_slice() { + [] | ["audit"] => { + let audit = memory::audit_memory(&*ctx.store) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!( + "{} — {} session(s) on record.", + audit.total_memory_note, + audit.sessions.len() + ) + } + ["compact"] | ["compact", _] => { + let keep = parts + .get(1) + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_KEEP); + let deleted = memory::compact_memory(&*ctx.store, keep) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("Compacted to {keep} rows per session — {deleted} entries removed.") + } + _ => USAGE.to_owned(), + }; + Ok(HandlerResult { response }) + } + + fn description(&self) -> &str { + "Audit or compact stored memory" + } + + fn is_builtin(&self) -> bool { + true + } +} diff --git a/crates/springtale-bot/src/handler/builtin/mod.rs b/crates/springtale-bot/src/handler/builtin/mod.rs index 43f2cb25..62869ab3 100644 --- a/crates/springtale-bot/src/handler/builtin/mod.rs +++ b/crates/springtale-bot/src/handler/builtin/mod.rs @@ -4,34 +4,45 @@ //! its `Handler` impl. This module re-exports them and provides //! [`register_builtins`] and [`BUILTIN_COMMANDS`] for the rest of the crate. +mod ai; mod alias; +mod approvals; mod connectors; mod delrule; mod disable; mod enable; mod events; +mod formation; mod help; +mod memory; mod newrule; mod pair; mod prefs; +mod resolve; mod rules; mod run; +mod safety; mod send; mod status; mod toggle; +pub use ai::AiHandler; pub use alias::AliasHandler; +pub use approvals::ApprovalsHandler; pub use connectors::ConnectorsHandler; pub use delrule::DelRuleHandler; pub use disable::DisableHandler; pub use enable::EnableHandler; pub use events::EventsHandler; +pub use formation::FormationHandler; pub use help::HelpHandler; +pub use memory::MemoryHandler; pub use newrule::NewRuleHandler; pub use pair::PairHandler; pub use prefs::PrefsHandler; pub use rules::RulesHandler; pub use run::RunHandler; +pub use safety::SafetyHandler; pub use send::SendHandler; pub use status::StatusHandler; pub use toggle::ToggleHandler; @@ -56,6 +67,12 @@ pub const BUILTIN_COMMANDS: &[&str] = &[ "newrule", "delrule", "run", + // Plan 5.4 — the platform verbs, reachable from chat. + "formation", + "approvals", + "memory", + "safety", + "ai", ]; /// Register every builtin handler into a fresh registry. @@ -75,5 +92,11 @@ pub fn register_builtins(registry: &mut HandlerRegistry) -> Result<(), BotError> registry.register("newrule".into(), Box::new(NewRuleHandler))?; registry.register("delrule".into(), Box::new(DelRuleHandler))?; registry.register("run".into(), Box::new(RunHandler))?; + // Plan 5.4 — chat runs the platform, under the drum rule. + registry.register("formation".into(), Box::new(FormationHandler))?; + registry.register("approvals".into(), Box::new(ApprovalsHandler))?; + registry.register("memory".into(), Box::new(MemoryHandler))?; + registry.register("safety".into(), Box::new(SafetyHandler))?; + registry.register("ai".into(), Box::new(AiHandler))?; Ok(()) } diff --git a/crates/springtale-bot/src/handler/builtin/resolve.rs b/crates/springtale-bot/src/handler/builtin/resolve.rs new file mode 100644 index 00000000..ec4b28a8 --- /dev/null +++ b/crates/springtale-bot/src/handler/builtin/resolve.rs @@ -0,0 +1,110 @@ +//! Formation name resolution for chat commands. +//! +//! Chat names a formation the way a person does — "research", "Research +//! Squad" — while the runtime keys on ids. Resolution is exact first, +//! then case-insensitive prefix, and an ambiguous prefix is an error +//! rather than a guess: pausing the wrong formation is not recoverable +//! by re-reading a chat line. + +use springtale_runtime::operations::formations::{FormationInfo, list_formations}; +use springtale_runtime::state::RuntimeState; + +use crate::error::BotError; + +/// Resolve a user-typed formation name to `(id, name)`. +pub async fn resolve_formation( + state: &RuntimeState, + query: &str, +) -> Result<(String, String), BotError> { + let formations = list_formations(state) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + match pick(&formations, query) { + Ok(f) => Ok((f.id.clone(), f.name.clone())), + Err(e) => Err(e), + } +} + +/// Exact match, then case-insensitive prefix; ambiguity is an error. +pub fn pick<'a>( + formations: &'a [FormationInfo], + query: &str, +) -> Result<&'a FormationInfo, BotError> { + let q = query.trim(); + if q.is_empty() { + return Err(BotError::Handler("name a formation".to_owned())); + } + if let Some(exact) = formations.iter().find(|f| f.name == q) { + return Ok(exact); + } + let lower = q.to_lowercase(); + let hits: Vec<&FormationInfo> = formations + .iter() + .filter(|f| f.name.to_lowercase().starts_with(&lower) || f.id == q) + .collect(); + match hits.as_slice() { + [one] => Ok(one), + [] => Err(BotError::Handler(format!("no formation matches '{q}'"))), + many => { + let names: Vec<&str> = many.iter().map(|f| f.name.as_str()).collect(); + Err(BotError::Handler(format!( + "'{q}' matches {} formations ({}) — say the whole name", + many.len(), + names.join(", ") + ))) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + fn info(id: &str, name: &str) -> FormationInfo { + FormationInfo { + id: id.to_owned(), + name: name.to_owned(), + intent: "reconnoiter".to_owned(), + status: "active".to_owned(), + member_count: 0, + operational_count: 0, + members: vec![], + momentum_tier: "Cold".to_owned(), + momentum_label: "Cold".to_owned(), + momentum_consecutive_successes: 0, + momentum_interference_count: 0, + momentum_successes_to_next_tier: None, + capabilities: vec![], + guard_status: "--".to_owned(), + guard_engaged: false, + rally_tokens: 0, + rally_max: 0, + } + } + + #[test] + fn test_pick_exact_name_wins() { + let f = vec![info("1", "Research"), info("2", "Research Squad")]; + assert_eq!(pick(&f, "Research").expect("exact").id, "1"); + } + + #[test] + fn test_pick_case_insensitive_prefix_resolves() { + let f = vec![info("1", "Research Squad"), info("2", "Watchtower")]; + assert_eq!(pick(&f, "research").expect("prefix").id, "1"); + } + + #[test] + fn test_pick_ambiguous_prefix_errors() { + let f = vec![info("1", "Research Squad"), info("2", "Research Team")]; + let err = pick(&f, "res").expect_err("ambiguous"); + assert!(err.to_string().contains("matches 2 formations")); + } + + #[test] + fn test_pick_unknown_name_errors() { + let f = vec![info("1", "Research Squad")]; + assert!(pick(&f, "nope").is_err()); + } +} diff --git a/crates/springtale-bot/src/handler/builtin/safety.rs b/crates/springtale-bot/src/handler/builtin/safety.rs new file mode 100644 index 00000000..923daba1 --- /dev/null +++ b/crates/springtale-bot/src/handler/builtin/safety.rs @@ -0,0 +1,79 @@ +//! `/safety` — read and change the safety configuration (plan 5.4). +//! +//! A safety setting is the one thing a coerced user must not change by +//! accident, so a write needs an explicit `--confirm` argument. Reads +//! never do. + +use async_trait::async_trait; +use springtale_runtime::operations::safety; + +use crate::error::BotError; +use crate::handler::registry::{Handler, HandlerContext, HandlerResult, runtime_or_err}; + +pub struct SafetyHandler; + +const USAGE: &str = "Usage: /safety get | /safety set --confirm"; + +#[async_trait] +impl Handler for SafetyHandler { + async fn handle(&self, args: &str, ctx: &HandlerContext) -> Result { + let rt = runtime_or_err(ctx)?; + let parts: Vec<&str> = args.split_whitespace().collect(); + let response = match parts.as_slice() { + [] | ["get"] => { + let cfg = safety::get_safety_config(rt) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!( + "window-title: {} · auto-lock-minutes: {} · content-protected: {} · panic-taps: {} · disguise: {}", + cfg.window_title, + cfg.auto_lock_minutes, + cfg.content_protected, + cfg.panic_tap_count, + if cfg.disguise_active { "on" } else { "off" } + ) + } + ["set", key, value, "--confirm"] => { + let mut cfg = safety::get_safety_config(rt) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + match *key { + "window-title" => cfg.window_title = (*value).to_owned(), + "auto-lock-minutes" => { + cfg.auto_lock_minutes = value + .parse() + .map_err(|_| BotError::Handler("minutes must be a number".to_owned()))? + } + "content-protected" => { + cfg.content_protected = matches!(*value, "true" | "on" | "yes") + } + "panic-taps" => { + cfg.panic_tap_count = value + .parse() + .map_err(|_| BotError::Handler("taps must be a number".to_owned()))? + } + other => { + return Ok(HandlerResult { + response: format!("'{other}' is not a safety setting.\n{USAGE}"), + }); + } + } + safety::save_safety_config(rt, cfg) + .await + .map_err(|e| BotError::Handler(e.to_string()))?; + format!("Safety setting {key} is now {value}.") + } + ["set", ..] => "Safety changes need an explicit --confirm at the end.".to_owned(), + _ => USAGE.to_owned(), + }; + Ok(HandlerResult { response }) + } + + fn description(&self) -> &str { + "Read or change the safety configuration" + } + + fn is_builtin(&self) -> bool { + true + } +} diff --git a/crates/springtale-bot/src/handler/registry.rs b/crates/springtale-bot/src/handler/registry.rs index 1675dee8..5d1fc0ce 100644 --- a/crates/springtale-bot/src/handler/registry.rs +++ b/crates/springtale-bot/src/handler/registry.rs @@ -37,6 +37,21 @@ pub struct HandlerContext { /// scoped dispatch via `runtime::event_loop` sets it to the calling /// formation's `MomentumTier` mapped through `momentum_to_wasm_tier`. pub formation_tier: Option, + /// Shared runtime state — what lets a chat command steer a + /// formation, answer an approval, audit memory, or change safety + /// and model configuration (plan 5.4). `None` in headless and test + /// bots, which have no `RuntimeState`; the platform handlers say so + /// rather than pretending to act. + pub runtime: Option, +} + +/// Borrow the runtime state, or explain that this bot has none. +pub fn runtime_or_err( + ctx: &HandlerContext, +) -> Result<&springtale_runtime::state::RuntimeState, BotError> { + ctx.runtime.as_ref().ok_or_else(|| { + BotError::NotInitialized("this bot runs without a platform runtime".to_owned()) + }) } /// Result returned by a handler. diff --git a/crates/springtale-bot/src/runtime/handlers.rs b/crates/springtale-bot/src/runtime/handlers.rs index 66450498..9b752275 100644 --- a/crates/springtale-bot/src/runtime/handlers.rs +++ b/crates/springtale-bot/src/runtime/handlers.rs @@ -290,6 +290,7 @@ pub(super) async fn handle_incoming_message( // Formation-scoped tick dispatch sets this to the // caller's mapped `MomentumTier` before invoking. formation_tier: None, + runtime: bot.runtime.clone(), }; match handler.handle(&args, &ctx).await { diff --git a/crates/springtale-bot/src/runtime/lifecycle.rs b/crates/springtale-bot/src/runtime/lifecycle.rs index f16ac34a..b4b6e6d1 100644 --- a/crates/springtale-bot/src/runtime/lifecycle.rs +++ b/crates/springtale-bot/src/runtime/lifecycle.rs @@ -154,6 +154,11 @@ pub struct Bot { /// engine degrades to a graceful "can't deploy here". See /// `crate::conversation::deploy`. pub(crate) recipe_deployer: Option, + /// Shared runtime state (plan 5.4). `Some` in the daemon / desktop, + /// `None` in headless / CLI / test builds. Chat's platform verbs + /// (`/formation`, `/approvals`, `/memory`, `/safety`, `/ai`) and the + /// platform intent documents read it. + pub(crate) runtime: Option, } impl Bot { @@ -218,6 +223,10 @@ pub struct BotBuilder { /// Optional conversational-setup deploy port (daemon / desktop wire /// a `RuntimeState`-backed impl; tests + headless leave None). recipe_deployer: Option, + /// Optional shared runtime state (plan 5.4). The daemon/desktop pass + /// `RuntimeState` so chat commands reach the same operations every + /// other surface calls; tests + headless leave None. + runtime: Option, } impl BotBuilder { @@ -245,9 +254,18 @@ impl BotBuilder { utterance_defs: None, cadence_tick: None, recipe_deployer: None, + runtime: None, } } + /// Inject the shared runtime state so chat can run the platform + /// verbs (plan 5.4). Without it `/formation`, `/safety` and `/ai` + /// report that this bot has no runtime instead of acting. + pub fn runtime(mut self, runtime: springtale_runtime::state::RuntimeState) -> Self { + self.runtime = Some(runtime); + self + } + /// Inject the conversational-setup deploy port. The daemon / desktop /// pass an impl that holds their `RuntimeState` so the chat bot can /// apply + schedule a recipe the user configured by chatting. @@ -603,6 +621,7 @@ impl BotBuilder { formation_gossip: self.formation_gossip, knowledge_store: self.knowledge_store, recipe_deployer: self.recipe_deployer, + runtime: self.runtime, }) } } diff --git a/crates/springtale-runtime/src/operations/mod.rs b/crates/springtale-runtime/src/operations/mod.rs index 9dc7c509..1487178d 100644 --- a/crates/springtale-runtime/src/operations/mod.rs +++ b/crates/springtale-runtime/src/operations/mod.rs @@ -23,6 +23,7 @@ pub mod memory; pub mod migrate; pub mod onboarding; pub mod pairing; +pub mod platform; pub mod preflight; pub mod preview; pub mod recipes; diff --git a/crates/springtale-runtime/src/operations/platform/mod.rs b/crates/springtale-runtime/src/operations/platform/mod.rs new file mode 100644 index 00000000..ae5130e3 --- /dev/null +++ b/crates/springtale-runtime/src/operations/platform/mod.rs @@ -0,0 +1,10 @@ +//! Platform verbs — the set of things chat may ask the platform to do. +//! +//! Plan 5.4. Chat gets the four orchestration verb groups plus +//! inspection, and never an assign verb (the drum rule). + +pub mod registry; +pub mod verb; + +pub use registry::{find_verb, platform_verbs, verb_commands}; +pub use verb::{PlatformVerb, VerbGroup}; diff --git a/crates/springtale-runtime/src/operations/platform/registry.rs b/crates/springtale-runtime/src/operations/platform/registry.rs new file mode 100644 index 00000000..44d8719f --- /dev/null +++ b/crates/springtale-runtime/src/operations/platform/registry.rs @@ -0,0 +1,254 @@ +//! The one registry of platform verbs. +//! +//! Chat, the NLU catalogue, and (plan 2.3) the AI tool list all read +//! this list. Adding a verb here is the only way a new thing becomes +//! sayable in chat, which is what makes the drum-rule test below +//! meaningful. + +use super::verb::{PlatformVerb, VerbGroup}; + +/// Every verb chat may use, in help order. +const VERBS: &[PlatformVerb] = &[ + // ── formation ──────────────────────────────────────────────────── + PlatformVerb { + name: "formation.list", + description: "List the formations and their status.", + group: VerbGroup::Inspection, + read_only: true, + args: &[], + }, + PlatformVerb { + name: "formation.get", + description: "Show one formation: intent, momentum, members.", + group: VerbGroup::Inspection, + read_only: true, + args: &["formation"], + }, + PlatformVerb { + name: "formation.deploy", + description: "Deploy a formation so its members start working.", + group: VerbGroup::Intervention, + read_only: false, + args: &["formation"], + }, + PlatformVerb { + name: "formation.pause", + description: "Pause a formation, holding its members where they are.", + group: VerbGroup::Intervention, + read_only: false, + args: &["formation"], + }, + PlatformVerb { + name: "formation.resume", + description: "Resume a paused formation.", + group: VerbGroup::Intervention, + read_only: false, + args: &["formation"], + }, + PlatformVerb { + name: "formation.dissolve", + description: "Dissolve a formation and release its members.", + group: VerbGroup::Intervention, + read_only: false, + args: &["formation"], + }, + PlatformVerb { + name: "formation.rally", + description: "Send a rally to a formation, redirecting its attention.", + group: VerbGroup::Intervention, + read_only: false, + args: &["formation"], + }, + PlatformVerb { + name: "formation.intent", + description: "Cycle or set a formation's intent.", + group: VerbGroup::Intent, + read_only: false, + args: &["formation", "intent"], + }, + PlatformVerb { + name: "formation.guard", + description: "Toggle a formation's guard.", + group: VerbGroup::Constraints, + read_only: false, + args: &["formation"], + }, + PlatformVerb { + name: "formation.add_member", + description: "Add a connector to a formation as a member.", + group: VerbGroup::Composition, + read_only: false, + args: &["formation", "connector"], + }, + PlatformVerb { + name: "formation.remove_member", + description: "Remove a member connector from a formation.", + group: VerbGroup::Composition, + read_only: false, + args: &["formation", "connector"], + }, + // ── approvals ──────────────────────────────────────────────────── + PlatformVerb { + name: "approvals.list", + description: "List the approval requests still waiting.", + group: VerbGroup::Inspection, + read_only: true, + args: &[], + }, + PlatformVerb { + name: "approvals.approve", + description: "Approve a pending request by id.", + group: VerbGroup::Intervention, + read_only: false, + args: &["id"], + }, + PlatformVerb { + name: "approvals.deny", + description: "Deny a pending request by id.", + group: VerbGroup::Intervention, + read_only: false, + args: &["id"], + }, + // ── memory ─────────────────────────────────────────────────────── + PlatformVerb { + name: "memory.audit", + description: "Audit what the bot remembers and how it is encrypted.", + group: VerbGroup::Inspection, + read_only: true, + args: &[], + }, + PlatformVerb { + name: "memory.compact", + description: "Compact stored memory, dropping rows past the retention window.", + group: VerbGroup::Intervention, + read_only: false, + args: &[], + }, + // ── safety ─────────────────────────────────────────────────────── + PlatformVerb { + name: "safety.get", + description: "Show the safety configuration.", + group: VerbGroup::Inspection, + read_only: true, + args: &[], + }, + PlatformVerb { + name: "safety.set", + description: "Change a safety setting (requires --confirm).", + group: VerbGroup::Constraints, + read_only: false, + args: &["key", "value"], + }, + // ── model configuration ────────────────────────────────────────── + PlatformVerb { + name: "ai.get", + description: "Show the configured AI adapter and model.", + group: VerbGroup::Inspection, + read_only: true, + args: &[], + }, + PlatformVerb { + name: "ai.set", + description: "Set the AI adapter (none, ollama, openai, anthropic).", + group: VerbGroup::Constraints, + read_only: false, + args: &["adapter"], + }, +]; + +/// The canonical platform verb list. +pub fn platform_verbs() -> &'static [PlatformVerb] { + VERBS +} + +/// Look one verb up by dotted name. +pub fn find_verb(name: &str) -> Option<&'static PlatformVerb> { + VERBS.iter().find(|v| v.name == name) +} + +/// The distinct chat command names (`formation`, `approvals`, …). +pub fn verb_commands() -> Vec<&'static str> { + let mut out: Vec<&'static str> = Vec::new(); + for v in VERBS { + if !out.contains(&v.command()) { + out.push(v.command()); + } + } + out +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + /// The drum rule: you steer a formation, you never hand work to a + /// named member. The CLI asserts this over its clap tree + /// (`apps/springtale-cli/src/cli.rs`); chat asserts it here, over + /// the registry every chat surface reads. + #[test] + fn test_no_chat_verb_assigns_work_to_a_named_member() { + let offenders: Vec<&str> = platform_verbs() + .iter() + .filter(|v| { + v.name.contains("assign") + || v.description.to_lowercase().contains("assign") + || v.args.contains(&"member") + || v.args.contains(&"agent") + }) + .map(|v| v.name) + .collect(); + assert!( + offenders.is_empty(), + "assign verb(s) present, drum rule violated: {offenders:?}" + ); + } + + /// Every chat verb belongs to one of the four orchestration groups + /// or to read-only inspection — and nothing else. + #[test] + fn test_chat_verbs_stay_in_the_four_groups_plus_inspection() { + for v in platform_verbs() { + assert!( + matches!( + v.group, + VerbGroup::Inspection + | VerbGroup::Composition + | VerbGroup::Intent + | VerbGroup::Constraints + | VerbGroup::Intervention + ), + "verb `{}` is outside the four groups", + v.name + ); + // Inspection is exactly the read-only set. + assert_eq!( + v.read_only, + v.group == VerbGroup::Inspection, + "verb `{}` disagrees with its group about being read-only", + v.name + ); + } + } + + #[test] + fn test_verb_names_are_unique_and_dotted() { + let mut seen = std::collections::HashSet::new(); + for v in platform_verbs() { + assert!(v.name.contains('.'), "verb `{}` is not dotted", v.name); + assert!(seen.insert(v.name), "duplicate verb `{}`", v.name); + } + } + + #[test] + fn test_input_schema_lists_every_argument() { + let verb = find_verb("formation.add_member").expect("verb exists"); + let schema = verb.input_schema(); + let props = schema + .get("properties") + .and_then(|p| p.as_object()) + .expect("object schema"); + assert!(props.contains_key("formation")); + assert!(props.contains_key("connector")); + } +} diff --git a/crates/springtale-runtime/src/operations/platform/verb.rs b/crates/springtale-runtime/src/operations/platform/verb.rs new file mode 100644 index 00000000..45b402af --- /dev/null +++ b/crates/springtale-runtime/src/operations/platform/verb.rs @@ -0,0 +1,103 @@ +//! The platform-verb descriptor. +//! +//! One value per thing chat is allowed to ask the platform to do. The +//! same registry backs the chat command handlers, the NLU intent +//! documents, and (later, plan 2.3) the AI tool list, so a verb that +//! exists on one surface exists on all of them with the same name, +//! description, schema, and read-only classification. + +use serde::Serialize; +use serde_json::Value; + +/// Which of the orchestration groups a verb belongs to. +/// +/// The drum rule (`docs/intended-arch/COOPERATION.pdf`): you steer a +/// formation, you never hand work to a named member. That is why there +/// is no `Assignment` variant — composition adds and removes members, +/// it does not give one of them a task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum VerbGroup { + /// Read-only. Never needs approval. + Inspection, + /// Who is in the formation (add / remove a member). + Composition, + /// What the formation is trying to do. + Intent, + /// What the formation is allowed to do (guard, safety, model). + Constraints, + /// Direct intervention in a running formation. + Intervention, +} + +impl VerbGroup { + pub fn as_str(&self) -> &'static str { + match self { + Self::Inspection => "inspection", + Self::Composition => "composition", + Self::Intent => "intent", + Self::Constraints => "constraints", + Self::Intervention => "intervention", + } + } +} + +/// One platform verb, as every surface sees it. +#[derive(Debug, Clone, Serialize)] +pub struct PlatformVerb { + /// Dotted name, e.g. `formation.pause`. The chat command is the + /// segment before the dot; the sub-command is the segment after. + pub name: &'static str, + /// One line, shown in `/help` and used as the AI tool description. + pub description: &'static str, + /// The orchestration group this verb belongs to. + pub group: VerbGroup, + /// True only when the verb purely retrieves state. Read-only verbs + /// run without an approval; everything else goes through the same + /// gate as a connector write. + pub read_only: bool, + /// Names of the arguments the verb takes, in order. `formation` + /// means "a formation name" and is the slot the NLU gazetteer fills + /// from the live formation list. + pub args: &'static [&'static str], +} + +impl PlatformVerb { + /// Chat command name — `formation.pause` → `formation`. + pub fn command(&self) -> &'static str { + match self.name.split_once('.') { + Some((head, _)) => head, + None => self.name, + } + } + + /// Sub-command — `formation.pause` → `pause`. + pub fn sub(&self) -> &'static str { + match self.name.split_once('.') { + Some((_, tail)) => tail, + None => "", + } + } + + /// True when the verb's first argument is a formation name. + pub fn takes_formation(&self) -> bool { + self.args.first() == Some(&"formation") + } + + /// JSON Schema for the verb's arguments — what the AI tool list + /// (plan 2.3) publishes and what the contract check reads. + pub fn input_schema(&self) -> Value { + let mut props = serde_json::Map::new(); + for arg in self.args { + props.insert( + (*arg).to_owned(), + serde_json::json!({ "type": "string", "description": arg }), + ); + } + serde_json::json!({ + "type": "object", + "properties": Value::Object(props), + "required": self.args, + }) + } +} diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index b1ecfe2a..fc10187d 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -2,6 +2,29 @@ Terms used throughout Springtale's codebase and documentation. Each entry links to where the concept appears in the project. +## The three vocabularies, mapped + +Springtale speaks three vocabularies and keeps all three. The canvas +names what you see, the formation verbs name the mechanics you drive, +and the runtime names the things in the code. They are not synonyms to +be collapsed — each surface uses its own column (plan 3.9, finding 70). + +| Canvas | Formation verb | Runtime | +| --- | --- | --- | +| tree | | connector | +| springtail | member | rule, bot agent | +| mycelium | | pipeline | +| formation zone | formation | formation (`swarm` in older fields) | +| pip | rally token | `rally_tokens` | +| bark | implicit signal | `BroadcastTrigger`, `ImplicitSignal` | +| momentum band | momentum tier | `MomentumTier` | + +Interface fields that said `swarm` and meant `formation` are renamed: +the `DataProvider` exposes `formations()` returning `FormationInfo[]`. +The formation verbs themselves are never renamed — they are the +mechanics. The Chiral theme is a skin and adds no words. + + ``` Application Bot Runtime ops ┌─────────┐ ┌────────────────┐ ┌──────────────────┐ diff --git a/tauri/apps/dashboard/src/App.tsx b/tauri/apps/dashboard/src/App.tsx index c3347776..6f8c5935 100644 --- a/tauri/apps/dashboard/src/App.tsx +++ b/tauri/apps/dashboard/src/App.tsx @@ -86,7 +86,7 @@ export const App = () => { if (ctl.showModeSelect()) { return ( 0} + hasExistingTeams={(db.formations()?.length ?? 0) > 0} onSelectMode={ctl.handleModeSelect} onCancel={() => ctl.setShowModeSelect(false)} /> diff --git a/tauri/apps/desktop/src/Colony.tsx b/tauri/apps/desktop/src/Colony.tsx index 1fda711e..8754ee77 100644 --- a/tauri/apps/desktop/src/Colony.tsx +++ b/tauri/apps/desktop/src/Colony.tsx @@ -237,7 +237,7 @@ export const Colony = (props: { onLock: () => void }) => { if (ctl.showModeSelect()) { return ( 0} + hasExistingTeams={(db.formations()?.length ?? 0) > 0} onSelectMode={ctl.handleModeSelect} onCancel={() => ctl.setShowModeSelect(false)} /> diff --git a/tauri/packages/ui/src/colony/controller.ts b/tauri/packages/ui/src/colony/controller.ts index 049ae0a2..7ef16447 100644 --- a/tauri/packages/ui/src/colony/controller.ts +++ b/tauri/packages/ui/src/colony/controller.ts @@ -138,7 +138,7 @@ export function createColonyController(db: ColonyDb, opts: ColonyControllerOptio // ── Data → Colony visual model (real data, no fakes) ─── const nodes = () => mapNodes(db.connectors()); const agents = () => mapAgents(db.rules(), db.agentStates()); - const formations = () => mapFormations(db.swarms(), db.cooperationEvents()); + const formations = () => mapFormations(db.formations(), db.cooperationEvents()); // ── Selection → detail-view wiring ───────────────────── const selectAgent = (id: string) => { @@ -445,7 +445,7 @@ export function createColonyController(db: ColonyDb, opts: ColonyControllerOptio } break; - // ── Formation (swarm selected) ── + // ── Formation (formation selected) ── // Parameterless lifecycle/capability commands → backend generic // dispatcher (`run_formation_command`). The frontend forwards the // clicked id; ALL command→action mapping lives in Rust. @@ -507,7 +507,7 @@ export function createColonyController(db: ColonyDb, opts: ColonyControllerOptio // persists at `ai:formation:{id}` and the next Fever-tier // orchestrate call picks it up via `resolve_ai_config`. if (sel.id) { - const fm = db.swarms().find((s) => s.id === sel.id); + const fm = db.formations().find((s) => s.id === sel.id); setAiConfigAgent({ id: sel.id, name: fm?.name ?? sel.id, scope: "formation" }); } break; diff --git a/tauri/packages/ui/src/colony/mappers.ts b/tauri/packages/ui/src/colony/mappers.ts index 21aa8d31..0b44152a 100644 --- a/tauri/packages/ui/src/colony/mappers.ts +++ b/tauri/packages/ui/src/colony/mappers.ts @@ -6,7 +6,7 @@ * comes from the backend via AgentState — no frontend inference. */ import type { AgentState } from "@springtale/types"; -import type { ConnectorStatus, RuleItem, SwarmInfo } from "../dashboard/model"; +import type { ConnectorStatus, RuleItem, FormationInfo } from "../dashboard/model"; import type { CooperationEventEnvelope } from "../dashboard/types"; import type { ColonyAgent, ColonyFormation, ColonyNode } from "./types"; import { MOMENTUM_COLORS, seeded } from "./types"; @@ -88,11 +88,11 @@ const TIER_TO_INDEX: Record = { Fever: 3, }; -/** Map swarms to formations. Momentum tier + label come from backend. +/** Map formations to formations. Momentum tier + label come from backend. * Optionally folds the cooperation events stream so each formation's * `pacingPhase` reflects the most-recent `pacing_phase_changed` event. */ export function mapFormations( - swarms: SwarmInfo[], + formations: FormationInfo[], cooperationEvents: CooperationEventEnvelope[] = [], ): ColonyFormation[] { // Most-recent-first; first `pacing_phase_changed` per formation wins. @@ -119,7 +119,7 @@ export function mapFormations( } } - return swarms.map((s) => { + return formations.map((s) => { const momentum = TIER_TO_INDEX[s.momentum_tier ?? "Cold"] ?? 0; const rawPhase = latestPacing.get(s.id); // Show the cascade glow only while the hit is recent on the colony's diff --git a/tauri/packages/ui/src/dashboard/context.ts b/tauri/packages/ui/src/dashboard/context.ts index d2666523..72678885 100644 --- a/tauri/packages/ui/src/dashboard/context.ts +++ b/tauri/packages/ui/src/dashboard/context.ts @@ -35,7 +35,7 @@ import type { EventItem, RuleDetail, RuleItem, - SwarmInfo, + FormationInfo, } from "../dashboard/model"; import { eventSeverity } from "../dashboard/model"; import type { Locale } from "../i18n/types"; @@ -118,7 +118,7 @@ export function createDashboardState(provider: DataProvider): DashboardState { const [schemas, setSchemas] = createSignal([]); const [rules, setRules] = createSignal([]); const [events, setEvents] = createSignal([]); - const [swarms, setSwarms] = createSignal([]); + const [formations, setFormations] = createSignal([]); const [agentStates, setAgentStates] = createSignal([]); const [canvasState, setCanvasState] = createSignal(null); // Phase H — cooperation events ring (last 200 envelopes). Drives the @@ -139,11 +139,11 @@ export function createDashboardState(provider: DataProvider): DashboardState { cooperationEvents().flatMap((e) => (e.event.kind === "utterance" ? [e.event] : [])), ); const colonyNow = createMemo(() => utterances().reduce((m, u) => Math.max(m, u.seq), 0)); - // Formation detail per swarm (member roster with connector + role). 3.1 + // Formation detail per formation (member roster with connector + role). 3.1 // owns the full loader; until then this is the minimal read. const [formationDetails, setFormationDetails] = createSignal([]); createEffect( - on(swarms, (list) => { + on(formations, (list) => { void Promise.all(list.map((sw) => provider.getFormation(sw.id))) .then(setFormationDetails) .catch(() => {}); @@ -195,7 +195,7 @@ export function createDashboardState(provider: DataProvider): DashboardState { // ── Selection signals ── const [selectedRuleId, setSelectedRuleId] = createSignal(null); - const [selectedSwarmId, setSelectedSwarmId] = createSignal(null); + const [selectedFormationId, setSelectedFormationId] = createSignal(null); // ── UI panel signals ── const [showNewRule, setShowNewRule] = createSignal(false); @@ -281,7 +281,7 @@ export function createDashboardState(provider: DataProvider): DashboardState { // Keep every FormationInfo field — rally, guard, momentum counters // and operational_count all feed real UI signals. - setSwarms(s.map((x) => ({ ...x, members: x.members ?? [] }))); + setFormations(s.map((x) => ({ ...x, members: x.members ?? [] }))); setSchemas(cs); setAgentStates(as_); @@ -403,7 +403,7 @@ export function createDashboardState(provider: DataProvider): DashboardState { const handleDissolveFormation = async (id: string) => { try { await provider.dissolveFormation(id); - setSelectedSwarmId(null); + setSelectedFormationId(null); await refresh(); } catch (e) { setError(String(e)); @@ -438,10 +438,10 @@ export function createDashboardState(provider: DataProvider): DashboardState { }; // F1 + B11: backend-supplied formation command list. Resource re-fetches - // when the selected swarm changes; status-aware enable/disable + canonical + // when the selected formation changes; status-aware enable/disable + canonical // hotkey live in Rust per the thin-frontend rule. const [formationCommandsResource] = createResource( - () => selectedSwarmId(), + () => selectedFormationId(), async (id) => { if (!id) return undefined; try { @@ -459,7 +459,7 @@ export function createDashboardState(provider: DataProvider): DashboardState { schemas, rules, events, - swarms, + formations, agentStates, canvasState, cooperationEvents, @@ -480,8 +480,8 @@ export function createDashboardState(provider: DataProvider): DashboardState { // Selection selectedRuleId, setSelectedRuleId, - selectedSwarmId, - setSelectedSwarmId, + selectedFormationId, + setSelectedFormationId, // UI panels showNewRule, setShowNewRule, diff --git a/tauri/packages/ui/src/dashboard/model.ts b/tauri/packages/ui/src/dashboard/model.ts index ebd8727f..8fde53cb 100644 --- a/tauri/packages/ui/src/dashboard/model.ts +++ b/tauri/packages/ui/src/dashboard/model.ts @@ -2,7 +2,7 @@ * Data model types for the dashboard state layer. * * These types define the shape of data flowing from backend → provider → context → components. - * Previously scattered across legacy component files (CommandPanel, ResourceBar, Roster, SwarmCard). + * Previously scattered across legacy component files (CommandPanel, ResourceBar, Roster, FormationCard). * Consolidated here so the types outlive their original rendering components. */ @@ -48,7 +48,7 @@ export function eventSeverity(actionTaken: string): EventItem["severity"] { return a.includes("error") || a.includes("fail") || a.includes("block") ? "error" : "ok"; } -export interface SwarmInfo { +export interface FormationInfo { id: string; name: string; intent: string; diff --git a/tauri/packages/ui/src/dashboard/types.ts b/tauri/packages/ui/src/dashboard/types.ts index 24fc6912..5a1efeb2 100644 --- a/tauri/packages/ui/src/dashboard/types.ts +++ b/tauri/packages/ui/src/dashboard/types.ts @@ -27,7 +27,7 @@ import type { import type { ConditionDef } from "../ConditionEditor"; import type { BotSettingsValue } from "../colony/AppSettingsPanel"; import type { Locale } from "../i18n/types"; -import type { ConnectorStatus, EventItem, RuleDetail, RuleItem, SwarmInfo } from "./model"; +import type { ConnectorStatus, EventItem, RuleDetail, RuleItem, FormationInfo } from "./model"; // Re-export types that originated in @springtale/types but are consumed // by components that import from @springtale/ui @@ -547,7 +547,7 @@ export interface DataProvider { */ getUtteranceDefs(): Promise; - // Formations (swarms) + // Formations (formations) getFormation(id: string): Promise; listFormations(): Promise; createFormation(name: string, intent: string, connectors: string[]): Promise; @@ -1002,7 +1002,7 @@ export interface DashboardState { schemas: () => ConnectorSchema[]; rules: () => RuleItem[]; events: () => EventItem[]; - swarms: () => SwarmInfo[]; + formations: () => FormationInfo[]; agentStates: () => AgentState[]; canvasState: () => CanvasState | null; /** @@ -1027,7 +1027,7 @@ export interface DashboardState { loading: () => boolean; /** * F1 + B11: backend-supplied formation command grid for the current - * `selectedSwarmId`. Resource that re-fetches when selection changes. + * `selectedFormationId`. Resource that re-fetches when selection changes. * Returns `undefined` when no formation is selected. */ formationCommands: () => CommandDecl[] | undefined; @@ -1035,8 +1035,8 @@ export interface DashboardState { // Selection state selectedRuleId: () => string | null; setSelectedRuleId: (id: string | null) => void; - selectedSwarmId: () => string | null; - setSelectedSwarmId: (id: string | null) => void; + selectedFormationId: () => string | null; + setSelectedFormationId: (id: string | null) => void; // UI panel state showNewRule: () => boolean; diff --git a/tauri/packages/ui/src/index.ts b/tauri/packages/ui/src/index.ts index eb129249..23bca8c5 100644 --- a/tauri/packages/ui/src/index.ts +++ b/tauri/packages/ui/src/index.ts @@ -136,7 +136,7 @@ export type { EventItem, RuleDetail, RuleItem, - SwarmInfo, + FormationInfo, } from "./dashboard/model"; export type { MutationResult } from "./dashboard/query"; export { createProviderMutation, createProviderQuery } from "./dashboard/query"; From fa365cfd7816b7b5f04af234de557d3e42fb2eac Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 11:47:45 -0700 Subject: [PATCH 2/2] ui: one FormationInfo, not two The swarm-to-formation rename left the canvas model module declaring a FormationInfo alongside the canonical wire type in the dashboard types module, which the same file also imported. The canonical one stays; the canvas mapper, the dashboard context and the package index now take it from there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- tauri/packages/ui/src/colony/mappers.ts | 4 ++-- tauri/packages/ui/src/dashboard/context.ts | 9 ++------- tauri/packages/ui/src/dashboard/model.ts | 20 -------------------- tauri/packages/ui/src/dashboard/types.ts | 2 +- tauri/packages/ui/src/index.ts | 1 - 5 files changed, 5 insertions(+), 31 deletions(-) diff --git a/tauri/packages/ui/src/colony/mappers.ts b/tauri/packages/ui/src/colony/mappers.ts index 0b44152a..58064351 100644 --- a/tauri/packages/ui/src/colony/mappers.ts +++ b/tauri/packages/ui/src/colony/mappers.ts @@ -6,8 +6,8 @@ * comes from the backend via AgentState — no frontend inference. */ import type { AgentState } from "@springtale/types"; -import type { ConnectorStatus, RuleItem, FormationInfo } from "../dashboard/model"; -import type { CooperationEventEnvelope } from "../dashboard/types"; +import type { ConnectorStatus, RuleItem } from "../dashboard/model"; +import type { CooperationEventEnvelope, FormationInfo } from "../dashboard/types"; import type { ColonyAgent, ColonyFormation, ColonyNode } from "./types"; import { MOMENTUM_COLORS, seeded } from "./types"; diff --git a/tauri/packages/ui/src/dashboard/context.ts b/tauri/packages/ui/src/dashboard/context.ts index 72678885..4d533692 100644 --- a/tauri/packages/ui/src/dashboard/context.ts +++ b/tauri/packages/ui/src/dashboard/context.ts @@ -30,13 +30,7 @@ import { } from "solid-js"; import type { ConditionDef } from "../ConditionEditor"; import type { ColonyAgent } from "../colony/types"; -import type { - ConnectorStatus, - EventItem, - RuleDetail, - RuleItem, - FormationInfo, -} from "../dashboard/model"; +import type { ConnectorStatus, EventItem, RuleDetail, RuleItem } from "../dashboard/model"; import { eventSeverity } from "../dashboard/model"; import type { Locale } from "../i18n/types"; import { activityOf as deriveActivity, agentMatches as matchesAgent } from "./activity"; @@ -46,6 +40,7 @@ import type { DashboardState, DataProvider, FormationDetail, + FormationInfo, Utterance, UtteranceDefs, } from "./types"; diff --git a/tauri/packages/ui/src/dashboard/model.ts b/tauri/packages/ui/src/dashboard/model.ts index 8fde53cb..1e2b8bfe 100644 --- a/tauri/packages/ui/src/dashboard/model.ts +++ b/tauri/packages/ui/src/dashboard/model.ts @@ -47,23 +47,3 @@ export function eventSeverity(actionTaken: string): EventItem["severity"] { const a = actionTaken.toLowerCase(); return a.includes("error") || a.includes("fail") || a.includes("block") ? "error" : "ok"; } - -export interface FormationInfo { - id: string; - name: string; - intent: string; - status: string; - member_count: number; - members: string[]; - operational_count?: number; - momentum_tier?: string; - momentum_label?: string; - momentum_consecutive_successes?: number; - momentum_interference_count?: number; - momentum_successes_to_next_tier?: number | null; - capabilities?: string[]; - guard_status?: string; - guard_engaged?: boolean; - rally_tokens?: number; - rally_max?: number; -} diff --git a/tauri/packages/ui/src/dashboard/types.ts b/tauri/packages/ui/src/dashboard/types.ts index 5a1efeb2..477c6566 100644 --- a/tauri/packages/ui/src/dashboard/types.ts +++ b/tauri/packages/ui/src/dashboard/types.ts @@ -27,7 +27,7 @@ import type { import type { ConditionDef } from "../ConditionEditor"; import type { BotSettingsValue } from "../colony/AppSettingsPanel"; import type { Locale } from "../i18n/types"; -import type { ConnectorStatus, EventItem, RuleDetail, RuleItem, FormationInfo } from "./model"; +import type { ConnectorStatus, EventItem, RuleDetail, RuleItem } from "./model"; // Re-export types that originated in @springtale/types but are consumed // by components that import from @springtale/ui diff --git a/tauri/packages/ui/src/index.ts b/tauri/packages/ui/src/index.ts index 23bca8c5..f7f92c51 100644 --- a/tauri/packages/ui/src/index.ts +++ b/tauri/packages/ui/src/index.ts @@ -136,7 +136,6 @@ export type { EventItem, RuleDetail, RuleItem, - FormationInfo, } from "./dashboard/model"; export type { MutationResult } from "./dashboard/query"; export { createProviderMutation, createProviderQuery } from "./dashboard/query";