use CommandAccess::{Anyone, Guest, Host, Owner};
use CommandGroup::{People, Project, Session, You};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandAccess {
Anyone,
Guest,
Owner,
Host,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandGroup {
Session,
People,
Project,
You,
}
#[derive(Debug, Clone, Copy)]
pub struct CommandMeta {
pub access: CommandAccess,
pub group: CommandGroup,
pub summary: &'static str,
pub argument: Option<&'static str>,
}
pub static COMMANDS: &[(&str, CommandMeta)] = &[
(
"!stop",
CommandMeta {
access: Owner,
group: Session,
summary: "end the session and close the thread",
argument: None,
},
),
(
"!interrupt",
CommandMeta {
access: Owner,
group: Session,
summary: "abort the running turn",
argument: None,
},
),
(
"!steer",
CommandMeta {
access: Owner,
group: Session,
summary: "redirect the running turn",
argument: Some("<instruction>"),
},
),
(
"!then",
CommandMeta {
access: Guest,
group: Session,
summary: "hold a prompt until the running turn finishes",
argument: Some("<prompt>"),
},
),
(
"!pr",
CommandMeta {
access: Owner,
group: Session,
summary: "open a pull request for the work on this branch",
argument: Some("<title>"),
},
),
(
"!compact",
CommandMeta {
access: Owner,
group: Session,
summary: "summarise the conversation so far to free up context",
argument: None,
},
),
(
"!model",
CommandMeta {
access: Owner,
group: Session,
summary: "show the models available, or switch to one",
argument: Some("[name]"),
},
),
(
"!allow",
CommandMeta {
access: Owner,
group: People,
summary: "let another account take part in this thread",
argument: Some("<user>"),
},
),
(
"!deny",
CommandMeta {
access: Owner,
group: People,
summary: "withdraw another account from this thread",
argument: Some("<user>"),
},
),
(
"!guests",
CommandMeta {
access: Guest,
group: People,
summary: "who may take part in this thread",
argument: None,
},
),
(
"!facts",
CommandMeta {
access: Guest,
group: People,
summary: "what is remembered about somebody, or this project",
argument: Some("[@somebody|project]"),
},
),
(
"!forget",
CommandMeta {
access: Owner,
group: People,
summary: "drop what is remembered about somebody, or this project",
argument: Some("<@somebody|project>"),
},
),
(
"!ls",
CommandMeta {
access: Guest,
group: Project,
summary: "list a directory",
argument: Some("[path]"),
},
),
(
"!cat",
CommandMeta {
access: Guest,
group: Project,
summary: "show a file",
argument: Some("<path>"),
},
),
(
"!file",
CommandMeta {
access: Guest,
group: Project,
summary: "upload a file",
argument: Some("<path>"),
},
),
(
"!pwd",
CommandMeta {
access: Guest,
group: Project,
summary: "show the project this session works in",
argument: None,
},
),
(
"!status",
CommandMeta {
access: Anyone,
group: You,
summary: "session state and queue",
argument: None,
},
),
(
"!help",
CommandMeta {
access: Anyone,
group: You,
summary: "list these commands",
argument: None,
},
),
(
"!usage",
CommandMeta {
access: Anyone,
group: You,
summary: "how much of the provider's usage window is left",
argument: None,
},
),
(
"!shutdown",
CommandMeta {
access: Host,
group: You,
summary: "power off the host this daemon runs on",
argument: None,
},
),
];
pub const ASIDE: &str = "!!!";
pub fn first_word(content: &str) -> &str {
content.split_whitespace().next().unwrap_or("")
}
pub fn is_command(content: &str) -> bool {
let first = first_word(content);
COMMANDS.iter().any(|(name, _)| *name == first)
}
pub fn is_addressed_to_bot(content: &str) -> bool {
content.trim_start().starts_with('!')
}
pub fn is_aside(content: &str) -> bool {
content.trim_start().starts_with(ASIDE)
}
fn is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn word_starts(text: &str, word: &str) -> Vec<usize> {
let bytes = text.as_bytes();
let mut starts = Vec::new();
let mut from = 0;
while let Some(found) = text[from..].find(word) {
let start = from + found;
if start == 0 || !is_word_byte(bytes[start - 1]) {
starts.push(start);
}
from = start + 1;
}
starts
}
fn verb_with_requests(text: &str, verb: &str) -> bool {
let bytes = text.as_bytes();
for start in word_starts(text, verb) {
let mut after = start + verb.len();
if text[after..]
.chars()
.next()
.is_some_and(|character| character.is_whitespace() || character == '-')
{
after += text[after..]
.chars()
.next()
.expect("checked above")
.len_utf8();
}
if !text[after..].starts_with("request") {
continue;
}
let mut end = after + "request".len();
if bytes.get(end) == Some(&b's') {
end += 1;
}
if end == bytes.len() || !is_word_byte(bytes[end]) {
return true;
}
}
false
}
pub fn asks_for_pull_request(content: &str) -> bool {
let lower = content.to_lowercase();
if verb_with_requests(&lower, "pull") || verb_with_requests(&lower, "merge") {
return true;
}
let bytes = lower.as_bytes();
for start in word_starts(&lower, "pr") {
let mut end = start + 2;
if bytes.get(end) == Some(&b's') {
end += 1;
}
if end == bytes.len() || !is_word_byte(bytes[end]) {
return true;
}
}
false
}
pub fn parse_user_id(text: &str) -> Option<String> {
fn is_id(digits: &str) -> bool {
(5..=25).contains(&digits.len()) && digits.bytes().all(|byte| byte.is_ascii_digit())
}
let trimmed = text.trim();
if let Some(rest) = trimmed.strip_prefix("<@") {
let rest = rest.strip_prefix('!').unwrap_or(rest);
if let Some(digits) = rest.strip_suffix('>') {
return if is_id(digits) {
Some(digits.to_owned())
} else {
None
};
}
return None;
}
if is_id(trimmed) {
return Some(trimmed.to_owned());
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Standing {
pub is_owner: bool,
pub is_guest: bool,
}
pub fn may_run(access: CommandAccess, standing: Standing) -> bool {
match access {
Host => false,
Anyone => true,
Guest => standing.is_owner || standing.is_guest,
Owner => standing.is_owner,
}
}
pub fn answer_without_session(content: &str) -> Option<String> {
(first_word(content) == "!help").then(help_text)
}
const GROUP_TITLES: [(CommandGroup, &str); 4] = [
(Session, "THE SESSION"),
(People, "WHO TAKES PART"),
(Project, "THE PROJECT"),
(You, "YOU"),
];
fn access_note(access: CommandAccess) -> &'static str {
match access {
Host => "named accounts",
Owner => "owner",
Guest => "invited",
Anyone => "",
}
}
pub fn help_text() -> String {
let spelled = |name: &str, meta: &CommandMeta| match meta.argument {
None => name.to_owned(),
Some(argument) => format!("{name} {argument}"),
};
let width = COMMANDS
.iter()
.map(|(name, meta)| spelled(name, meta).chars().count())
.max()
.unwrap_or(0);
let mut lines: Vec<String> = Vec::new();
for (group, title) in GROUP_TITLES {
let in_group: Vec<&(&str, CommandMeta)> = COMMANDS
.iter()
.filter(|(_, meta)| meta.group == group)
.collect();
if in_group.is_empty() {
continue;
}
if !lines.is_empty() {
lines.push(String::new());
}
lines.push(title.to_owned());
for (name, meta) in in_group {
let note = access_note(meta.access);
let spelled_name = spelled(name, meta);
let padding = " ".repeat(width - spelled_name.chars().count());
lines.push(format!(
" {spelled_name}{padding} {}{}",
meta.summary,
if note.is_empty() {
String::new()
} else {
format!(" ({note})")
},
));
}
}
[
"Type these in the thread, or use the same name as a slash command.".to_owned(),
"```".to_owned(),
]
.into_iter()
.chain(lines)
.chain([
"```".to_owned(),
"Anything else is a prompt for the agent. A message starting `!!!` is an".to_owned(),
"aside: the agent is never told about it.".to_owned(),
])
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests;