faculties 0.21.0

An office suite for AI agents: kanban, wiki, files, messaging, and a Lissajous-backed viewer — all persisted in a TribleSpace pile.
//! Standing intentions with resident inputs and explicit predicate evaluation.

use super::{render, DeclaredState, Habits as Operations};
use crate::mcp::{decode_arguments, invalid_arguments, Faculty, Tool};
use crate::out::Out;
use anybytes::Bytes;
use anyhow::{bail, Result};
use base64::Engine as _;
use serde::Deserialize;
use std::path::PathBuf;

const EMPTY: &str = r#"{"type":"object","properties":{},"additionalProperties":false}"#;
const SELECTOR: &str = r#"{"type":"object","properties":{"habit":{"type":"string"}},"required":["habit"],"additionalProperties":false}"#;
const TOOLS: &[Tool] = &[
    Tool { name: "habit_add", description: "Store an immutable standing intention from literal text and an optional base64 carried script. This does not execute the predicate or script. Optional personas are literal labels or exact IDs; omitted or empty personas notify everyone. A label is not unique; replacements require explicit supersedes IDs.", input_schema: r#"{"type":"object","properties":{"label":{"type":"string"},"when":{"type":"string"},"nudge":{"type":"string"},"script_base64":{"type":"string"},"supersedes":{"type":"array","items":{"type":"string"},"default":[]},"personas":{"type":"array","items":{"type":"string"},"default":[]}},"required":["label","when","nudge"],"additionalProperties":false}"# },
    Tool { name: "habit_list", description: "Inspect current definitions and activation without evaluating stored predicates by default. Explicit evaluate_conditions=true executes stored shell predicates on the server, as the CLI list command does.", input_schema: r#"{"type":"object","properties":{"evaluate_conditions":{"type":"boolean","default":false}},"additionalProperties":false}"# },
    Tool { name: "habit_show", description: "Inspect an exact definition or unambiguous label, including historical superseded definitions. Does not evaluate predicates.", input_schema: SELECTOR },
    Tool { name: "habit_due", description: "Explicitly evaluate stored standing-intention predicates on the server and return those due. Predicates can execute carried scripts or shell commands; failed evaluations are reported, not silently treated as not due.", input_schema: EMPTY },
    Tool { name: "habit_done", description: "Publish a completion occurrence for one live intention by label or ID.", input_schema: SELECTOR },
    Tool { name: "habit_pause", description: "Publish paused activation, reconciling all observed state heads without changing the definition.", input_schema: SELECTOR },
    Tool { name: "habit_resume", description: "Publish active activation, reconciling all observed state heads without changing the definition.", input_schema: SELECTOR },
    Tool { name: "habit_check", description: "Explicitly validate the complete Habit collection and its resident attachments, without executing carried predicates.", input_schema: EMPTY },
];

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Add {
    label: String,
    when: String,
    nudge: String,
    script_base64: Option<String>,
    #[serde(default)]
    supersedes: Vec<String>,
    #[serde(default)]
    personas: Vec<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct List {
    #[serde(default)]
    evaluate_conditions: bool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Selector {
    habit: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Empty {}

pub struct Habits {
    operations: Operations,
}
impl Habits {
    pub fn new(pile: PathBuf, key: Option<PathBuf>) -> Self {
        Self::with_storage(crate::storage::Storage::new(pile, key))
    }
    pub fn with_storage(storage: crate::storage::Storage) -> Self {
        Self {
            operations: Operations::with_storage(storage),
        }
    }
}
impl Faculty for Habits {
    fn tools(&self) -> &[Tool] {
        TOOLS
    }
    fn call(&self, name: &str, arguments: Bytes, out: &mut Out<'_>) -> Result<()> {
        match name {
            "habit_add" => {
                let args: Add = decode_arguments(arguments)?;
                let script = args
                    .script_base64
                    .as_deref()
                    .map(|value| {
                        base64::engine::general_purpose::STANDARD
                            .decode(value)
                            .map_err(|error| {
                                invalid_arguments(format!("invalid script_base64: {error}"))
                            })
                    })
                    .transpose()?;
                render::added(
                    &self.operations.add(
                        &args.label,
                        &args.when,
                        &args.nudge,
                        script.as_deref(),
                        &args.supersedes,
                        &args.personas,
                    )?,
                    out,
                )
            }
            "habit_list" | "habit_due" => {
                let due = name == "habit_due";
                let evaluate = if due {
                    let _: Empty = decode_arguments(arguments)?;
                    true
                } else {
                    let args: List = decode_arguments(arguments)?;
                    args.evaluate_conditions
                };
                let listing = render::listed(&self.operations.list(evaluate)?, due);
                out.text(listing.text)?;
                for diagnostic in &listing.diagnostics {
                    out.line(diagnostic)?;
                }
                if !listing.diagnostics.is_empty() {
                    bail!(
                        "{} standing intention(s) could not be evaluated",
                        listing.diagnostics.len()
                    );
                }
                Ok(())
            }
            "habit_show" | "habit_done" | "habit_pause" | "habit_resume" => {
                let args: Selector = decode_arguments(arguments)?;
                match name {
                    "habit_show" => render::shown(&self.operations.show(&args.habit)?, out),
                    "habit_done" => render::completed(&self.operations.done(&args.habit)?, out),
                    _ => render::state_changed(
                        &self.operations.set_state(
                            &args.habit,
                            if name == "habit_pause" {
                                DeclaredState::Paused
                            } else {
                                DeclaredState::Active
                            },
                        )?,
                        out,
                    ),
                }
            }
            "habit_check" => {
                let _: Empty = decode_arguments(arguments)?;
                out.line(self.operations.check()?)
            }
            _ => bail!("Habit MCP has no tool {name:?}"),
        }
    }
}