liminal-cli 0.3.0

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! The harness's own conversations: list them, start a new one, reopen an earlier one.
//!
//! The harness keeps its sessions (aion's ruling: "most of them take care of the session storage");
//! the host only asks it `session/list`, `session/new` and `session/load`, and a reopened
//! conversation's history arrives as the harness replays it, marked `replayed` on the stream.

use super::{Publish, Pump};
use serde_json::{Value, json};

pub(super) enum Opening {
    List,
    New,
    Open { previous: String },
}

impl Pump {
    /// `{"session":{"list":true}}`, `{"session":{"new":true}}` or `{"session":{"open":"<id>"}}`.
    pub(super) fn session_control(&mut self, change: &Value) -> anyhow::Result<()> {
        if self.opening.is_some() {
            return self.say(json!({"type":"session_failed","reason":"a conversation change is already in flight"}));
        }
        let opening = if change.get("list").is_some() {
            Opening::List
        } else if change.get("new").is_some() {
            Opening::New
        } else if let Some(id) = change["open"].as_str().filter(|id| !id.is_empty()) {
            Opening::Open {
                previous: id.to_owned(),
            }
        } else {
            return self
                .say(json!({"type":"session_failed","reason":"session takes list, new or open"}));
        };
        if !matches!(opening, Opening::List) && self.turn.is_some() {
            return self.say(
                json!({"type":"session_failed","reason":"a turn is open; cancel it or wait"}),
            );
        }
        let (method, params) = match &opening {
            Opening::List => ("session/list", json!({"cwd": self.session.cwd})),
            Opening::New => super::super::handshake::session_request(
                self.session.version,
                &self.session.cwd,
                &self.session.seat,
                None,
            )?,
            Opening::Open { previous: id } => super::super::handshake::session_request(
                self.session.version,
                &self.session.cwd,
                &self.session.seat,
                Some(id),
            )?,
        };
        // A reopened conversation's replay names its own id, so the host listens as it before
        // the harness starts replaying; the id it leaves is kept to return to on a refusal.
        let opening = match opening {
            Opening::Open { previous: id } => Opening::Open {
                previous: std::mem::replace(&mut self.session.id, id),
            },
            other => other,
        };
        if !matches!(opening, Opening::List) {
            // Watchers start their reading afresh here; a reopened conversation's replay follows.
            self.say(json!({"type":"session_opening"}))?;
        }
        let id = self.next_id;
        self.next_id = id
            .checked_add(1)
            .ok_or_else(|| anyhow::anyhow!("ACP request id exhausted"))?;
        self.opening = Some((id, opening));
        self.output.request(id, method, params)
    }

    pub(super) fn opened(&mut self, result: Result<Value, i64>) -> anyhow::Result<()> {
        let Some((_, opening)) = self.opening.take() else {
            return Ok(());
        };
        let answer = match result {
            Ok(answer) => answer,
            Err(code) => {
                if let Opening::Open { previous } = opening {
                    self.session.id = previous;
                }
                return self.say(json!({"type":"session_failed","code":code}));
            }
        };
        match opening {
            Opening::List => self.say(json!({
                "type": "sessions",
                "current": self.session.id,
                "sessions": listed(&answer, self.listing),
            })),
            Opening::New | Opening::Open { .. } => {
                let resumed = matches!(opening, Opening::Open { .. });
                if let Some(id) = answer["sessionId"].as_str().filter(|_| !resumed) {
                    id.clone_into(&mut self.session.id);
                }
                if let Some(options) = answer
                    .get("configOptions")
                    .filter(|options| options.is_array())
                {
                    self.session.config = options.clone();
                }
                self.say(
                    json!({"type":"session_opened","session":self.session.id,"resumed":resumed}),
                )?;
                self.say(json!({"type":"config_options","options":self.session.config}))
            }
        }
    }

    /// Whether a replayed update belongs to the conversation being reopened.
    pub(super) const fn replaying(&self) -> bool {
        matches!(self.opening, Some((_, Opening::Open { .. })))
    }

    /// Whether a harness update names a conversation the host has not adopted yet.
    pub(super) fn foreign(&self, params: &Value) -> bool {
        self.opening.is_some()
            && params["sessionId"]
                .as_str()
                .is_some_and(|id| id != self.session.id)
    }

    /// A command is delivered as the prompt line ACP describes: `/name` and what was typed after.
    pub(super) fn prompt_text(&self, value: &Value) -> anyhow::Result<Option<String>> {
        let Some(command) = value.get("command") else {
            return self.exchange.inbox(value);
        };
        let name = command["name"]
            .as_str()
            .filter(|name| !name.is_empty() && !name.contains(char::is_whitespace))
            .ok_or_else(|| anyhow::anyhow!("command has no name"))?;
        Ok(Some(
            command["input"]
                .as_str()
                .filter(|input| !input.is_empty())
                .map_or_else(|| format!("/{name}"), |input| format!("/{name} {input}")),
        ))
    }

    fn say(&self, item: Value) -> anyhow::Result<()> {
        self.publications
            .try_send(Publish::Stream(item))
            .map_err(|_| anyhow::anyhow!("stream_queue reached or publisher closed"))
    }
}

/// At most `count` conversations, newest first, each title cut to `title` characters, so one
/// stream item stays bounded.
fn listed(answer: &Value, (count, title): (usize, usize)) -> Vec<Value> {
    let mut sessions: Vec<&Value> = answer["sessions"]
        .as_array()
        .map(|all| all.iter().collect())
        .unwrap_or_default();
    sessions.sort_by(|left, right| right["updatedAt"].as_str().cmp(&left["updatedAt"].as_str()));
    sessions
        .into_iter()
        .take(count)
        .filter_map(|session| {
            let id = session["sessionId"].as_str()?;
            let kept: String = session["title"]
                .as_str()
                .unwrap_or_default()
                .chars()
                .take(title)
                .collect();
            Some(json!({"id": id, "title": kept, "updated_at": session["updatedAt"]}))
        })
        .collect()
}