use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ToolGroup {
Notes,
Todos,
Files,
Code,
Meta,
Git,
Ide,
Search,
Advanced,
Facts,
Registry,
Github,
Markets,
Telegram,
Calendar,
Schedule,
Gmail,
Recall,
}
impl ToolGroup {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Notes => "notes",
Self::Todos => "todos",
Self::Files => "files",
Self::Code => "code",
Self::Meta => "meta",
Self::Git => "git",
Self::Ide => "ide",
Self::Search => "search",
Self::Advanced => "advanced",
Self::Facts => "facts",
Self::Registry => "registry",
Self::Github => "github",
Self::Markets => "markets",
Self::Telegram => "telegram",
Self::Calendar => "calendar",
Self::Schedule => "schedule",
Self::Gmail => "gmail",
Self::Recall => "recall",
}
}
#[must_use]
pub fn summary(self) -> &'static str {
match self {
Self::Notes => "personal notes: create/list/read/update/delete",
Self::Todos => "todo list: add/list/complete/uncomplete/delete",
Self::Files => "file ops: read_file, write_file, list_dir (under ~/)",
Self::Code => "code generation via specialised coder model + validator",
Self::Meta => "self-introspection: config, tool inventory, limits",
Self::Git => "git workflows: status, diff, log, add, commit, branch, checkout, push",
Self::Ide => "IDE integration: open_in_editor, reveal_in_explorer, open_url",
Self::Search => "search: web_search (Brave), web_fetch, glob_search, grep_search",
Self::Advanced => "power tools: bash, edit_file, spawn_agent (delegation)",
Self::Facts => "reference lookups: wikipedia, weather (no API key needed)",
Self::Registry => "package registries: crates.io and npmjs metadata",
Self::Github => "github PRs/issues/code search (requires GITHUB_TOKEN)",
Self::Markets => "market data: TradingView quotes/ratings/economic calendar, vestige.fi Algorand ASAs",
Self::Telegram => "telegram bot: send messages, poll updates, send photos (requires TELEGRAM_BOT_TOKEN)",
Self::Calendar => "google calendar: list/create/update/delete events, RSVP (requires claudette --auth-google)",
Self::Schedule => "proactive reminders: one-shot + recurring schedules that fire prompts back at you",
Self::Gmail => "gmail (read-only): list/search/read messages, list labels (requires claudette --auth-google gmail)",
Self::Recall => "cross-session memory: recall past messages by semantic similarity (use when user references prior conversations)",
}
}
#[must_use]
pub fn all() -> [ToolGroup; 18] {
[
Self::Notes,
Self::Todos,
Self::Files,
Self::Code,
Self::Meta,
Self::Git,
Self::Ide,
Self::Search,
Self::Advanced,
Self::Facts,
Self::Registry,
Self::Github,
Self::Markets,
Self::Telegram,
Self::Calendar,
Self::Schedule,
Self::Gmail,
Self::Recall,
]
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"notes" | "note" => Some(Self::Notes),
"todos" | "todo" | "tasks" | "task" => Some(Self::Todos),
"files" | "file" | "fs" => Some(Self::Files),
"code" | "codegen" | "coder" => Some(Self::Code),
"meta" | "capabilities" | "info" | "status" => Some(Self::Meta),
"git" => Some(Self::Git),
"ide" | "editor" => Some(Self::Ide),
"search" | "grep" | "glob" | "web" => Some(Self::Search),
"advanced" | "shell" | "power" | "bash" => Some(Self::Advanced),
"facts" | "wikipedia" | "weather" => Some(Self::Facts),
"registry" | "crates" | "npm" => Some(Self::Registry),
"github" | "gh" => Some(Self::Github),
"markets" | "market" | "tradingview" | "tv" | "vestige" | "stocks" | "crypto" => {
Some(Self::Markets)
}
"telegram" | "tg" | "tg_bot" => Some(Self::Telegram),
"calendar" | "gcal" | "google-calendar" | "google_calendar" => Some(Self::Calendar),
"schedule" | "scheduler" | "reminders" | "reminder" => Some(Self::Schedule),
"gmail" | "mail" | "email" | "inbox" => Some(Self::Gmail),
"recall" | "memory" | "remember" | "history" => Some(Self::Recall),
_ => None,
}
}
}
pub const CORE_TOOL_NAMES: &[&str] = &["enable_tools", "get_current_time", "load_workspace_rules"];
#[must_use]
pub fn group_of(tool: &str) -> Option<ToolGroup> {
match tool {
"note_create" | "note_list" | "note_read" | "note_update" | "note_delete" => {
Some(ToolGroup::Notes)
}
"todo_add" | "todo_list" | "todo_complete" | "todo_uncomplete" | "todo_delete" => {
Some(ToolGroup::Todos)
}
"read_file" | "write_file" | "list_dir" => Some(ToolGroup::Files),
"generate_code" => Some(ToolGroup::Code),
"get_capabilities" => Some(ToolGroup::Meta),
"git_status" | "git_diff" | "git_log" | "git_add" | "git_commit" | "git_branch"
| "git_checkout" | "git_push" | "git_clone" => Some(ToolGroup::Git),
"open_in_editor" | "reveal_in_explorer" | "open_url" => Some(ToolGroup::Ide),
"glob_search" | "grep_search" | "web_fetch" | "web_search" => Some(ToolGroup::Search),
"bash" | "edit_file" | "spawn_agent" => Some(ToolGroup::Advanced),
"wikipedia_search" | "wikipedia_summary" | "weather_current" | "weather_forecast" => {
Some(ToolGroup::Facts)
}
"crate_info" | "crate_search" | "npm_info" | "npm_search" => Some(ToolGroup::Registry),
"gh_list_my_prs"
| "gh_list_assigned_issues"
| "gh_get_issue"
| "gh_create_issue"
| "gh_comment_issue"
| "gh_search_code"
| "gh_list_repo_issues"
| "gh_pr_status"
| "gh_fork"
| "gh_create_pr"
| "mission_start"
| "mission_status"
| "mission_list"
| "mission_attach"
| "mission_exit"
| "mission_submit" => Some(ToolGroup::Github),
"tv_get_quote"
| "tv_technical_rating"
| "tv_search_symbol"
| "tv_economic_calendar"
| "vestige_asa_info"
| "vestige_search_asa"
| "vestige_top_movers" => Some(ToolGroup::Markets),
"tg_send" | "tg_get_updates" | "tg_send_photo" => Some(ToolGroup::Telegram),
"calendar_list_events"
| "calendar_create_event"
| "calendar_update_event"
| "calendar_delete_event"
| "calendar_respond_to_event" => Some(ToolGroup::Calendar),
"schedule_once" | "schedule_recurring" | "schedule_list" | "schedule_cancel" => {
Some(ToolGroup::Schedule)
}
"gmail_list" | "gmail_search" | "gmail_read" | "gmail_list_labels" => {
Some(ToolGroup::Gmail)
}
"recall" => Some(ToolGroup::Recall),
_ => None,
}
}
pub struct ToolRegistry {
core: Vec<Value>,
groups: BTreeMap<ToolGroup, Vec<Value>>,
enabled: BTreeSet<ToolGroup>,
}
impl ToolRegistry {
#[must_use]
pub fn new() -> Self {
let full = crate::tools::secretary_tools_json();
let arr = full.as_array().cloned().unwrap_or_default();
let mut core: Vec<Value> = Vec::with_capacity(CORE_TOOL_NAMES.len());
let mut groups: BTreeMap<ToolGroup, Vec<Value>> = BTreeMap::new();
core.push(enable_tools_schema());
for tool in arr {
let Some(name) = tool
.pointer("/function/name")
.and_then(Value::as_str)
.map(str::to_string)
else {
continue;
};
if CORE_TOOL_NAMES.contains(&name.as_str()) {
core.push(tool);
} else if let Some(group) = group_of(&name) {
groups.entry(group).or_default().push(tool);
}
}
Self {
core,
groups,
enabled: BTreeSet::new(),
}
}
#[must_use]
pub fn current_tools(&self) -> Value {
let mut out: Vec<Value> = Vec::with_capacity(self.current_len());
out.extend(self.core.iter().cloned());
for group in &self.enabled {
if let Some(tools) = self.groups.get(group) {
out.extend(tools.iter().cloned());
}
}
Value::Array(out)
}
#[must_use]
pub fn current_len(&self) -> usize {
self.core.len()
+ self
.enabled
.iter()
.map(|g| self.groups.get(g).map_or(0, Vec::len))
.sum::<usize>()
}
#[must_use]
pub fn current_schema_chars(&self) -> usize {
self.current_tools().to_string().len()
}
pub fn enable(&mut self, group: ToolGroup) -> bool {
self.enabled.insert(group)
}
#[must_use]
pub fn is_enabled(&self, group: ToolGroup) -> bool {
self.enabled.contains(&group)
}
#[must_use]
pub fn enabled_groups(&self) -> Vec<ToolGroup> {
self.enabled.iter().copied().collect()
}
#[must_use]
pub fn group_tool_names(&self, group: ToolGroup) -> Vec<String> {
self.groups
.get(&group)
.map(|tools| {
tools
.iter()
.filter_map(|t| {
t.pointer("/function/name")
.and_then(Value::as_str)
.map(String::from)
})
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub fn core_tool_names(&self) -> Vec<String> {
self.core
.iter()
.filter_map(|t| {
t.pointer("/function/name")
.and_then(Value::as_str)
.map(String::from)
})
.collect()
}
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::new()
}
}
fn enable_tools_schema() -> Value {
let names_csv = ToolGroup::all()
.iter()
.map(|g| g.name())
.collect::<Vec<_>>()
.join(", ");
let description = format!("Enable a tool group (available next turn). Groups: {names_csv}.");
json!({
"type": "function",
"function": {
"name": "enable_tools",
"description": description,
"parameters": {
"type": "object",
"properties": {
"group": { "type": "string" }
},
"required": ["group"]
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_group_canonical() {
assert_eq!(ToolGroup::parse("git"), Some(ToolGroup::Git));
assert_eq!(ToolGroup::parse("ide"), Some(ToolGroup::Ide));
assert_eq!(ToolGroup::parse("search"), Some(ToolGroup::Search));
assert_eq!(ToolGroup::parse("advanced"), Some(ToolGroup::Advanced));
assert_eq!(ToolGroup::parse("facts"), Some(ToolGroup::Facts));
assert_eq!(ToolGroup::parse("registry"), Some(ToolGroup::Registry));
assert_eq!(ToolGroup::parse("github"), Some(ToolGroup::Github));
assert_eq!(ToolGroup::parse("markets"), Some(ToolGroup::Markets));
assert_eq!(ToolGroup::parse("tradingview"), Some(ToolGroup::Markets));
assert_eq!(ToolGroup::parse("vestige"), Some(ToolGroup::Markets));
assert_eq!(ToolGroup::parse("telegram"), Some(ToolGroup::Telegram));
}
#[test]
fn parse_group_aliases() {
assert_eq!(ToolGroup::parse("GIT"), Some(ToolGroup::Git));
assert_eq!(ToolGroup::parse(" git "), Some(ToolGroup::Git));
assert_eq!(ToolGroup::parse("editor"), Some(ToolGroup::Ide));
assert_eq!(ToolGroup::parse("grep"), Some(ToolGroup::Search));
assert_eq!(ToolGroup::parse("shell"), Some(ToolGroup::Advanced));
assert_eq!(ToolGroup::parse("bash"), Some(ToolGroup::Advanced));
assert_eq!(ToolGroup::parse("wikipedia"), Some(ToolGroup::Facts));
assert_eq!(ToolGroup::parse("weather"), Some(ToolGroup::Facts));
assert_eq!(ToolGroup::parse("crates"), Some(ToolGroup::Registry));
assert_eq!(ToolGroup::parse("npm"), Some(ToolGroup::Registry));
assert_eq!(ToolGroup::parse("gh"), Some(ToolGroup::Github));
assert_eq!(ToolGroup::parse("tg"), Some(ToolGroup::Telegram));
}
#[test]
fn parse_group_unknown() {
assert_eq!(ToolGroup::parse(""), None);
assert_eq!(ToolGroup::parse("unknown"), None);
assert_eq!(ToolGroup::parse("core"), None);
}
#[test]
fn group_of_classifies_known_tools() {
assert_eq!(group_of("git_status"), Some(ToolGroup::Git));
assert_eq!(group_of("git_push"), Some(ToolGroup::Git));
assert_eq!(group_of("open_in_editor"), Some(ToolGroup::Ide));
assert_eq!(group_of("glob_search"), Some(ToolGroup::Search));
assert_eq!(group_of("web_fetch"), Some(ToolGroup::Search));
assert_eq!(group_of("bash"), Some(ToolGroup::Advanced));
assert_eq!(group_of("spawn_agent"), Some(ToolGroup::Advanced));
assert_eq!(group_of("wikipedia_search"), Some(ToolGroup::Facts));
assert_eq!(group_of("weather_forecast"), Some(ToolGroup::Facts));
assert_eq!(group_of("crate_info"), Some(ToolGroup::Registry));
assert_eq!(group_of("npm_search"), Some(ToolGroup::Registry));
assert_eq!(group_of("gh_list_my_prs"), Some(ToolGroup::Github));
assert_eq!(group_of("gh_create_issue"), Some(ToolGroup::Github));
assert_eq!(group_of("tv_get_quote"), Some(ToolGroup::Markets));
assert_eq!(group_of("tv_technical_rating"), Some(ToolGroup::Markets));
assert_eq!(group_of("vestige_asa_info"), Some(ToolGroup::Markets));
assert_eq!(group_of("vestige_top_movers"), Some(ToolGroup::Markets));
assert_eq!(group_of("tg_send"), Some(ToolGroup::Telegram));
assert_eq!(group_of("tg_get_updates"), Some(ToolGroup::Telegram));
assert_eq!(group_of("tg_send_photo"), Some(ToolGroup::Telegram));
}
#[test]
fn group_of_returns_none_for_core() {
for &name in CORE_TOOL_NAMES {
assert_eq!(
group_of(name),
None,
"core tool {name} should not map to a group"
);
}
}
#[test]
fn every_advertised_tool_is_classified() {
let full = crate::tools::secretary_tools_json();
let arr = full.as_array().cloned().unwrap_or_default();
let mut unclassified: Vec<String> = Vec::new();
for tool in arr {
let Some(name) = tool
.pointer("/function/name")
.and_then(Value::as_str)
.map(str::to_string)
else {
continue;
};
let is_core = CORE_TOOL_NAMES.contains(&name.as_str());
let is_grouped = group_of(&name).is_some();
if !is_core && !is_grouped {
unclassified.push(name);
}
}
assert!(
unclassified.is_empty(),
"tool(s) advertised but not classified into core/group — \
will be silently dropped by ToolRegistry::new: {unclassified:?}. \
Add to CORE_TOOL_NAMES or to a `group_of` arm."
);
}
#[test]
fn registry_starts_with_only_core() {
let reg = ToolRegistry::new();
assert!(reg.enabled_groups().is_empty());
let core_names = reg.core_tool_names();
assert_eq!(
core_names.len(),
3,
"core should be exactly 3 tools, got {core_names:?}"
);
assert!(core_names.contains(&"enable_tools".to_string()));
assert!(core_names.contains(&"get_current_time".to_string()));
assert!(core_names.contains(&"load_workspace_rules".to_string()));
assert!(!core_names.contains(&"read_file".to_string()));
assert!(!core_names.contains(&"generate_code".to_string()));
assert!(!core_names.contains(&"note_create".to_string()));
assert!(!core_names.contains(&"web_search".to_string()));
}
#[test]
fn registry_current_tools_starts_at_core_size() {
let reg = ToolRegistry::new();
let tools = reg.current_tools();
let arr = tools.as_array().expect("tools should be an array");
assert_eq!(arr.len(), reg.core.len());
assert_eq!(arr.len(), reg.current_len());
}
#[test]
fn enable_group_adds_tools_to_current() {
let mut reg = ToolRegistry::new();
let base = reg.current_len();
let newly_enabled = reg.enable(ToolGroup::Git);
assert!(newly_enabled);
assert!(reg.is_enabled(ToolGroup::Git));
let after = reg.current_len();
assert!(
after > base,
"enabling git should add tools (base={base}, after={after})"
);
let arr = reg.current_tools();
let names: Vec<&str> = arr
.as_array()
.unwrap()
.iter()
.filter_map(|t| t.pointer("/function/name").and_then(Value::as_str))
.collect();
assert!(names.contains(&"git_status"));
assert!(names.contains(&"enable_tools"));
}
#[test]
fn enable_group_idempotent() {
let mut reg = ToolRegistry::new();
let first = reg.enable(ToolGroup::Ide);
let second = reg.enable(ToolGroup::Ide);
assert!(first, "first enable reports new");
assert!(!second, "second enable reports already-on");
assert_eq!(reg.enabled_groups(), vec![ToolGroup::Ide]);
}
#[test]
fn enable_multiple_groups_combines_tools() {
let mut reg = ToolRegistry::new();
reg.enable(ToolGroup::Git);
reg.enable(ToolGroup::Search);
let names: Vec<String> = reg
.current_tools()
.as_array()
.unwrap()
.iter()
.filter_map(|t| {
t.pointer("/function/name")
.and_then(Value::as_str)
.map(String::from)
})
.collect();
assert!(names.contains(&"git_commit".to_string()));
assert!(names.contains(&"grep_search".to_string()));
assert!(!names.contains(&"bash".to_string()));
}
#[test]
fn group_tool_names_returns_schema_order() {
let reg = ToolRegistry::new();
let git = reg.group_tool_names(ToolGroup::Git);
assert!(git.contains(&"git_status".to_string()));
assert!(git.contains(&"git_push".to_string()));
assert!(git.contains(&"git_clone".to_string()));
assert_eq!(git.len(), 9);
}
#[test]
fn github_group_includes_brownfield_tools() {
let reg = ToolRegistry::new();
let gh = reg.group_tool_names(ToolGroup::Github);
for name in [
"gh_list_repo_issues",
"gh_pr_status",
"gh_fork",
"gh_create_pr",
"mission_start",
"mission_status",
"mission_list",
"mission_attach",
"mission_exit",
"mission_submit",
] {
assert!(gh.contains(&name.to_string()), "missing {name} in {gh:?}");
}
assert_eq!(gh.len(), 16);
}
#[test]
fn schema_chars_grows_with_enables() {
let mut reg = ToolRegistry::new();
let core_only = reg.current_schema_chars();
reg.enable(ToolGroup::Git);
let with_git = reg.current_schema_chars();
assert!(
with_git > core_only,
"enabling git should grow schema (core={core_only}, with_git={with_git})"
);
}
#[test]
fn enable_tools_schema_mentions_every_group() {
let schema = enable_tools_schema();
let desc = schema
.pointer("/function/description")
.and_then(Value::as_str)
.unwrap_or("");
for g in ToolGroup::all() {
assert!(
desc.contains(g.name()),
"description should mention {}: {desc}",
g.name()
);
}
}
#[test]
fn enable_tools_group_param_has_no_redundant_description() {
let schema = enable_tools_schema();
let desc = schema.pointer("/function/parameters/properties/group/description");
assert!(
desc.is_none(),
"group param must not carry a description (token budget): {desc:?}"
);
}
#[test]
fn enable_tools_schema_has_no_enum() {
let schema = enable_tools_schema();
let enum_node = schema.pointer("/function/parameters/properties/group/enum");
assert!(
enum_node.is_none(),
"group param must not carry an enum (token budget): {enum_node:?}"
);
}
#[test]
fn schema_size_report() {
let old_full = crate::tools::secretary_tools_json().to_string().len();
let reg = ToolRegistry::new();
let core_only = reg.current_schema_chars();
let core_count = reg.core_tool_names().len();
let mut git_only = ToolRegistry::new();
git_only.enable(ToolGroup::Git);
let with_git = git_only.current_schema_chars();
let mut all = ToolRegistry::new();
for g in ToolGroup::all() {
all.enable(g);
}
let with_all = all.current_schema_chars();
let all_count = all.current_len();
eprintln!("─── schema size report ───");
eprintln!("old flat registry (30 tools): {old_full} chars");
eprintln!("core only ({core_count} tools): {core_only} chars");
eprintln!("core + git: {with_git} chars");
eprintln!("core + all groups ({all_count} tools): {with_all} chars");
eprintln!(
"savings vs old, core-only: {} chars (~{}%)",
old_full.saturating_sub(core_only),
100 * (old_full.saturating_sub(core_only)) / old_full.max(1),
);
assert!(core_only < old_full);
assert!(with_all >= old_full);
}
#[test]
fn advanced_group_contains_spawn_agent() {
let reg = ToolRegistry::new();
let advanced = reg.group_tool_names(ToolGroup::Advanced);
assert!(advanced.contains(&"bash".to_string()));
assert!(advanced.contains(&"edit_file".to_string()));
assert!(advanced.contains(&"spawn_agent".to_string()));
}
}