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.
//! Explicit finite capture only; neither GUI startup nor filesystem/web export
//! is a remotely supplied operation. The launcher owns pile and signing key.

use std::path::PathBuf;
use std::time::Duration;

use anybytes::Bytes;
use anyhow::{bail, Result};
use serde::Deserialize;

use super::{CaptureOptions, Target};
use crate::mcp::{decode_arguments, invalid_arguments, Faculty, Tool};
use crate::out::Out;

pub struct Viewer(super::Viewer);
impl Viewer {
    pub fn new(pile: impl Into<PathBuf>, key: Option<PathBuf>) -> Self {
        Self::with_storage(crate::storage::Storage::new(pile.into(), key))
    }
    pub fn with_storage(storage: crate::storage::Storage) -> Self {
        Self(super::Viewer::with_storage(storage))
    }
}

const TOOLS: &[Tool] = &[Tool {
    name: "viewer_capture",
    description: "Render the selected shared notebook against the launcher-configured pile, returning ordered card/page metadata and resident PNG images. Requires the native widgets feature and a local GPU; discovery does not open either. This is one finite capture, not a window, watcher, host file path or web export. The images include normal viewer loading/error banners; capture does not certify source completeness. Dashboard preserves all existing widgets and may initialize their own local GPU computations. Settle timeout is per layout pass; output bounds limit PNG bytes/images, not raw renderer allocations or transport base64. Previously emitted pages are not retried on failure.",
    input_schema: r#"{"type":"object","properties":{"target":{"type":"string","enum":["dashboard","atlas","discord","files","gauge","headspace","memory","messages","planner","status","teams","triage"]},"scale":{"type":"number","minimum":0.25,"maximum":4,"default":2},"settle_ms":{"type":"integer","minimum":0,"maximum":5000,"default":2000},"max_images":{"type":"integer","minimum":1,"maximum":256,"default":64},"max_bytes":{"type":"integer","minimum":1,"maximum":33554432,"default":4194304}},"required":["target"],"additionalProperties":false}"#,
}];

#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
enum CaptureTarget {
    Dashboard,
    Atlas,
    Discord,
    Files,
    Gauge,
    Headspace,
    Memory,
    Messages,
    Planner,
    Status,
    Teams,
    Triage,
}
impl From<CaptureTarget> for Target {
    fn from(value: CaptureTarget) -> Self {
        match value {
            CaptureTarget::Dashboard => Self::Dashboard,
            CaptureTarget::Atlas => Self::Atlas,
            CaptureTarget::Discord => Self::Discord,
            CaptureTarget::Files => Self::Files,
            CaptureTarget::Gauge => Self::Gauge,
            CaptureTarget::Headspace => Self::Headspace,
            CaptureTarget::Memory => Self::Memory,
            CaptureTarget::Messages => Self::Messages,
            CaptureTarget::Planner => Self::Planner,
            CaptureTarget::Status => Self::Status,
            CaptureTarget::Teams => Self::Teams,
            CaptureTarget::Triage => Self::Triage,
        }
    }
}
fn scale() -> f32 {
    2.0
}
fn settle_ms() -> u64 {
    2000
}
fn max_images() -> usize {
    64
}
fn max_bytes() -> usize {
    4 * 1024 * 1024
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Capture {
    target: CaptureTarget,
    #[serde(default = "scale")]
    scale: f32,
    #[serde(default = "settle_ms")]
    settle_ms: u64,
    #[serde(default = "max_images")]
    max_images: usize,
    #[serde(default = "max_bytes")]
    max_bytes: usize,
}

impl Faculty for Viewer {
    fn tools(&self) -> &[Tool] {
        TOOLS
    }
    fn call(&self, name: &str, arguments: Bytes, out: &mut Out<'_>) -> Result<()> {
        if name != "viewer_capture" {
            bail!("unknown Viewer MCP tool {name:?}");
        }
        let a: Capture = decode_arguments(arguments)?;
        let options = CaptureOptions {
            scale: a.scale,
            settle_timeout: Duration::from_millis(a.settle_ms),
            max_images: a.max_images,
            max_bytes: a.max_bytes,
        };
        options.validate().map_err(invalid_arguments)?;
        let summary = self.0.capture(a.target.into(), &options, out)?;
        out.line(format!(
            "{} PNG image(s), {} encoded bytes",
            summary.images, summary.bytes
        ))
    }
}