refactor: split god-files into focused sibling modules (#835) (#1010)

Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
Jwalin Shah
2026-04-29 11:11:26 -07:00
committed by GitHub
co-authored by Jwalin Shah
parent 078a6b29a9
commit d9ed70142e
36 changed files with 9754 additions and 9483 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,292 @@
//! Telegram channel — constructor, configuration, auth/pairing, and API plumbing helpers.
use super::channel_types::{
TelegramChannel, TelegramUpdateWindow, TELEGRAM_RECENT_UPDATE_CACHE_SIZE,
};
use super::text::TELEGRAM_BIND_COMMAND;
use crate::openhuman::config::{Config, StreamMode};
use crate::openhuman::security::pairing::PairingGuard;
use anyhow::Context;
use directories::UserDirs;
use std::sync::{Arc, RwLock};
use tokio::fs;
impl TelegramChannel {
pub fn new(bot_token: String, allowed_users: Vec<String>, mention_only: bool) -> Self {
let normalized_allowed = Self::normalize_allowed_users(allowed_users);
let pairing = if normalized_allowed.is_empty() {
let guard = PairingGuard::new(true, &[]);
if let Some(code) = guard.pairing_code() {
println!(" 🔐 Telegram pairing required. One-time bind code: {code}");
println!(" Send `{TELEGRAM_BIND_COMMAND} <code>` from your Telegram account.");
}
Some(guard)
} else {
None
};
Self {
bot_token,
allowed_users: Arc::new(RwLock::new(normalized_allowed)),
pairing,
client: reqwest::Client::new(),
stream_mode: StreamMode::Off,
draft_update_interval_ms: 1000,
silent_streaming: true,
last_draft_edit: parking_lot::Mutex::new(std::collections::HashMap::new()),
typing_handle: parking_lot::Mutex::new(None),
mention_only,
bot_username: parking_lot::Mutex::new(None),
recent_updates: parking_lot::Mutex::new(TelegramUpdateWindow::default()),
}
}
/// Configure streaming mode for progressive draft updates.
/// Configure streaming mode for progressive draft updates.
pub fn with_streaming(
mut self,
stream_mode: StreamMode,
draft_update_interval_ms: u64,
silent_streaming: bool,
) -> Self {
self.stream_mode = stream_mode;
self.draft_update_interval_ms = draft_update_interval_ms;
self.silent_streaming = silent_streaming;
self
}
/// Parse reply_target into (chat_id, optional thread_id).
pub(crate) fn parse_reply_target(reply_target: &str) -> (String, Option<String>) {
if let Some((chat_id, thread_id)) = reply_target.split_once(':') {
(chat_id.to_string(), Some(thread_id.to_string()))
} else {
(reply_target.to_string(), None)
}
}
pub(crate) fn parse_message_id(value: Option<&str>) -> Option<i64> {
value.and_then(|raw| raw.trim().parse::<i64>().ok())
}
pub(crate) fn http_client(&self) -> reqwest::Client {
crate::openhuman::config::build_runtime_proxy_client("channel.telegram")
}
pub(crate) fn normalize_identity(value: &str) -> String {
value.trim().trim_start_matches('@').to_string()
}
pub(crate) fn normalize_allowed_users(allowed_users: Vec<String>) -> Vec<String> {
allowed_users
.into_iter()
.map(|entry| Self::normalize_identity(&entry))
.filter(|entry| !entry.is_empty())
.collect()
}
pub(crate) fn api_url(&self, method: &str) -> String {
format!("https://api.telegram.org/bot{}/{method}", self.bot_token)
}
pub(crate) fn pairing_code_active(&self) -> bool {
self.pairing
.as_ref()
.and_then(PairingGuard::pairing_code)
.is_some()
}
pub(crate) fn extract_bind_code(text: &str) -> Option<&str> {
let mut parts = text.split_whitespace();
let command = parts.next()?;
let base_command = command.split('@').next().unwrap_or(command);
if base_command != TELEGRAM_BIND_COMMAND {
return None;
}
parts.next().map(str::trim).filter(|code| !code.is_empty())
}
pub(crate) fn track_update_id(&self, update_id: i64) -> bool {
let mut window = self.recent_updates.lock();
if window.recent_lookup.contains(&update_id) {
tracing::debug!(
update_id,
"Telegram update dedupe hit: duplicate update skipped"
);
return false;
}
if update_id < window.max_seen_update_id {
tracing::debug!(
update_id,
max_seen = window.max_seen_update_id,
"Telegram update ordering safeguard: stale update skipped"
);
return false;
}
if update_id > window.max_seen_update_id {
window.max_seen_update_id = update_id;
}
window.recent_lookup.insert(update_id);
window.recent_order.push_back(update_id);
if window.recent_order.len() > TELEGRAM_RECENT_UPDATE_CACHE_SIZE {
if let Some(evicted) = window.recent_order.pop_front() {
window.recent_lookup.remove(&evicted);
}
}
true
}
/// Clears Bot API webhook mode so `getUpdates` long polling can run.
pub(crate) async fn delete_webhook_for_long_polling(&self) -> bool {
let url = self.api_url("deleteWebhook");
let body = serde_json::json!({ "drop_pending_updates": false });
tracing::info!(
"[telegram] deleteWebhook: enabling getUpdates polling (drop_pending_updates=false)"
);
match self.http_client().post(&url).json(&body).send().await {
Ok(resp) => Self::telegram_api_ok(resp).await,
Err(e) => {
tracing::warn!(error = %e, "[telegram] deleteWebhook HTTP request failed");
false
}
}
}
pub(crate) async fn telegram_api_ok(resp: reqwest::Response) -> bool {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
tracing::warn!(status = ?status, body, "Telegram API request failed");
return false;
}
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(payload) => {
if payload
.get("ok")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
true
} else {
let error_code = payload
.get("error_code")
.and_then(serde_json::Value::as_i64)
.unwrap_or_default();
let description = payload
.get("description")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown Telegram API error");
tracing::warn!(
status = ?status,
error_code,
description,
body,
"Telegram API responded with ok=false"
);
false
}
}
Err(error) => {
tracing::warn!(
status = ?status,
%error,
body,
"Telegram API returned non-JSON body"
);
false
}
}
}
pub(crate) async fn fetch_bot_username(&self) -> anyhow::Result<String> {
let resp = self.http_client().get(self.api_url("getMe")).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Failed to fetch bot info: {}", resp.status());
}
let data: serde_json::Value = resp.json().await?;
let username = data
.get("result")
.and_then(|r| r.get("username"))
.and_then(|u| u.as_str())
.context("Bot username not found in response")?;
Ok(username.to_string())
}
pub(crate) async fn get_bot_username(&self) -> Option<String> {
{
let cache = self.bot_username.lock();
if let Some(ref username) = *cache {
return Some(username.clone());
}
}
match self.fetch_bot_username().await {
Ok(username) => {
let mut cache = self.bot_username.lock();
*cache = Some(username.clone());
Some(username)
}
Err(e) => {
tracing::warn!("Failed to fetch bot username: {e}");
None
}
}
}
async fn load_config_without_env() -> anyhow::Result<Config> {
let home = UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.context("Could not find home directory")?;
let openhuman_dir = home.join(".openhuman");
let config_path = openhuman_dir.join("config.toml");
let contents = fs::read_to_string(&config_path)
.await
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
let mut config: Config = toml::from_str(&contents)
.context("Failed to parse config file for Telegram binding")?;
config.config_path = config_path;
config.workspace_dir = openhuman_dir.join("workspace");
Ok(config)
}
pub(crate) async fn persist_allowed_identity(&self, identity: &str) -> anyhow::Result<()> {
let mut config = Self::load_config_without_env().await?;
let Some(telegram) = config.channels_config.telegram.as_mut() else {
anyhow::bail!("Telegram channel config is missing in config.toml");
};
let normalized = Self::normalize_identity(identity);
if normalized.is_empty() {
anyhow::bail!("Cannot persist empty Telegram identity");
}
if !telegram.allowed_users.iter().any(|u| u == &normalized) {
telegram.allowed_users.push(normalized);
config
.save()
.await
.context("Failed to persist Telegram allowlist to config.toml")?;
}
Ok(())
}
pub(crate) fn add_allowed_identity_runtime(&self, identity: &str) {
let normalized = Self::normalize_identity(identity);
if normalized.is_empty() {
return;
}
if let Ok(mut users) = self.allowed_users.write() {
if !users.iter().any(|u| u == &normalized) {
users.push(normalized);
}
}
}
}
@@ -0,0 +1,523 @@
//! Telegram channel — `Channel` trait implementation: send, listen, draft streaming, typing.
use super::attachments::{parse_attachment_markers, parse_path_only_attachment};
use super::channel_types::{TelegramChannel, TelegramTypingTask};
use super::text::{strip_tool_call_tags, TELEGRAM_MAX_MESSAGE_LENGTH};
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage};
use crate::openhuman::config::StreamMode;
use async_trait::async_trait;
use std::time::Duration;
#[async_trait]
impl Channel for TelegramChannel {
fn name(&self) -> &str {
"telegram"
}
fn supports_reactions(&self) -> bool {
true
}
fn supports_draft_updates(&self) -> bool {
self.stream_mode != StreamMode::Off
}
async fn send_draft(&self, message: &SendMessage) -> anyhow::Result<Option<String>> {
if self.stream_mode == StreamMode::Off {
return Ok(None);
}
let (chat_id, thread_id) = Self::parse_reply_target(&message.recipient);
let parent_message_id = Self::parse_message_id(message.thread_ts.as_deref());
let initial_text = if message.content.is_empty() {
"...".to_string()
} else {
message.content.clone()
};
let mut body = serde_json::json!({
"chat_id": chat_id,
"text": initial_text,
});
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::Value::String(tid.to_string());
}
if let Some(parent_id) = parent_message_id {
body["reply_to_message_id"] = serde_json::Value::from(parent_id);
}
let resp = self
.client
.post(self.api_url("sendMessage"))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Telegram sendMessage (draft) failed: {err}");
}
let resp_json: serde_json::Value = resp.json().await?;
let message_id = resp_json
.get("result")
.and_then(|r| r.get("message_id"))
.and_then(|id| id.as_i64())
.map(|id| id.to_string());
self.last_draft_edit
.lock()
.insert(chat_id.to_string(), std::time::Instant::now());
Ok(message_id)
}
async fn update_draft(
&self,
recipient: &str,
message_id: &str,
text: &str,
) -> anyhow::Result<()> {
let (chat_id, _) = Self::parse_reply_target(recipient);
// Rate-limit edits per chat
{
let last_edits = self.last_draft_edit.lock();
if let Some(last_time) = last_edits.get(&chat_id) {
let elapsed = u64::try_from(last_time.elapsed().as_millis()).unwrap_or(u64::MAX);
if elapsed < self.draft_update_interval_ms {
return Ok(());
}
}
}
// Truncate to Telegram limit for mid-stream edits (UTF-8 safe)
let display_text = if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH {
let mut end = 0;
for (idx, ch) in text.char_indices() {
let next = idx + ch.len_utf8();
if next > TELEGRAM_MAX_MESSAGE_LENGTH {
break;
}
end = next;
}
&text[..end]
} else {
text
};
let message_id_parsed = match message_id.parse::<i64>() {
Ok(id) => id,
Err(e) => {
tracing::warn!("Invalid Telegram message_id '{message_id}': {e}");
return Ok(());
}
};
let body = serde_json::json!({
"chat_id": chat_id,
"message_id": message_id_parsed,
"text": display_text,
});
let resp = self
.client
.post(self.api_url("editMessageText"))
.json(&body)
.send()
.await?;
if resp.status().is_success() {
self.last_draft_edit
.lock()
.insert(chat_id.clone(), std::time::Instant::now());
} else {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
tracing::debug!("Telegram editMessageText failed ({status}): {err}");
}
Ok(())
}
async fn finalize_draft(
&self,
recipient: &str,
message_id: &str,
text: &str,
thread_ts: Option<&str>,
) -> anyhow::Result<()> {
let text = &strip_tool_call_tags(text);
let (chat_id, thread_id) = Self::parse_reply_target(recipient);
let parent_message_id = Self::parse_message_id(thread_ts);
// Clean up rate-limit tracking for this chat
self.last_draft_edit.lock().remove(&chat_id);
// If text exceeds limit, delete draft and send as chunked messages
if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH {
let msg_id = match message_id.parse::<i64>() {
Ok(id) => id,
Err(e) => {
tracing::warn!("Invalid Telegram message_id '{message_id}': {e}");
return self
.send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id)
.await;
}
};
// Delete the draft
let _ = self
.client
.post(self.api_url("deleteMessage"))
.json(&serde_json::json!({
"chat_id": chat_id,
"message_id": msg_id,
}))
.send()
.await;
// Fall back to chunked send
return self
.send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id)
.await;
}
let msg_id = match message_id.parse::<i64>() {
Ok(id) => id,
Err(e) => {
tracing::warn!("Invalid Telegram message_id '{message_id}': {e}");
return self
.send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id)
.await;
}
};
// Try editing with Markdown formatting
let body = serde_json::json!({
"chat_id": chat_id,
"message_id": msg_id,
"text": text,
"parse_mode": "Markdown",
});
let resp = self
.client
.post(self.api_url("editMessageText"))
.json(&body)
.send()
.await?;
if resp.status().is_success() {
return Ok(());
}
// Markdown failed — retry without parse_mode
let plain_body = serde_json::json!({
"chat_id": chat_id,
"message_id": msg_id,
"text": text,
});
let resp = self
.client
.post(self.api_url("editMessageText"))
.json(&plain_body)
.send()
.await?;
if resp.status().is_success() {
return Ok(());
}
// Edit failed entirely — fall back to new message
tracing::warn!("Telegram finalize_draft edit failed; falling back to sendMessage");
self.send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id)
.await
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
// Strip tool_call tags before processing to prevent Markdown parsing failures
let content = strip_tool_call_tags(&message.content);
let parent_message_id = Self::parse_message_id(message.thread_ts.as_deref());
// Parse recipient: "chat_id" or "chat_id:thread_id" format
let (chat_id, thread_id) = match message.recipient.split_once(':') {
Some((chat, thread)) => (chat, Some(thread)),
None => (message.recipient.as_str(), None),
};
let (reactionless_content, reaction_marker) = Self::parse_reaction_marker(&content);
if let Some(reaction_marker) = reaction_marker.as_deref() {
let (emoji, explicit_target_id) = match reaction_marker.split_once('|') {
Some((emoji, target)) => (emoji.trim(), Self::parse_message_id(Some(target))),
None => (reaction_marker.trim(), None),
};
let target_message_id = explicit_target_id.or(parent_message_id);
if let Some(target_id) = target_message_id {
let _ = self
.send_message_reaction(chat_id, target_id, emoji)
.await?;
tracing::debug!(
chat_id,
target_id,
emoji,
has_reply = !reactionless_content.is_empty(),
"[telegram] reaction sent; continuing to send reply text if present"
);
} else {
tracing::warn!(
recipient = message.recipient,
marker = reaction_marker,
"[telegram] reaction marker ignored: missing target message id"
);
}
// If no text follows the reaction marker, we are done.
if reactionless_content.trim().is_empty() {
return Ok(());
}
}
let (text_without_markers, attachments) = parse_attachment_markers(&reactionless_content);
if !attachments.is_empty() {
if !text_without_markers.is_empty() {
self.send_text_chunks(&text_without_markers, chat_id, thread_id, parent_message_id)
.await?;
}
for attachment in &attachments {
self.send_attachment(chat_id, thread_id, attachment).await?;
}
return Ok(());
}
if let Some(attachment) = parse_path_only_attachment(&reactionless_content) {
self.send_attachment(chat_id, thread_id, &attachment)
.await?;
return Ok(());
}
self.send_text_chunks(&reactionless_content, chat_id, thread_id, parent_message_id)
.await
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
let mut offset: i64 = 0;
if self.mention_only {
let _ = self.get_bot_username().await;
}
tracing::info!("Telegram channel listening for messages...");
loop {
if self.mention_only {
let missing_username = self.bot_username.lock().is_none();
if missing_username {
let _ = self.get_bot_username().await;
}
}
let url = self.api_url("getUpdates");
let body = serde_json::json!({
"offset": offset,
"timeout": 30,
"allowed_updates": ["message", "edited_message", "message_reaction"]
});
let resp = match self.http_client().post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!("Telegram poll error: {e}");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
};
let data: serde_json::Value = match resp.json().await {
Ok(d) => d,
Err(e) => {
tracing::warn!("Telegram parse error: {e}");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
};
let ok = data
.get("ok")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
if !ok {
let error_code = data
.get("error_code")
.and_then(serde_json::Value::as_i64)
.unwrap_or_default();
let description = data
.get("description")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown Telegram API error");
if error_code == 409 {
let webhook_blocks_polling = description.to_lowercase().contains("webhook");
if webhook_blocks_polling {
tracing::warn!(
"[telegram] getUpdates conflict (409): webhook is active; calling deleteWebhook"
);
if self.delete_webhook_for_long_polling().await {
tracing::info!("[telegram] deleteWebhook ok; retrying getUpdates");
continue;
}
tracing::warn!("[telegram] deleteWebhook did not succeed; backing off");
} else {
tracing::warn!(
"Telegram polling conflict (409): {description}. \
Ensure only one `openhuman` process is using this bot token."
);
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
} else {
tracing::warn!(
"Telegram getUpdates API error (code={}): {description}",
error_code
);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
continue;
}
if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) {
for update in results {
let update_id = update
.get("update_id")
.and_then(serde_json::Value::as_i64)
.unwrap_or_default();
if update_id > 0 && !self.track_update_id(update_id) {
continue;
}
// Advance offset past this update
if let Some(uid) = update.get("update_id").and_then(serde_json::Value::as_i64) {
offset = uid + 1;
}
if let Some(reaction) = self.parse_update_reaction(update) {
tracing::info!(
sender = reaction.sender,
reply_target = reaction.reply_target,
target_message_id = reaction.target_message_id,
emoji = reaction.emoji,
"Telegram reaction received"
);
publish_global(DomainEvent::ChannelReactionReceived {
channel: "telegram".to_string(),
sender: reaction.sender,
target_message_id: format!(
"telegram_{}_{}",
reaction.reply_target, reaction.target_message_id
),
emoji: reaction.emoji,
});
continue;
}
let Some(msg) = self.parse_update_message(update) else {
self.handle_unauthorized_message(update).await;
continue;
};
if tx.send(msg).await.is_err() {
return Ok(());
}
}
}
}
}
async fn health_check(&self) -> bool {
let timeout_duration = Duration::from_secs(5);
match tokio::time::timeout(
timeout_duration,
self.http_client().get(self.api_url("getMe")).send(),
)
.await
{
Ok(Ok(resp)) => resp.status().is_success(),
Ok(Err(e)) => {
tracing::debug!("Telegram health check failed: {e}");
false
}
Err(_) => {
tracing::debug!("Telegram health check timed out after 5s");
false
}
}
}
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
tracing::info!(recipient, "Telegram start_typing invoked");
// Emit immediately so short model turns still show "typing…"
self.send_typing_action_once(recipient).await;
{
let guard = self.typing_handle.lock();
if guard
.as_ref()
.is_some_and(|task| task.recipient == recipient)
{
return Ok(());
}
}
self.stop_typing(recipient).await?;
let client = self.http_client();
let url = self.api_url("sendChatAction");
let recipient_owned = recipient.to_string();
let recipient_for_log = recipient_owned.clone();
let body = Self::typing_body_for_recipient(recipient);
let handle = tokio::spawn(async move {
loop {
match client.post(&url).json(&body).send().await {
Ok(resp) => {
if !Self::telegram_api_ok(resp).await {
tracing::warn!(
recipient = recipient_for_log,
"Telegram typing refresh rejected"
);
}
}
Err(error) => {
tracing::warn!(
recipient = recipient_for_log,
%error,
"Telegram typing refresh request failed"
);
}
}
// Telegram typing indicator expires after 5s; refresh at 4s
tokio::time::sleep(Duration::from_secs(4)).await;
}
});
let mut guard = self.typing_handle.lock();
*guard = Some(TelegramTypingTask {
recipient: recipient_owned,
handle,
});
Ok(())
}
async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
tracing::info!("Telegram stop_typing invoked");
let mut guard = self.typing_handle.lock();
if let Some(task) = guard.take() {
task.handle.abort();
}
Ok(())
}
}
@@ -0,0 +1,508 @@
//! Telegram channel — inbound message/reaction parsing, allowlist checks, mention filtering,
//! unauthorized-message handling, and typing-action helpers.
use super::channel_types::{TelegramChannel, TelegramReactionEvent};
use crate::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage};
impl TelegramChannel {
pub(crate) fn typing_body_for_recipient(recipient: &str) -> serde_json::Value {
let (chat_id, thread_id) = Self::parse_reply_target(recipient);
let mut body = serde_json::json!({
"chat_id": chat_id,
"action": "typing"
});
if let Some(thread_id) = thread_id {
body["message_thread_id"] = serde_json::Value::String(thread_id);
}
body
}
pub(crate) async fn send_typing_action_once(&self, recipient: &str) {
tracing::info!(recipient, "Telegram typing action attempt");
let body = Self::typing_body_for_recipient(recipient);
let has_thread_id = body.get("message_thread_id").is_some();
match self
.http_client()
.post(self.api_url("sendChatAction"))
.json(&body)
.send()
.await
{
Ok(resp) => {
if Self::telegram_api_ok(resp).await {
tracing::info!(recipient, "Telegram typing action sent");
return;
}
tracing::warn!(recipient, "Telegram typing action rejected");
// Some chats can reject thread-scoped chat actions; retry plain chat_id once.
if has_thread_id {
let (chat_id, _) = Self::parse_reply_target(recipient);
let fallback_body = serde_json::json!({
"chat_id": chat_id,
"action": "typing"
});
match self
.http_client()
.post(self.api_url("sendChatAction"))
.json(&fallback_body)
.send()
.await
{
Ok(fallback_resp) => {
if Self::telegram_api_ok(fallback_resp).await {
tracing::warn!(
recipient,
"Telegram typing action accepted after removing message_thread_id"
);
} else {
tracing::warn!(
recipient,
"Telegram typing fallback (without message_thread_id) rejected"
);
}
}
Err(fallback_error) => {
tracing::warn!(
recipient,
%fallback_error,
"Telegram typing fallback request failed"
);
}
}
}
}
Err(error) => {
tracing::warn!(recipient, %error, "Telegram typing action request failed");
}
}
}
pub(crate) fn is_telegram_username_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_'
}
pub(crate) fn find_bot_mention_spans(text: &str, bot_username: &str) -> Vec<(usize, usize)> {
let bot_username = bot_username.trim_start_matches('@');
if bot_username.is_empty() {
return Vec::new();
}
let mut spans = Vec::new();
for (at_idx, ch) in text.char_indices() {
if ch != '@' {
continue;
}
if at_idx > 0 {
let prev = text[..at_idx].chars().next_back().unwrap_or(' ');
if Self::is_telegram_username_char(prev) {
continue;
}
}
let username_start = at_idx + 1;
let mut username_end = username_start;
for (rel_idx, candidate_ch) in text[username_start..].char_indices() {
if Self::is_telegram_username_char(candidate_ch) {
username_end = username_start + rel_idx + candidate_ch.len_utf8();
} else {
break;
}
}
if username_end == username_start {
continue;
}
let mention_username = &text[username_start..username_end];
if mention_username.eq_ignore_ascii_case(bot_username) {
spans.push((at_idx, username_end));
}
}
spans
}
pub(crate) fn contains_bot_mention(text: &str, bot_username: &str) -> bool {
!Self::find_bot_mention_spans(text, bot_username).is_empty()
}
pub(crate) fn normalize_incoming_content(text: &str, bot_username: &str) -> Option<String> {
let spans = Self::find_bot_mention_spans(text, bot_username);
if spans.is_empty() {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
return (!normalized.is_empty()).then_some(normalized);
}
let mut normalized = String::with_capacity(text.len());
let mut cursor = 0;
for (start, end) in spans {
normalized.push_str(&text[cursor..start]);
cursor = end;
}
normalized.push_str(&text[cursor..]);
let normalized = normalized.split_whitespace().collect::<Vec<_>>().join(" ");
(!normalized.is_empty()).then_some(normalized)
}
pub(crate) fn is_group_message(message: &serde_json::Value) -> bool {
message
.get("chat")
.and_then(|c| c.get("type"))
.and_then(|t| t.as_str())
.map(|t| t == "group" || t == "supergroup")
.unwrap_or(false)
}
pub(crate) fn is_user_allowed(&self, username: &str) -> bool {
let identity = Self::normalize_identity(username);
self.allowed_users
.read()
.map(|users| {
users
.iter()
.any(|u| u == "*" || u.eq_ignore_ascii_case(&identity))
})
.unwrap_or(false)
}
pub(crate) fn is_any_user_allowed<'a, I>(&self, identities: I) -> bool
where
I: IntoIterator<Item = &'a str>,
{
identities.into_iter().any(|id| self.is_user_allowed(id))
}
pub(crate) async fn handle_unauthorized_message(&self, update: &serde_json::Value) {
let Some(message) = update.get("message") else {
return;
};
let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else {
return;
};
let username_opt = message
.get("from")
.and_then(|from| from.get("username"))
.and_then(serde_json::Value::as_str);
let username = username_opt.unwrap_or("unknown");
let normalized_username = Self::normalize_identity(username);
let sender_id = message
.get("from")
.and_then(|from| from.get("id"))
.and_then(serde_json::Value::as_i64);
let sender_id_str = sender_id.map(|id| id.to_string());
let normalized_sender_id = sender_id_str.as_deref().map(Self::normalize_identity);
let chat_id = message
.get("chat")
.and_then(|chat| chat.get("id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string());
let Some(chat_id) = chat_id else {
tracing::warn!("Telegram: missing chat_id in message, skipping");
return;
};
let mut identities = vec![normalized_username.as_str()];
if let Some(ref id) = normalized_sender_id {
identities.push(id.as_str());
}
if self.is_any_user_allowed(identities.iter().copied()) {
return;
}
if let Some(code) = Self::extract_bind_code(text) {
if let Some(pairing) = self.pairing.as_ref() {
match pairing.try_pair(code).await {
Ok(Some(_token)) => {
let bind_identity = normalized_sender_id.clone().or_else(|| {
if normalized_username.is_empty() || normalized_username == "unknown" {
None
} else {
Some(normalized_username.clone())
}
});
if let Some(identity) = bind_identity {
self.add_allowed_identity_runtime(&identity);
match self.persist_allowed_identity(&identity).await {
Ok(()) => {
let _ = self
.send(&SendMessage::new(
"✅ Telegram account bound successfully. You can talk to OpenHuman now.",
&chat_id,
))
.await;
tracing::info!(
"Telegram: paired and allowlisted identity={identity}"
);
}
Err(e) => {
tracing::error!(
"Telegram: failed to persist allowlist after bind: {e}"
);
let _ = self
.send(&SendMessage::new(
"⚠️ Bound for this runtime, but failed to persist config. Access may be lost after restart; check config file permissions.",
&chat_id,
))
.await;
}
}
} else {
let _ = self
.send(&SendMessage::new(
"❌ Could not identify your Telegram account. Ensure your account has a username or stable user ID, then retry.",
&chat_id,
))
.await;
}
}
Ok(None) => {
let _ = self
.send(&SendMessage::new(
"❌ Invalid binding code. Ask operator for the latest code and retry.",
&chat_id,
))
.await;
}
Err(lockout_secs) => {
let _ = self
.send(&SendMessage::new(
format!("⏳ Too many invalid attempts. Retry in {lockout_secs}s."),
&chat_id,
))
.await;
}
}
} else {
let _ = self
.send(&SendMessage::new(
"️ Telegram pairing is not active. Ask operator to update allowlist in config.toml.",
&chat_id,
))
.await;
}
return;
}
tracing::warn!(
"Telegram: ignoring message from unauthorized user: username={username}, sender_id={}. \
Allowlist Telegram username (without '@') or numeric user ID.",
sender_id_str.as_deref().unwrap_or("unknown")
);
let _ = self
.send(&SendMessage::new(
"🔐 This bot requires operator approval.\n\nAsk the operator to approve the pairing in the web UI, then send your message again.".to_string(),
&chat_id,
))
.await;
if self.pairing_code_active() {
let _ = self
.send(&SendMessage::new(
"️ If operator provides a one-time pairing code, you can also run `/bind <code>`.",
&chat_id,
))
.await;
}
}
pub(crate) fn parse_update_message(
&self,
update: &serde_json::Value,
) -> Option<ChannelMessage> {
let message = update
.get("message")
.or_else(|| update.get("edited_message"))?;
let text = message.get("text").and_then(serde_json::Value::as_str)?;
let username = message
.get("from")
.and_then(|from| from.get("username"))
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
let sender_id = message
.get("from")
.and_then(|from| from.get("id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string());
let sender_identity = if username == "unknown" {
sender_id.clone().unwrap_or_else(|| "unknown".to_string())
} else {
username.clone()
};
let mut identities = vec![username.as_str()];
if let Some(id) = sender_id.as_deref() {
identities.push(id);
}
if !self.is_any_user_allowed(identities.iter().copied()) {
tracing::debug!(
username = %username,
sender_id = sender_id.as_deref().unwrap_or("none"),
message_len = text.len(),
"[telegram] dropped message: sender not in allowed_users (unauthorized handler may reply)"
);
return None;
}
let is_group = Self::is_group_message(message);
if self.mention_only && is_group {
let bot_username = self.bot_username.lock();
if let Some(ref bot_username) = *bot_username {
if !Self::contains_bot_mention(text, bot_username) {
return None;
}
} else {
return None;
}
}
let chat_id = message
.get("chat")
.and_then(|chat| chat.get("id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string())?;
let message_id = message
.get("message_id")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
// Extract thread/topic ID for forum support
let thread_id = message
.get("message_thread_id")
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string());
// reply_target: chat_id or chat_id:thread_id format
let reply_target = if let Some(tid) = thread_id {
format!("{}:{}", chat_id, tid)
} else {
chat_id.clone()
};
let replied_parent_message_id = message
.get("reply_to_message")
.and_then(|reply| reply.get("message_id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string());
// Telegram "reply" targeting should point to the inbound message itself so the
// assistant response is visibly attached in chat. We still retain the inbound
// parent reference in logs for reply-context diagnostics.
let outbound_reply_to_message_id = Some(message_id.to_string());
tracing::debug!(
chat_id,
message_id,
reply_to_parent = replied_parent_message_id.as_deref().unwrap_or("none"),
"Telegram inbound message parsed for reply mapping"
);
let content = if self.mention_only && is_group {
let bot_username = self.bot_username.lock();
let bot_username = bot_username.as_ref()?;
Self::normalize_incoming_content(text, bot_username)?
} else {
text.to_string()
};
Some(ChannelMessage {
id: format!("telegram_{chat_id}_{message_id}"),
sender: sender_identity,
reply_target,
content,
channel: "telegram".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: outbound_reply_to_message_id,
})
}
pub(crate) fn parse_update_reaction(
&self,
update: &serde_json::Value,
) -> Option<TelegramReactionEvent> {
let reaction = update.get("message_reaction")?;
let chat_id = reaction
.get("chat")
.and_then(|chat| chat.get("id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string())?;
let message_id = reaction
.get("message_id")
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string())?;
let actor = reaction
.get("user")
.and_then(|user| user.get("username"))
.and_then(serde_json::Value::as_str)
.map(ToString::to_string)
.or_else(|| {
reaction
.get("user")
.and_then(|user| user.get("id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string())
})
.unwrap_or_else(|| "unknown".to_string());
let user_id = reaction
.get("user")
.and_then(|user| user.get("id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string());
let actor_allowed = self.is_user_allowed(&actor);
let user_id_allowed = user_id
.as_deref()
.is_some_and(|id| self.is_user_allowed(id));
if !(actor_allowed || user_id_allowed) {
tracing::debug!(
actor,
message_id,
"Telegram reaction ignored: actor is not allowlisted"
);
return None;
}
let emoji = reaction
.get("new_reaction")
.and_then(serde_json::Value::as_array)
.and_then(|arr| {
arr.iter().find_map(|entry| {
entry
.get("emoji")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string)
})
})?;
Some(TelegramReactionEvent {
sender: actor,
reply_target: chat_id,
target_message_id: message_id,
emoji,
})
}
}
@@ -0,0 +1,687 @@
//! Telegram channel — outbound message sending: text chunking, media uploads, reaction sending,
//! and attachment dispatch.
use super::attachments::{
is_http_url, parse_attachment_markers, parse_path_only_attachment, TelegramAttachment,
TelegramAttachmentKind,
};
use super::channel_types::TelegramChannel;
use super::text::split_message_for_telegram;
use crate::core::event_bus::{publish_global, DomainEvent};
use reqwest::multipart::{Form, Part};
use std::path::Path;
use std::time::Duration;
impl TelegramChannel {
pub(crate) fn parse_reaction_marker(content: &str) -> (String, Option<String>) {
// Marker format at the start of the message: [REACTION:😀] or [REACTION:😀|12345]
// The marker may be followed by a text reply: [REACTION:👍] Great point!
// Returns (remaining_text, Some(marker_inner)) or (original, None).
let trimmed = content.trim();
let Some(rest) = trimmed.strip_prefix("[REACTION:") else {
return (content.to_string(), None);
};
let Some(close_pos) = rest.find(']') else {
return (content.to_string(), None);
};
let inner = rest[..close_pos].trim();
if inner.is_empty() {
return (String::new(), None);
}
let remaining = rest[close_pos + 1..].trim().to_string();
(remaining, Some(inner.to_string()))
}
pub(crate) async fn send_message_reaction(
&self,
chat_id: &str,
message_id: i64,
emoji: &str,
) -> anyhow::Result<bool> {
let emoji = emoji.trim();
if emoji.is_empty() {
return Ok(false);
}
let body = serde_json::json!({
"chat_id": chat_id,
"message_id": message_id,
"reaction": [
{
"type": "emoji",
"emoji": emoji
}
],
"is_big": false
});
let resp = self
.http_client()
.post(self.api_url("setMessageReaction"))
.json(&body)
.send()
.await?;
if resp.status().is_success() {
publish_global(DomainEvent::ChannelReactionSent {
channel: "telegram".to_string(),
target_message_id: format!("telegram_{chat_id}_{message_id}"),
emoji: emoji.to_string(),
success: true,
});
tracing::info!(chat_id, message_id, emoji, "Telegram reaction sent");
return Ok(true);
}
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
tracing::warn!(
chat_id,
message_id,
emoji,
status = ?status,
error = err,
"Telegram reaction not applied; continuing without failure"
);
publish_global(DomainEvent::ChannelReactionSent {
channel: "telegram".to_string(),
target_message_id: format!("telegram_{chat_id}_{message_id}"),
emoji: emoji.to_string(),
success: false,
});
Ok(false)
}
pub(crate) async fn send_text_chunks(
&self,
message: &str,
chat_id: &str,
thread_id: Option<&str>,
reply_to_message_id: Option<i64>,
) -> anyhow::Result<()> {
let chunks = split_message_for_telegram(message);
for (index, chunk) in chunks.iter().enumerate() {
let text = if chunks.len() > 1 {
if index == 0 {
format!("{chunk}\n\n(continues...)")
} else if index == chunks.len() - 1 {
format!("(continued)\n\n{chunk}")
} else {
format!("(continued)\n\n{chunk}\n\n(continues...)")
}
} else {
chunk.to_string()
};
let mut markdown_body = serde_json::json!({
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown"
});
// Add message_thread_id for forum topic support
if let Some(tid) = thread_id {
markdown_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
}
if index == 0 {
if let Some(parent_id) = reply_to_message_id {
markdown_body["reply_to_message_id"] = serde_json::Value::from(parent_id);
}
}
let markdown_resp = self
.http_client()
.post(self.api_url("sendMessage"))
.json(&markdown_body)
.send()
.await?;
if markdown_resp.status().is_success() {
if index < chunks.len() - 1 {
tokio::time::sleep(Duration::from_millis(100)).await;
}
continue;
}
let markdown_status = markdown_resp.status();
let markdown_err = markdown_resp.text().await.unwrap_or_default();
tracing::warn!(
status = ?markdown_status,
"Telegram sendMessage with Markdown failed; retrying without parse_mode"
);
let mut plain_body = serde_json::json!({
"chat_id": chat_id,
"text": text,
});
// Add message_thread_id for forum topic support
if let Some(tid) = thread_id {
plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
}
if index == 0 {
if let Some(parent_id) = reply_to_message_id {
plain_body["reply_to_message_id"] = serde_json::Value::from(parent_id);
}
}
let plain_resp = self
.http_client()
.post(self.api_url("sendMessage"))
.json(&plain_body)
.send()
.await?;
if !plain_resp.status().is_success() {
let plain_status = plain_resp.status();
let plain_err = plain_resp.text().await.unwrap_or_default();
anyhow::bail!(
"Telegram sendMessage failed (markdown {}: {}; plain {}: {})",
markdown_status,
markdown_err,
plain_status,
plain_err
);
}
if index < chunks.len() - 1 {
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
Ok(())
}
async fn send_media_by_url(
&self,
method: &str,
media_field: &str,
chat_id: &str,
thread_id: Option<&str>,
url: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
let mut body = serde_json::json!({
"chat_id": chat_id,
});
body[media_field] = serde_json::Value::String(url.to_string());
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::Value::String(tid.to_string());
}
if let Some(cap) = caption {
body["caption"] = serde_json::Value::String(cap.to_string());
}
let resp = self
.http_client()
.post(self.api_url(method))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram {method} by URL failed: {err}");
}
tracing::info!("Telegram {method} sent to {chat_id}: {url}");
Ok(())
}
pub(crate) async fn send_attachment(
&self,
chat_id: &str,
thread_id: Option<&str>,
attachment: &TelegramAttachment,
) -> anyhow::Result<()> {
let target = attachment.target.trim();
if is_http_url(target) {
return match attachment.kind {
TelegramAttachmentKind::Image => {
self.send_photo_by_url(chat_id, thread_id, target, None)
.await
}
TelegramAttachmentKind::Document => {
self.send_document_by_url(chat_id, thread_id, target, None)
.await
}
TelegramAttachmentKind::Video => {
self.send_video_by_url(chat_id, thread_id, target, None)
.await
}
TelegramAttachmentKind::Audio => {
self.send_audio_by_url(chat_id, thread_id, target, None)
.await
}
TelegramAttachmentKind::Voice => {
self.send_voice_by_url(chat_id, thread_id, target, None)
.await
}
};
}
let path = Path::new(target);
if !path.exists() {
anyhow::bail!("Telegram attachment path not found: {target}");
}
match attachment.kind {
TelegramAttachmentKind::Image => self.send_photo(chat_id, thread_id, path, None).await,
TelegramAttachmentKind::Document => {
self.send_document(chat_id, thread_id, path, None).await
}
TelegramAttachmentKind::Video => self.send_video(chat_id, thread_id, path, None).await,
TelegramAttachmentKind::Audio => self.send_audio(chat_id, thread_id, path, None).await,
TelegramAttachmentKind::Voice => self.send_voice(chat_id, thread_id, path, None).await,
}
}
/// Send a document/file to a Telegram chat
pub async fn send_document(
&self,
chat_id: &str,
thread_id: Option<&str>,
file_path: &Path,
caption: Option<&str>,
) -> anyhow::Result<()> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file");
let file_bytes = tokio::fs::read(file_path).await?;
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("document", part);
if let Some(tid) = thread_id {
form = form.text("message_thread_id", tid.to_string());
}
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.http_client()
.post(self.api_url("sendDocument"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument failed: {err}");
}
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a document from bytes (in-memory) to a Telegram chat
pub async fn send_document_bytes(
&self,
chat_id: &str,
thread_id: Option<&str>,
file_bytes: Vec<u8>,
file_name: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("document", part);
if let Some(tid) = thread_id {
form = form.text("message_thread_id", tid.to_string());
}
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.http_client()
.post(self.api_url("sendDocument"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument failed: {err}");
}
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a photo to a Telegram chat
pub async fn send_photo(
&self,
chat_id: &str,
thread_id: Option<&str>,
file_path: &Path,
caption: Option<&str>,
) -> anyhow::Result<()> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("photo.jpg");
let file_bytes = tokio::fs::read(file_path).await?;
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("photo", part);
if let Some(tid) = thread_id {
form = form.text("message_thread_id", tid.to_string());
}
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.http_client()
.post(self.api_url("sendPhoto"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto failed: {err}");
}
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a photo from bytes (in-memory) to a Telegram chat
pub async fn send_photo_bytes(
&self,
chat_id: &str,
thread_id: Option<&str>,
file_bytes: Vec<u8>,
file_name: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("photo", part);
if let Some(tid) = thread_id {
form = form.text("message_thread_id", tid.to_string());
}
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.http_client()
.post(self.api_url("sendPhoto"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto failed: {err}");
}
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a video to a Telegram chat
pub async fn send_video(
&self,
chat_id: &str,
thread_id: Option<&str>,
file_path: &Path,
caption: Option<&str>,
) -> anyhow::Result<()> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("video.mp4");
let file_bytes = tokio::fs::read(file_path).await?;
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("video", part);
if let Some(tid) = thread_id {
form = form.text("message_thread_id", tid.to_string());
}
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.http_client()
.post(self.api_url("sendVideo"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendVideo failed: {err}");
}
tracing::info!("Telegram video sent to {chat_id}: {file_name}");
Ok(())
}
/// Send an audio file to a Telegram chat
pub async fn send_audio(
&self,
chat_id: &str,
thread_id: Option<&str>,
file_path: &Path,
caption: Option<&str>,
) -> anyhow::Result<()> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("audio.mp3");
let file_bytes = tokio::fs::read(file_path).await?;
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("audio", part);
if let Some(tid) = thread_id {
form = form.text("message_thread_id", tid.to_string());
}
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.http_client()
.post(self.api_url("sendAudio"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendAudio failed: {err}");
}
tracing::info!("Telegram audio sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a voice message to a Telegram chat
pub async fn send_voice(
&self,
chat_id: &str,
thread_id: Option<&str>,
file_path: &Path,
caption: Option<&str>,
) -> anyhow::Result<()> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("voice.ogg");
let file_bytes = tokio::fs::read(file_path).await?;
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part("voice", part);
if let Some(tid) = thread_id {
form = form.text("message_thread_id", tid.to_string());
}
if let Some(cap) = caption {
form = form.text("caption", cap.to_string());
}
let resp = self
.http_client()
.post(self.api_url("sendVoice"))
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendVoice failed: {err}");
}
tracing::info!("Telegram voice sent to {chat_id}: {file_name}");
Ok(())
}
/// Send a file by URL (Telegram will download it)
pub async fn send_document_by_url(
&self,
chat_id: &str,
thread_id: Option<&str>,
url: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
let mut body = serde_json::json!({
"chat_id": chat_id,
"document": url
});
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::Value::String(tid.to_string());
}
if let Some(cap) = caption {
body["caption"] = serde_json::Value::String(cap.to_string());
}
let resp = self
.http_client()
.post(self.api_url("sendDocument"))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument by URL failed: {err}");
}
tracing::info!("Telegram document (URL) sent to {chat_id}: {url}");
Ok(())
}
/// Send a photo by URL (Telegram will download it)
pub async fn send_photo_by_url(
&self,
chat_id: &str,
thread_id: Option<&str>,
url: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
let mut body = serde_json::json!({
"chat_id": chat_id,
"photo": url
});
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::Value::String(tid.to_string());
}
if let Some(cap) = caption {
body["caption"] = serde_json::Value::String(cap.to_string());
}
let resp = self
.http_client()
.post(self.api_url("sendPhoto"))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto by URL failed: {err}");
}
tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}");
Ok(())
}
/// Send a video by URL (Telegram will download it)
pub async fn send_video_by_url(
&self,
chat_id: &str,
thread_id: Option<&str>,
url: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
self.send_media_by_url("sendVideo", "video", chat_id, thread_id, url, caption)
.await
}
/// Send an audio file by URL (Telegram will download it)
pub async fn send_audio_by_url(
&self,
chat_id: &str,
thread_id: Option<&str>,
url: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
self.send_media_by_url("sendAudio", "audio", chat_id, thread_id, url, caption)
.await
}
/// Send a voice message by URL (Telegram will download it)
pub async fn send_voice_by_url(
&self,
chat_id: &str,
thread_id: Option<&str>,
url: &str,
caption: Option<&str>,
) -> anyhow::Result<()> {
self.send_media_by_url("sendVoice", "voice", chat_id, thread_id, url, caption)
.await
}
}
@@ -0,0 +1,45 @@
//! Telegram channel — private types and the main struct definition.
use crate::openhuman::config::StreamMode;
use crate::openhuman::security::pairing::PairingGuard;
use parking_lot::Mutex;
use std::collections::{HashSet, VecDeque};
use std::sync::{Arc, RwLock};
pub(crate) const TELEGRAM_RECENT_UPDATE_CACHE_SIZE: usize = 4096;
pub(crate) struct TelegramTypingTask {
pub(crate) recipient: String,
pub(crate) handle: tokio::task::JoinHandle<()>,
}
#[derive(Default)]
pub(crate) struct TelegramUpdateWindow {
pub(crate) max_seen_update_id: i64,
pub(crate) recent_order: VecDeque<i64>,
pub(crate) recent_lookup: HashSet<i64>,
}
#[derive(Debug, Clone)]
pub(crate) struct TelegramReactionEvent {
pub(crate) sender: String,
pub(crate) reply_target: String,
pub(crate) target_message_id: String,
pub(crate) emoji: String,
}
/// Telegram channel — long-polls the Bot API for updates
pub struct TelegramChannel {
pub(crate) bot_token: String,
pub(crate) allowed_users: Arc<RwLock<Vec<String>>>,
pub(crate) pairing: Option<PairingGuard>,
pub(crate) client: reqwest::Client,
pub(crate) typing_handle: Mutex<Option<TelegramTypingTask>>,
pub(crate) stream_mode: StreamMode,
pub(crate) draft_update_interval_ms: u64,
pub(crate) silent_streaming: bool,
pub(crate) last_draft_edit: Mutex<std::collections::HashMap<String, std::time::Instant>>,
pub(crate) mention_only: bool,
pub(crate) bot_username: Mutex<Option<String>>,
pub(crate) recent_updates: Mutex<TelegramUpdateWindow>,
}
@@ -2,6 +2,11 @@
mod attachments;
mod channel;
mod channel_core;
mod channel_ops;
mod channel_recv;
mod channel_send;
mod channel_types;
mod text;
pub use channel::TelegramChannel;
pub use channel_types::TelegramChannel;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,524 @@
//! Curated catalogs — business toolkits: Shopify, Stripe, HubSpot,
//! Salesforce, Airtable, Figma.
use super::tool_scope::{CuratedTool, ToolScope};
// ── shopify ─────────────────────────────────────────────────────────
pub const SHOPIFY_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "SHOPIFY_BULK_QUERY_OPERATION",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SHOPIFY_COUNT_PRODUCTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SHOPIFY_COUNT_ORDERS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SHOPIFY_COUNT_FULFILLMENTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SHOPIFY_COUNT_CUSTOMERS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SHOPIFY_CREATE_ORDER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SHOPIFY_CREATE_PRODUCT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SHOPIFY_CREATE_DRAFT_ORDER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SHOPIFY_CREATE_FULFILLMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SHOPIFY_CREATE_CUSTOMER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SHOPIFY_CREATE_PRICE_RULE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SHOPIFY_ADJUST_INVENTORY_LEVEL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SHOPIFY_CREATE_DISCOUNT_CODE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SHOPIFY_UPDATE_PRODUCT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SHOPIFY_CREATE_CUSTOM_COLLECTION",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SHOPIFY_CANCEL_ORDER",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SHOPIFY_CANCEL_FULFILLMENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SHOPIFY_DELETE_PRODUCT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SHOPIFY_BULK_DELETE_CUSTOMER_ADDRESSES",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SHOPIFY_BULK_DELETE_METAFIELDS",
scope: ToolScope::Admin,
},
];
// ── stripe ──────────────────────────────────────────────────────────
pub const STRIPE_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "STRIPE_GET_PAYMENT_INTENT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "STRIPE_LIST_INVOICES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "STRIPE_GET_CUSTOMER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "STRIPE_LIST_CHARGES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "STRIPE_GET_SUBSCRIPTION",
scope: ToolScope::Read,
},
CuratedTool {
slug: "STRIPE_CREATE_PAYMENT_INTENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "STRIPE_CREATE_INVOICE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "STRIPE_CREATE_CUSTOMER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "STRIPE_CREATE_CUSTOMER_SUBSCRIPTION",
scope: ToolScope::Write,
},
CuratedTool {
slug: "STRIPE_CREATE_CHECKOUT_SESSION",
scope: ToolScope::Write,
},
CuratedTool {
slug: "STRIPE_CONFIRM_PAYMENT_INTENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "STRIPE_CAPTURE_PAYMENT_INTENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "STRIPE_ATTACH_PAYMENT_METHOD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "STRIPE_CANCEL_SUBSCRIPTION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "STRIPE_CANCEL_PAYMENT_INTENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "STRIPE_CREATE_CHARGE_REFUND",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "STRIPE_CLOSE_DISPUTE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "STRIPE_CANCEL_SETUP_INTENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "STRIPE_ARCHIVE_BILLING_ALERT",
scope: ToolScope::Admin,
},
];
// ── hubspot ─────────────────────────────────────────────────────────
pub const HUBSPOT_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "HUBSPOT_GET_CONTACTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "HUBSPOT_SEARCH_CONTACTS_BY_CRITERIA",
scope: ToolScope::Read,
},
CuratedTool {
slug: "HUBSPOT_LIST_CONTACTS_PAGE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "HUBSPOT_GET_COMPANIES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "HUBSPOT_GET_DEALS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "HUBSPOT_GET_CRM_OBJECT_BY_ID",
scope: ToolScope::Read,
},
CuratedTool {
slug: "HUBSPOT_BATCH_READ_COMPANIES_BY_PROPERTIES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "HUBSPOT_CREATE_CONTACT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "HUBSPOT_CREATE_COMPANY",
scope: ToolScope::Write,
},
CuratedTool {
slug: "HUBSPOT_CREATE_DEAL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "HUBSPOT_CREATE_CONTACTS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "HUBSPOT_UPDATE_CONTACT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "HUBSPOT_UPDATE_COMPANY",
scope: ToolScope::Write,
},
CuratedTool {
slug: "HUBSPOT_CREATE_OBJECT_ASSOCIATION",
scope: ToolScope::Write,
},
CuratedTool {
slug: "HUBSPOT_CREATE_A_NEW_MARKETING_EMAIL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "HUBSPOT_CREATE_BATCH_OF_OBJECTS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "HUBSPOT_BATCH_UPDATE_QUOTES",
scope: ToolScope::Write,
},
CuratedTool {
slug: "HUBSPOT_ARCHIVE_CONTACT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "HUBSPOT_ARCHIVE_COMPANY",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "HUBSPOT_ARCHIVE_DEAL",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "HUBSPOT_ARCHIVE_CONTACTS",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "HUBSPOT_ARCHIVE_COMPANIES",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "HUBSPOT_ARCHIVE_DEALS",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "HUBSPOT_ARCHIVE_CRM_OBJECT_BY_ID",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "HUBSPOT_ARCHIVE_PROPERTY_BY_OBJECT_TYPE_AND_NAME",
scope: ToolScope::Admin,
},
];
// ── salesforce ──────────────────────────────────────────────────────
pub const SALESFORCE_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "SALESFORCE_RUN_SOQL_QUERY",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SALESFORCE_EXECUTE_SOSL_SEARCH",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SALESFORCE_GET_ACCOUNT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SALESFORCE_GET_CAMPAIGN",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SALESFORCE_GET_ALL_FIELDS_FOR_OBJECT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SALESFORCE_GET_ALL_CUSTOM_OBJECTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SALESFORCE_CREATE_ACCOUNT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_CREATE_CONTACT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_CREATE_LEAD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_CREATE_OPPORTUNITY",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_CREATE_CAMPAIGN",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_CREATE_TASK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_UPDATE_ACCOUNT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_UPDATE_CONTACT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_UPDATE_OPPORTUNITY",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_ADD_OPPORTUNITY_LINE_ITEM",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_ADD_CONTACT_TO_CAMPAIGN",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_ADD_LEAD_TO_CAMPAIGN",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_ASSOCIATE_CONTACT_TO_ACCOUNT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_CLONE_OPPORTUNITY_WITH_PRODUCTS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SALESFORCE_DELETE_ACCOUNT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SALESFORCE_DELETE_CONTACT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SALESFORCE_DELETE_LEAD",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SALESFORCE_DELETE_OPPORTUNITY",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SALESFORCE_DELETE_CAMPAIGN",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SALESFORCE_DELETE_SOBJECT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SALESFORCE_DELETE_SOBJECT_COLLECTIONS",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SALESFORCE_CREATE_CUSTOM_FIELD",
scope: ToolScope::Admin,
},
];
// ── airtable ────────────────────────────────────────────────────────
pub const AIRTABLE_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "AIRTABLE_LIST_RECORDS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "AIRTABLE_GET_RECORD",
scope: ToolScope::Read,
},
CuratedTool {
slug: "AIRTABLE_GET_BASE_SCHEMA",
scope: ToolScope::Read,
},
CuratedTool {
slug: "AIRTABLE_LIST_BASES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "AIRTABLE_LIST_COMMENTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "AIRTABLE_CREATE_RECORDS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "AIRTABLE_UPDATE_RECORD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "AIRTABLE_UPDATE_MULTIPLE_RECORDS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "AIRTABLE_CREATE_FIELD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "AIRTABLE_CREATE_TABLE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "AIRTABLE_CREATE_COMMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "AIRTABLE_UPLOAD_ATTACHMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "AIRTABLE_UPDATE_FIELD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "AIRTABLE_UPDATE_TABLE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "AIRTABLE_DELETE_RECORD",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "AIRTABLE_DELETE_MULTIPLE_RECORDS",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "AIRTABLE_DELETE_COMMENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "AIRTABLE_CREATE_BASE",
scope: ToolScope::Admin,
},
];
// ── figma ───────────────────────────────────────────────────────────
pub const FIGMA_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "FIGMA_GET_FILE_JSON",
scope: ToolScope::Read,
},
CuratedTool {
slug: "FIGMA_GET_FILE_NODES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "FIGMA_GET_COMMENTS_IN_A_FILE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "FIGMA_GET_CURRENT_USER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "FIGMA_DISCOVER_FIGMA_RESOURCES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "FIGMA_GET_FILE_COMPONENTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "FIGMA_GET_LOCAL_VARIABLES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "FIGMA_EXTRACT_DESIGN_TOKENS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "FIGMA_ADD_A_COMMENT_TO_A_FILE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "FIGMA_CREATE_DEV_RESOURCES",
scope: ToolScope::Write,
},
CuratedTool {
slug: "FIGMA_CREATE_MODIFY_DELETE_VARIABLES",
scope: ToolScope::Write,
},
CuratedTool {
slug: "FIGMA_DELETE_A_COMMENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "FIGMA_DELETE_A_WEBHOOK",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "FIGMA_DELETE_DEV_RESOURCE",
scope: ToolScope::Admin,
},
];
@@ -0,0 +1,360 @@
//! Curated catalogs — Google toolkits: GoogleCalendar, GoogleDrive,
//! GoogleDocs, GoogleSheets.
use super::tool_scope::{CuratedTool, ToolScope};
// ── googlecalendar ──────────────────────────────────────────────────
pub const GOOGLECALENDAR_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "GOOGLECALENDAR_EVENTS_LIST",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLECALENDAR_FIND_EVENT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLECALENDAR_LIST_CALENDARS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLECALENDAR_EVENTS_GET",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLECALENDAR_FIND_FREE_SLOTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLECALENDAR_GET_CALENDAR",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLECALENDAR_EVENTS_LIST_ALL_CALENDARS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLECALENDAR_CREATE_EVENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLECALENDAR_UPDATE_EVENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLECALENDAR_PATCH_EVENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLECALENDAR_QUICK_ADD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLECALENDAR_EVENTS_MOVE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLECALENDAR_REMOVE_ATTENDEE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLECALENDAR_EVENTS_IMPORT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLECALENDAR_DELETE_EVENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLECALENDAR_CLEAR_CALENDAR",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLECALENDAR_CALENDARS_DELETE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLECALENDAR_DUPLICATE_CALENDAR",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLECALENDAR_PATCH_CALENDAR",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLECALENDAR_ACL_INSERT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLECALENDAR_ACL_DELETE",
scope: ToolScope::Admin,
},
];
// ── googledrive ─────────────────────────────────────────────────────
pub const GOOGLEDRIVE_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "GOOGLEDRIVE_FIND_FILE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLEDRIVE_LIST_FILES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLEDRIVE_GET_FILE_METADATA",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLEDRIVE_DOWNLOAD_FILE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLEDRIVE_LIST_PERMISSIONS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLEDRIVE_FIND_FOLDER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLEDRIVE_GET_ABOUT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLEDRIVE_CREATE_FILE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDRIVE_CREATE_FOLDER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDRIVE_UPLOAD_FILE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDRIVE_CREATE_FILE_FROM_TEXT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDRIVE_COPY_FILE_ADVANCED",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDRIVE_MOVE_FILE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDRIVE_EDIT_FILE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDRIVE_RENAME_FILE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDRIVE_CREATE_PERMISSION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLEDRIVE_DELETE_PERMISSION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLEDRIVE_UPDATE_PERMISSION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLEDRIVE_DELETE_FILE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLEDRIVE_GOOGLE_DRIVE_DELETE_FOLDER_OR_FILE_ACTION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLEDRIVE_EMPTY_TRASH",
scope: ToolScope::Admin,
},
];
// ── googledocs ──────────────────────────────────────────────────────
pub const GOOGLEDOCS_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "GOOGLEDOCS_GET_DOCUMENT_BY_ID",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLEDOCS_SEARCH_DOCUMENTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLEDOCS_CREATE_DOCUMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_CREATE_DOCUMENT_MARKDOWN",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_INSERT_TEXT_ACTION",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_INSERT_TABLE_ACTION",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_INSERT_INLINE_IMAGE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_UPDATE_EXISTING_DOCUMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_UPDATE_DOCUMENT_SECTION_MARKDOWN",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_REPLACE_ALL_TEXT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_COPY_DOCUMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_CREATE_HEADER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_CREATE_FOOTER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLEDOCS_DELETE_CONTENT_RANGE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLEDOCS_DELETE_HEADER",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLEDOCS_DELETE_FOOTER",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLEDOCS_DELETE_NAMED_RANGE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLEDOCS_DELETE_TABLE_ROW",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLEDOCS_DELETE_TABLE_COLUMN",
scope: ToolScope::Admin,
},
];
// ── googlesheets ────────────────────────────────────────────────────
pub const GOOGLESHEETS_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "GOOGLESHEETS_BATCH_GET",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLESHEETS_VALUES_GET",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLESHEETS_LOOKUP_SPREADSHEET_ROW",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLESHEETS_GET_SPREADSHEET_INFO",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLESHEETS_GET_SHEET_NAMES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLESHEETS_SEARCH_SPREADSHEETS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GOOGLESHEETS_VALUES_UPDATE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_UPDATE_VALUES_BATCH",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_SPREADSHEETS_VALUES_APPEND",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_UPSERT_ROWS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_CREATE_GOOGLE_SHEET1",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_ADD_SHEET",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_CREATE_SPREADSHEET_ROW",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_CREATE_SPREADSHEET_COLUMN",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_FIND_REPLACE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_FORMAT_CELL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_SET_DATA_VALIDATION_RULE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GOOGLESHEETS_SPREADSHEETS_VALUES_BATCH_CLEAR",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLESHEETS_DELETE_SHEET",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLESHEETS_DELETE_DIMENSION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLESHEETS_UPDATE_SHEET_PROPERTIES",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GOOGLESHEETS_UPDATE_SPREADSHEET_PROPERTIES",
scope: ToolScope::Admin,
},
];
@@ -0,0 +1,372 @@
//! Curated catalogs — messaging toolkits: Slack, Discord, Telegram,
//! WhatsApp, Microsoft Teams.
use super::tool_scope::{CuratedTool, ToolScope};
// ── slack ───────────────────────────────────────────────────────────
pub const SLACK_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "SLACK_FIND_CHANNELS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SLACK_FIND_USERS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SLACK_FETCH_CONVERSATION_HISTORY",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SLACK_LIST_ALL_CHANNELS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SLACK_LIST_ALL_USERS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SLACK_LIST_CONVERSATIONS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SLACK_FETCH_TEAM_INFO",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SLACK_GET_USER_PRESENCE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SLACK_ASSISTANT_SEARCH_CONTEXT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SLACK_SEND_MESSAGE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SLACK_POST_MESSAGE_TO_CHANNEL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SLACK_SEND_MESSAGE_TO_CHANNEL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SLACK_CREATE_CHANNEL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SLACK_ADD_REACTION_TO_AN_ITEM",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SLACK_UPLOAD_FILE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SLACK_CREATE_A_REMINDER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SLACK_CREATE_USER_GROUP",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SLACK_DELETE_CHANNEL",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SLACK_ARCHIVE_CONVERSATION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SLACK_DELETE_FILE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SLACK_DELETE_REMINDER",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SLACK_LEAVE_CONVERSATION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SLACK_INVITE_USER_TO_WORKSPACE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SLACK_CONVERT_CHANNEL_TO_PRIVATE",
scope: ToolScope::Admin,
},
];
// ── discord ─────────────────────────────────────────────────────────
pub const DISCORD_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "DISCORD_GET_MY_USER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DISCORD_GET_USER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DISCORD_LIST_MY_GUILDS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DISCORD_GET_MY_GUILD_MEMBER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DISCORD_INVITE_RESOLVE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DISCORD_GET_GUILD_WIDGET",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DISCORD_LIST_MY_CONNECTIONS",
scope: ToolScope::Read,
},
];
// ── telegram ────────────────────────────────────────────────────────
pub const TELEGRAM_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "TELEGRAM_GET_UPDATES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TELEGRAM_GET_CHAT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TELEGRAM_GET_CHAT_HISTORY",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TELEGRAM_GET_CHAT_MEMBER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TELEGRAM_GET_CHAT_MEMBERS_COUNT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TELEGRAM_GET_CHAT_ADMINISTRATORS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TELEGRAM_GET_ME",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TELEGRAM_SEND_MESSAGE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TELEGRAM_SEND_PHOTO",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TELEGRAM_SEND_DOCUMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TELEGRAM_SEND_LOCATION",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TELEGRAM_SEND_POLL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TELEGRAM_FORWARD_MESSAGE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TELEGRAM_EDIT_MESSAGE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TELEGRAM_ANSWER_CALLBACK_QUERY",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TELEGRAM_DELETE_MESSAGE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TELEGRAM_CREATE_CHAT_INVITE_LINK",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TELEGRAM_SET_MY_COMMANDS",
scope: ToolScope::Admin,
},
];
// ── whatsapp ────────────────────────────────────────────────────────
pub const WHATSAPP_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "WHATSAPP_GET_PHONE_NUMBERS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "WHATSAPP_GET_MESSAGE_TEMPLATES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "WHATSAPP_GET_PHONE_NUMBER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "WHATSAPP_GET_BUSINESS_PROFILE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "WHATSAPP_GET_TEMPLATE_STATUS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "WHATSAPP_GET_MEDIA_INFO",
scope: ToolScope::Read,
},
CuratedTool {
slug: "WHATSAPP_SEND_MESSAGE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "WHATSAPP_SEND_TEMPLATE_MESSAGE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "WHATSAPP_SEND_MEDIA",
scope: ToolScope::Write,
},
CuratedTool {
slug: "WHATSAPP_SEND_MEDIA_BY_ID",
scope: ToolScope::Write,
},
CuratedTool {
slug: "WHATSAPP_SEND_INTERACTIVE_BUTTONS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "WHATSAPP_SEND_INTERACTIVE_LIST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "WHATSAPP_UPLOAD_MEDIA",
scope: ToolScope::Write,
},
CuratedTool {
slug: "WHATSAPP_CREATE_MESSAGE_TEMPLATE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "WHATSAPP_DELETE_MESSAGE_TEMPLATE",
scope: ToolScope::Admin,
},
];
// ── microsoft_teams ─────────────────────────────────────────────────
pub const MICROSOFT_TEAMS_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "MICROSOFT_TEAMS_GET_CHAT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_GET_CHANNEL",
scope: ToolScope::Read,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_GET_TEAM_FROM_GROUP",
scope: ToolScope::Read,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_CHATS_GET_ALL_CHATS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_GET_PRESENCE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_GET_ONLINE_MEETING",
scope: ToolScope::Read,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_GET_SCHEDULE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_CREATE_CHANNEL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_CREATE_TEAM",
scope: ToolScope::Write,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_CREATE_MEETING",
scope: ToolScope::Write,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_ADD_TEAM_MEMBER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_ADD_CHAT_MEMBER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_CREATE_SHIFT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_CREATE_TIME_OFF_REQUEST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_DELETE_TEAM",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_DELETE_CHANNEL",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_ARCHIVE_TEAM",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_ARCHIVE_CHANNEL",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_DELETE_TAB",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "MICROSOFT_TEAMS_DELETE_TIME_OFF",
scope: ToolScope::Admin,
},
];
@@ -0,0 +1,552 @@
//! Curated catalogs — productivity toolkits: Outlook, Linear, Jira,
//! Trello, Asana, Dropbox.
use super::tool_scope::{CuratedTool, ToolScope};
// ── outlook ─────────────────────────────────────────────────────────
pub const OUTLOOK_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "OUTLOOK_GET_MESSAGE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "OUTLOOK_LIST_MESSAGES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "OUTLOOK_SEARCH_MESSAGES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "OUTLOOK_LIST_CALENDARS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "OUTLOOK_LIST_CALENDAR_EVENTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "OUTLOOK_GET_CALENDAR_EVENT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "OUTLOOK_LIST_CONTACTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "OUTLOOK_LIST_MAIL_FOLDERS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "OUTLOOK_SEND_EMAIL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "OUTLOOK_CREATE_DRAFT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "OUTLOOK_SEND_DRAFT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "OUTLOOK_CREATE_DRAFT_REPLY",
scope: ToolScope::Write,
},
CuratedTool {
slug: "OUTLOOK_CREATE_ME_FORWARD_DRAFT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "OUTLOOK_CALENDAR_CREATE_EVENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "OUTLOOK_CREATE_CONTACT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "OUTLOOK_CREATE_MAIL_FOLDER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "OUTLOOK_DELETE_MESSAGE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "OUTLOOK_BATCH_MOVE_MESSAGES",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "OUTLOOK_BATCH_UPDATE_MESSAGES",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "OUTLOOK_ACCEPT_EVENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "OUTLOOK_CANCEL_EVENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "OUTLOOK_CREATE_ME_CALENDAR_PERMISSION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "OUTLOOK_CREATE_EMAIL_RULE",
scope: ToolScope::Admin,
},
];
// ── linear ──────────────────────────────────────────────────────────
pub const LINEAR_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "LINEAR_LIST_LINEAR_ISSUES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "LINEAR_GET_LINEAR_ISSUE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "LINEAR_LIST_LINEAR_TEAMS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "LINEAR_LIST_LINEAR_PROJECTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "LINEAR_LIST_LINEAR_STATES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "LINEAR_SEARCH_ISSUES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "LINEAR_GET_CYCLES_BY_TEAM_ID",
scope: ToolScope::Read,
},
CuratedTool {
slug: "LINEAR_LIST_LINEAR_USERS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "LINEAR_LIST_LINEAR_LABELS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "LINEAR_GET_LINEAR_PROJECT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "LINEAR_CREATE_LINEAR_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "LINEAR_UPDATE_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "LINEAR_CREATE_LINEAR_COMMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "LINEAR_CREATE_ATTACHMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "LINEAR_CREATE_LINEAR_PROJECT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "LINEAR_CREATE_LINEAR_LABEL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "LINEAR_UPDATE_LINEAR_COMMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "LINEAR_CREATE_ISSUE_RELATION",
scope: ToolScope::Write,
},
CuratedTool {
slug: "LINEAR_UPDATE_LINEAR_PROJECT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "LINEAR_DELETE_LINEAR_ISSUE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "LINEAR_REMOVE_ISSUE_LABEL",
scope: ToolScope::Admin,
},
];
// ── jira ────────────────────────────────────────────────────────────
pub const JIRA_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "JIRA_GET_ISSUE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "JIRA_GET_ALL_PROJECTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "JIRA_FETCH_BULK_ISSUES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "JIRA_GET_ISSUE_TYPES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "JIRA_GET_PROJECT_ROLES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "JIRA_FIND_USERS2",
scope: ToolScope::Read,
},
CuratedTool {
slug: "JIRA_GET_FIELDS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "JIRA_GET_ISSUE_EDIT_METADATA",
scope: ToolScope::Read,
},
CuratedTool {
slug: "JIRA_GET_PROJECT_VERSIONS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "JIRA_CREATE_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "JIRA_BULK_CREATE_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "JIRA_EDIT_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "JIRA_ADD_COMMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "JIRA_ASSIGN_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "JIRA_ADD_ATTACHMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "JIRA_CREATE_ISSUE_LINK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "JIRA_ADD_WORKLOG",
scope: ToolScope::Write,
},
CuratedTool {
slug: "JIRA_TRANSITION_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "JIRA_DELETE_ISSUE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "JIRA_DELETE_COMMENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "JIRA_DELETE_VERSION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "JIRA_DELETE_WORKLOG",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "JIRA_CREATE_PROJECT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "JIRA_ADD_USERS_TO_PROJECT_ROLE",
scope: ToolScope::Admin,
},
];
// ── trello ──────────────────────────────────────────────────────────
pub const TRELLO_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "TRELLO_GET_BOARDS_BY_ID_BOARD",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TRELLO_GET_ACTIONS_BY_ID_ACTION",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TRELLO_GET_BATCH",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TRELLO_GET_BOARDS_ACTIONS_BY_ID_BOARD",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TRELLO_GET_MEMBERS_BOARDS_BY_ID_MEMBER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TRELLO_ADD_CARDS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TRELLO_ADD_BOARDS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TRELLO_ADD_LISTS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TRELLO_ADD_CARDS_ACTIONS_COMMENTS_BY_ID_CARD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TRELLO_ADD_MEMBER_TO_CARD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TRELLO_CREATE_CARD_LABEL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TRELLO_ADD_CARDS_ATTACHMENTS_BY_ID_CARD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TRELLO_ADD_CARDS_CHECKLISTS_BY_ID_CARD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TRELLO_CREATE_WEBHOOK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TRELLO_DELETE_CARDS_BY_ID_CARD",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TRELLO_DELETE_BOARD",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TRELLO_DELETE_CHECKLISTS_BY_ID_CHECKLIST",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TRELLO_ARCHIVE_ALL_LIST_CARDS",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TRELLO_DELETE_CARD_COMMENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TRELLO_DELETE_LABELS_BY_ID_LABEL",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TRELLO_DELETE_ORGANIZATIONS_BY_ID_ORG",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TRELLO_DELETE_WEBHOOKS_BY_ID_WEBHOOK",
scope: ToolScope::Admin,
},
];
// ── asana ───────────────────────────────────────────────────────────
pub const ASANA_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "ASANA_GET_A_TASK",
scope: ToolScope::Read,
},
CuratedTool {
slug: "ASANA_GET_A_PROJECT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "ASANA_GET_MULTIPLE_TASKS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "ASANA_GET_MULTIPLE_PROJECTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "ASANA_GET_CURRENT_USER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "ASANA_GET_MULTIPLE_WORKSPACES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "ASANA_GET_PORTFOLIO",
scope: ToolScope::Read,
},
CuratedTool {
slug: "ASANA_GET_GOALS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "ASANA_GET_CUSTOM_FIELDS_FOR_WORKSPACE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "ASANA_CREATE_A_TASK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "ASANA_CREATE_A_PROJECT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "ASANA_CREATE_SUBTASK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "ASANA_CREATE_TASK_COMMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "ASANA_UPDATE_A_TASK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "ASANA_ADD_FOLLOWERS_TO_TASK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "ASANA_ADD_TAG_TO_TASK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "ASANA_ADD_PROJECT_FOR_TASK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "ASANA_ADD_TASK_DEPENDENCIES",
scope: ToolScope::Write,
},
CuratedTool {
slug: "ASANA_CREATE_ATTACHMENT_FOR_TASK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "ASANA_DELETE_TASK",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "ASANA_DELETE_PROJECT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "ASANA_DELETE_SECTION",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "ASANA_DELETE_TAG",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "ASANA_DELETE_CUSTOM_FIELD",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "ASANA_DELETE_MEMBERSHIP",
scope: ToolScope::Admin,
},
];
// ── dropbox ─────────────────────────────────────────────────────────
pub const DROPBOX_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "DROPBOX_GET_METADATA",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DROPBOX_FILES_SEARCH",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DROPBOX_LIST_FILE_MEMBERS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DROPBOX_GET_SHARED_LINK_METADATA",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DROPBOX_GET_ABOUT_ME",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DROPBOX_GET_SPACE_USAGE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "DROPBOX_ALPHA_UPLOAD_FILE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "DROPBOX_CREATE_FOLDER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "DROPBOX_COPY_FILE_OR_FOLDER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "DROPBOX_CREATE_SHARED_LINK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "DROPBOX_ADD_FILE_MEMBER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "DROPBOX_DELETE_FILE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "DROPBOX_DELETE_BATCH",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "DROPBOX_ADD_TEAM_MEMBERS",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "DROPBOX_CREATE_TEAM_FOLDER",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "DROPBOX_ARCHIVE_TEAM_FOLDER",
scope: ToolScope::Admin,
},
];
@@ -0,0 +1,248 @@
//! Curated catalogs — social media / entertainment toolkits: Twitter,
//! Spotify, YouTube.
use super::tool_scope::{CuratedTool, ToolScope};
// ── twitter ─────────────────────────────────────────────────────────
pub const TWITTER_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "TWITTER_RECENT_SEARCH",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TWITTER_GET_USER_BY_ID",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TWITTER_POST_LOOKUP_BY_POST_ID",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TWITTER_FOLLOWERS_BY_USER_ID",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TWITTER_FOLLOWING_BY_USER_ID",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TWITTER_BOOKMARKS_BY_USER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TWITTER_GET_LIST_MEMBERS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TWITTER_FULL_ARCHIVE_SEARCH",
scope: ToolScope::Read,
},
CuratedTool {
slug: "TWITTER_CREATION_OF_A_POST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TWITTER_RETWEET_POST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TWITTER_ADD_POST_TO_BOOKMARKS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TWITTER_FOLLOW_USER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TWITTER_MUTE_USER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TWITTER_CREATE_DM_CONVERSATION",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TWITTER_CREATE_LIST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TWITTER_ADD_LIST_MEMBER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "TWITTER_POST_DELETE_BY_POST_ID",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TWITTER_DELETE_LIST",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TWITTER_REMOVE_LIST_MEMBER",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TWITTER_DELETE_DM",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "TWITTER_REMOVE_POST_FROM_BOOKMARKS",
scope: ToolScope::Admin,
},
];
// ── spotify ─────────────────────────────────────────────────────────
pub const SPOTIFY_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "SPOTIFY_GET_CURRENT_USER_S_PROFILE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SPOTIFY_GET_USER_S_TOP_TRACKS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SPOTIFY_GET_PLAYLIST",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SPOTIFY_GET_PLAYLIST_ITEMS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SPOTIFY_GET_RECENTLY_PLAYED_TRACKS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SPOTIFY_GET_USER_S_SAVED_TRACKS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SPOTIFY_SEARCH_FOR_ITEM",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SPOTIFY_GET_AVAILABLE_DEVICES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "SPOTIFY_ADD_ITEMS_TO_PLAYLIST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SPOTIFY_CREATE_PLAYLIST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SPOTIFY_SAVE_TRACKS_FOR_CURRENT_USER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SPOTIFY_PAUSE_PLAYBACK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SPOTIFY_ADD_ITEM_TO_PLAYBACK_QUEUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SPOTIFY_CHANGE_PLAYLIST_DETAILS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "SPOTIFY_REMOVE_PLAYLIST_ITEMS",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SPOTIFY_REMOVE_USER_S_SAVED_TRACKS",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SPOTIFY_UNFOLLOW_ARTISTS_OR_USERS",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "SPOTIFY_REMOVE_USER_S_SAVED_ALBUMS",
scope: ToolScope::Admin,
},
];
// ── youtube ─────────────────────────────────────────────────────────
pub const YOUTUBE_CURATED: &[CuratedTool] = &[
CuratedTool {
slug: "YOUTUBE_SEARCH_YOU_TUBE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "YOUTUBE_LIST_CHANNEL_VIDEOS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "YOUTUBE_GET_CHANNEL_STATISTICS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "YOUTUBE_LIST_COMMENT_THREADS2",
scope: ToolScope::Read,
},
CuratedTool {
slug: "YOUTUBE_LIST_COMMENTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "YOUTUBE_GET_VIDEO_DETAILS_BATCH",
scope: ToolScope::Read,
},
CuratedTool {
slug: "YOUTUBE_LIST_USER_PLAYLISTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "YOUTUBE_LIST_PLAYLIST_ITEMS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "YOUTUBE_UPLOAD_VIDEO",
scope: ToolScope::Write,
},
CuratedTool {
slug: "YOUTUBE_UPDATE_VIDEO",
scope: ToolScope::Write,
},
CuratedTool {
slug: "YOUTUBE_CREATE_PLAYLIST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "YOUTUBE_ADD_VIDEO_TO_PLAYLIST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "YOUTUBE_POST_COMMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "YOUTUBE_RATE_VIDEO",
scope: ToolScope::Write,
},
CuratedTool {
slug: "YOUTUBE_UPDATE_PLAYLIST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "YOUTUBE_DELETE_VIDEO",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "YOUTUBE_DELETE_PLAYLIST",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "YOUTUBE_DELETE_COMMENT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "YOUTUBE_DELETE_PLAYLIST_ITEM",
scope: ToolScope::Admin,
},
];
+5
View File
@@ -40,6 +40,11 @@ mod types;
pub mod user_scopes;
pub mod catalogs;
pub mod catalogs_business;
pub mod catalogs_google;
pub mod catalogs_messaging;
pub mod catalogs_productivity;
pub mod catalogs_social_media;
pub mod github;
pub mod gmail;
pub mod notion;
File diff suppressed because it is too large Load Diff
+935
View File
@@ -0,0 +1,935 @@
//! Document parsing helpers: chunking, alias resolution, header/metadata enrichment,
//! and the top-level `parse_document` pipeline.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use serde_json::{json, Map, Value};
use super::ingestion_regex::{
action_item_regex, classify_entity, email_header_regex, explicit_owner_regex,
explicit_preference_regex, graph_fact_regex, named_email_regex, recipient_regex,
sanitize_entity_name, sanitize_fact_text, spatial_regex, will_review_regex,
};
use super::ingestion_types::{
ExtractedEntity, ExtractedRelation, ExtractionAccumulator, ExtractionMode, ExtractionUnit,
MemoryIngestionConfig, ParsedIngestion, RawEntity, RawRelation, DEFAULT_CHUNK_TOKENS,
};
use crate::openhuman::memory::store::types::NamespaceDocumentInput;
use crate::openhuman::memory::UnifiedMemory;
// ── Chunking helpers ──────────────────────────────────────────────────────────
/// Splits a document into individual sentences based on punctuation and line breaks.
pub(super) fn split_sentences(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
for ch in text.chars() {
current.push(ch);
if matches!(ch, '.' | '!' | '?' | '\n') {
let candidate = sanitize_fact_text(&current);
if !candidate.is_empty() {
out.push(candidate);
}
current.clear();
}
}
let tail = sanitize_fact_text(&current);
if !tail.is_empty() {
out.push(tail);
}
let mut merged: Vec<String> = Vec::new();
for sentence in out {
if sentence.len() < 5 && !merged.is_empty() {
if let Some(last) = merged.last_mut() {
last.push(' ');
last.push_str(&sentence);
}
} else {
merged.push(sentence);
}
}
if merged.is_empty() && !text.trim().is_empty() {
merged.push(sanitize_fact_text(text));
}
merged
}
/// Groups chunks into extraction units based on the configured mode.
pub(super) fn build_units(chunks: &[String], mode: ExtractionMode) -> Vec<ExtractionUnit> {
let mut units = Vec::new();
let mut order_index = 0_i64;
for (chunk_index, chunk) in chunks.iter().enumerate() {
match mode {
ExtractionMode::Chunk => {
let text = sanitize_fact_text(chunk);
if text.is_empty() {
continue;
}
units.push(ExtractionUnit {
text,
chunk_index,
order_index,
});
order_index += 1;
}
ExtractionMode::Sentence => {
for sentence in split_sentences(chunk) {
if sentence.is_empty() {
continue;
}
units.push(ExtractionUnit {
text: sentence,
chunk_index,
order_index,
});
order_index += 1;
}
}
}
}
units
}
/// Searches for the chunk index that most likely contains the given excerpt.
pub(super) fn find_chunk_index(chunks: &[String], excerpt: &str, hint: usize) -> usize {
if chunks.is_empty() {
return 0;
}
let needle = UnifiedMemory::normalize_search_text(excerpt);
if needle.is_empty() {
return hint.min(chunks.len().saturating_sub(1));
}
for (index, chunk) in chunks.iter().enumerate().skip(hint) {
if UnifiedMemory::normalize_search_text(chunk).contains(&needle) {
return index;
}
}
for (index, chunk) in chunks.iter().enumerate().take(hint.min(chunks.len())) {
if UnifiedMemory::normalize_search_text(chunk).contains(&needle) {
return index;
}
}
hint.min(chunks.len().saturating_sub(1))
}
// ── Alias resolution ──────────────────────────────────────────────────────────
pub(super) fn reverse_aliases(aliases: &HashMap<String, String>) -> BTreeMap<String, Vec<String>> {
let mut reverse = BTreeMap::new();
for (alias, canonical) in aliases {
if alias == canonical {
continue;
}
reverse
.entry(canonical.clone())
.or_insert_with(Vec::new)
.push(alias.clone());
}
for values in reverse.values_mut() {
values.sort();
values.dedup();
}
reverse
}
pub(super) fn build_alias_map(entities: &HashMap<String, RawEntity>) -> HashMap<String, String> {
let mut by_type = HashMap::<String, Vec<String>>::new();
for entity in entities.values() {
by_type
.entry(entity.entity_type.clone())
.or_default()
.push(entity.name.clone());
}
let mut aliases = HashMap::new();
for names in by_type.values_mut() {
names.sort_by_key(|name| std::cmp::Reverse(name.len()));
for short in names.iter() {
for long in names.iter() {
if short == long || long.len() <= short.len() {
continue;
}
if long.starts_with(&format!("{short} ")) || long.ends_with(&format!(" {short}")) {
aliases.entry(short.clone()).or_insert_with(|| long.clone());
break;
}
}
}
}
aliases
}
pub(super) fn resolve_alias(name: &str, aliases: &HashMap<String, String>) -> String {
let mut current = name.to_string();
let mut seen = BTreeSet::new();
while let Some(next) = aliases.get(&current) {
if !seen.insert(current.clone()) {
break;
}
current = next.clone();
}
current
}
// ── Header / metadata helpers ─────────────────────────────────────────────────
pub(super) fn extract_people_from_header(
value: &str,
accumulator: &mut ExtractionAccumulator,
) -> Vec<String> {
let mut people = Vec::new();
for captures in named_email_regex().captures_iter(value) {
let name = sanitize_fact_text(
captures
.name("name")
.map(|value| value.as_str())
.unwrap_or(""),
);
if name.is_empty() {
continue;
}
let canonical = sanitize_entity_name(&name);
let _ = accumulator.add_entity(&canonical, "PERSON", 0.95);
accumulator.remember_person_aliases(&canonical);
people.push(canonical);
}
people
}
pub(super) fn detect_primary_subject(text: &str) -> Option<String> {
if text.contains("OpenHuman") {
return Some("OPENHUMAN".to_string());
}
None
}
pub(super) fn enrich_document_metadata(
input: &NamespaceDocumentInput,
parsed: &ParsedIngestion,
config: &MemoryIngestionConfig,
) -> (NamespaceDocumentInput, Vec<String>) {
let mut metadata = match input.metadata.clone() {
Value::Object(map) => map,
_ => Map::new(),
};
for (key, value) in parsed.metadata.as_object().cloned().unwrap_or_default() {
metadata.insert(key, value);
}
metadata.insert(
"ingestion".to_string(),
json!({
"backend": "openhuman_rust_heuristic",
"model_name": config.model_name,
"extraction_mode": config.extraction_mode.as_str(),
"entity_count": parsed.entities.len(),
"relation_count": parsed.relations.len(),
"preference_count": parsed.preference_count,
"decision_count": parsed.decision_count,
"chunk_count": parsed.chunk_count,
}),
);
if parsed.preference_count > 0 || parsed.decision_count > 0 {
metadata.insert("kind".to_string(), json!("profile"));
}
let mut tags = input.tags.iter().cloned().collect::<BTreeSet<_>>();
tags.extend(parsed.tags.iter().cloned());
let tags = tags.into_iter().collect::<Vec<_>>();
(
NamespaceDocumentInput {
namespace: input.namespace.clone(),
key: input.key.clone(),
title: input.title.clone(),
content: input.content.clone(),
source_type: input.source_type.clone(),
priority: input.priority.clone(),
tags: tags.clone(),
metadata: Value::Object(metadata),
category: input.category.clone(),
session_id: input.session_id.clone(),
document_id: input.document_id.clone(),
},
tags,
)
}
// ── Top-level document parser ─────────────────────────────────────────────────
pub(super) async fn parse_document(
content: &str,
title: &str,
config: &MemoryIngestionConfig,
) -> ParsedIngestion {
let chunks = UnifiedMemory::chunk_document_content(content, DEFAULT_CHUNK_TOKENS);
log::info!(
"[memory:ingestion] parse_document title={title:?} model={} \
content_len={} chunk_count={} — heuristic extraction active",
config.model_name,
content.len(),
chunks.len(),
);
let mut accumulator = ExtractionAccumulator {
document_title: Some(sanitize_entity_name(title)),
primary_subject: detect_primary_subject(title),
..ExtractionAccumulator::default()
};
let mut chunk_hint = 0_usize;
for raw_line in content.lines() {
let line = sanitize_fact_text(raw_line);
if line.is_empty() {
continue;
}
let chunk_index = find_chunk_index(&chunks, &line, chunk_hint);
chunk_hint = chunk_index;
let order_index = i64::try_from(chunk_index).unwrap_or(i64::MAX);
if raw_line.trim_start().starts_with('#') {
let heading = sanitize_entity_name(raw_line.trim_start_matches('#'));
if !heading.is_empty() {
if accumulator.document_title.is_none() {
accumulator.document_title = Some(heading.clone());
}
accumulator.current_subject = Some(heading);
}
continue;
}
if let Some(captures) = email_header_regex().captures(&line) {
let header_name = captures
.get(1)
.map(|value| value.as_str())
.unwrap_or_default()
.to_ascii_uppercase();
let value = captures
.name("value")
.map(|value| value.as_str())
.unwrap_or("");
let people = extract_people_from_header(value, &mut accumulator);
if header_name == "FROM" {
accumulator.current_sender = people.first().cloned();
} else if header_name == "TO" || header_name == "CC" {
if let Some(sender) = accumulator.current_sender.clone() {
for recipient in &people {
accumulator.add_relation(
&sender,
"PERSON",
"communicates_with",
recipient,
"PERSON",
0.82,
chunk_index,
order_index,
Map::new(),
);
}
}
}
continue;
}
if let Some(subject) = line.strip_prefix("Subject:") {
let subject_text = sanitize_fact_text(subject);
if let Some(primary_subject) = detect_primary_subject(&subject_text) {
accumulator.primary_subject = Some(primary_subject);
}
continue;
}
if let Some(date_text) = line.strip_prefix("Date:") {
let date_text = sanitize_fact_text(date_text);
if let Some(sender) = accumulator.current_sender.clone() {
accumulator.add_relation(
&sender,
"PERSON",
"has_deadline",
&date_text,
"DATE",
0.75,
chunk_index,
order_index,
Map::new(),
);
}
continue;
}
if let Some(value) = line.strip_prefix("Project name:") {
let project = sanitize_entity_name(value);
if !project.is_empty() {
accumulator.primary_subject = Some(project.clone());
let _ = accumulator.add_entity(&project, "PROJECT", 0.96);
}
continue;
}
if let Some(value) = line.strip_prefix("Subproject:") {
let subproject = sanitize_entity_name(value);
if !subproject.is_empty() {
let _ = accumulator.add_entity(&subproject, "PROJECT", 0.92);
}
continue;
}
if let Some(value) = line.strip_prefix("Owner:") {
let owner = sanitize_entity_name(value);
let owned = accumulator
.current_subject
.clone()
.or_else(|| accumulator.primary_subject.clone())
.or_else(|| accumulator.document_title.clone())
.unwrap_or_else(|| "DOCUMENT".to_string());
accumulator.add_relation(
&owner,
"PERSON",
"owns",
&owned,
"WORK_ITEM",
0.94,
chunk_index,
order_index,
Map::new(),
);
continue;
}
if let Some(value) = line.strip_prefix("Name:") {
let name = sanitize_entity_name(value);
if !name.is_empty() {
accumulator.current_subject = Some(name.clone());
let _ = accumulator.add_entity(&name, "WORK_ITEM", 0.93);
}
continue;
}
if let Some(value) = line.strip_prefix("Due date:") {
let due_date = sanitize_fact_text(value);
let subject = accumulator
.current_subject
.clone()
.or_else(|| accumulator.primary_subject.clone())
.unwrap_or_else(|| "DOCUMENT".to_string());
accumulator.add_relation(
&subject,
"WORK_ITEM",
"has_deadline",
&due_date,
"DATE",
0.92,
chunk_index,
order_index,
Map::new(),
);
accumulator.tags.insert("deadline".to_string());
continue;
}
if let Some(value) = line.strip_prefix("Target milestone:") {
let due_date = sanitize_fact_text(value);
let subject = accumulator
.primary_subject
.clone()
.or_else(|| accumulator.document_title.clone())
.unwrap_or_else(|| "DOCUMENT".to_string());
accumulator.add_relation(
&subject,
"PROJECT",
"has_deadline",
&due_date,
"DATE",
0.92,
chunk_index,
order_index,
Map::new(),
);
accumulator.tags.insert("deadline".to_string());
continue;
}
if let Some(value) = line.strip_prefix("Preferred embedding model for local experiments:") {
let model = sanitize_fact_text(value);
let subject = accumulator
.primary_subject
.clone()
.or_else(|| accumulator.document_title.clone())
.unwrap_or_else(|| "DOCUMENT".to_string());
accumulator.add_relation(
&subject,
"PROJECT",
"uses",
&model,
"TOOL",
0.88,
chunk_index,
order_index,
Map::new(),
);
accumulator
.decisions
.insert(format!("{subject} uses {model}"));
accumulator.tags.insert("decision".to_string());
continue;
}
if let Some(value) = line.strip_prefix("Preferred extraction mode to try first:") {
let mode = sanitize_fact_text(value);
let subject = accumulator
.primary_subject
.clone()
.or_else(|| accumulator.document_title.clone())
.unwrap_or_else(|| "DOCUMENT".to_string());
accumulator.add_relation(
&subject,
"PROJECT",
"uses",
&mode,
"MODE",
0.88,
chunk_index,
order_index,
Map::new(),
);
accumulator
.decisions
.insert(format!("{subject} uses {mode}"));
accumulator.tags.insert("decision".to_string());
continue;
}
if let Some(captures) = graph_fact_regex().captures(&line) {
let subject = captures
.name("subject")
.map(|value| value.as_str())
.unwrap_or("");
let predicate = captures
.name("predicate")
.map(|value| value.as_str())
.unwrap_or("");
let object = captures
.name("object")
.map(|value| value.as_str())
.unwrap_or("");
let subject_type = classify_entity(subject, &accumulator.known_people);
let object_type = classify_entity(object, &accumulator.known_people);
accumulator.add_relation(
subject,
subject_type,
predicate,
object,
object_type,
0.87,
chunk_index,
order_index,
Map::new(),
);
if UnifiedMemory::normalize_graph_predicate(predicate) == "PREFERS" {
accumulator.preferences.insert(format!(
"{} prefers {}",
sanitize_entity_name(subject),
sanitize_fact_text(object)
));
accumulator.tags.insert("preference".to_string());
accumulator.doc_kind = Some("profile".to_string());
}
continue;
}
if let Some(captures) = explicit_owner_regex().captures(&line) {
let subject = captures
.name("subject")
.map(|value| value.as_str())
.unwrap_or("");
let object = captures
.name("object")
.map(|value| value.as_str())
.unwrap_or("");
accumulator.add_relation(
subject,
"PERSON",
"owns",
object,
classify_entity(object, &accumulator.known_people),
0.94,
chunk_index,
order_index,
Map::new(),
);
accumulator.tags.insert("owner".to_string());
continue;
}
if let Some(captures) = will_review_regex().captures(&line) {
let subject = captures
.name("subject")
.map(|value| value.as_str())
.unwrap_or("");
let object = captures
.name("object")
.map(|value| value.as_str())
.unwrap_or("");
accumulator.add_relation(
subject,
"PERSON",
"reviews",
object,
classify_entity(object, &accumulator.known_people),
0.80,
chunk_index,
order_index,
Map::new(),
);
accumulator.tags.insert("owner".to_string());
continue;
}
if let Some(captures) = explicit_preference_regex().captures(&line) {
let subject = captures
.name("subject")
.map(|value| value.as_str())
.unwrap_or("");
let object = captures
.name("object")
.map(|value| value.as_str())
.unwrap_or("");
accumulator.add_relation(
subject,
"PERSON",
"prefers",
object,
classify_entity(object, &accumulator.known_people),
0.90,
chunk_index,
order_index,
Map::new(),
);
accumulator.preferences.insert(format!(
"{} prefers {}",
sanitize_entity_name(subject),
sanitize_fact_text(object)
));
accumulator.tags.insert("preference".to_string());
accumulator.doc_kind = Some("profile".to_string());
continue;
}
if let Some(value) = line.strip_prefix("I prefer ") {
if let Some(subject) = accumulator.current_sender.clone() {
let preference = sanitize_fact_text(value);
accumulator.add_relation(
&subject,
"PERSON",
"prefers",
&preference,
classify_entity(&preference, &accumulator.known_people),
0.92,
chunk_index,
order_index,
Map::new(),
);
accumulator
.preferences
.insert(format!("{subject} prefers {preference}"));
accumulator.tags.insert("preference".to_string());
accumulator.doc_kind = Some("profile".to_string());
continue;
}
}
if let Some(captures) = action_item_regex().captures(&line) {
let subject = captures
.name("subject")
.map(|value| value.as_str())
.unwrap_or("");
let object = captures
.name("object")
.map(|value| value.as_str())
.unwrap_or("");
if accumulator
.known_people
.contains_key(&sanitize_entity_name(subject))
|| classify_entity(subject, &accumulator.known_people) == "PERSON"
{
accumulator.add_relation(
subject,
"PERSON",
"owns",
object,
classify_entity(object, &accumulator.known_people),
0.83,
chunk_index,
order_index,
Map::new(),
);
accumulator.tags.insert("owner".to_string());
continue;
}
}
let upper = sanitize_entity_name(&line);
let decision_subject = accumulator
.primary_subject
.clone()
.or_else(|| accumulator.document_title.clone())
.unwrap_or_else(|| "DOCUMENT".to_string());
if upper.contains("JSON-RPC") {
accumulator.add_relation(
&decision_subject,
"PROJECT",
"uses",
"JSON-RPC",
"PRODUCT",
0.86,
chunk_index,
order_index,
Map::new(),
);
accumulator
.decisions
.insert(format!("{decision_subject} uses JSON-RPC"));
accumulator.tags.insert("decision".to_string());
continue;
}
if upper.contains("SHOULD USE NAMESPACE")
|| upper.contains("USE NAMESPACE AS THE STORAGE")
|| upper.contains("NAMESPACE AS THE MAIN SCOPE KEY")
{
accumulator.add_relation(
&decision_subject,
"PROJECT",
"uses",
"namespace",
"TOPIC",
0.84,
chunk_index,
order_index,
Map::new(),
);
accumulator
.decisions
.insert(format!("{decision_subject} uses namespace"));
accumulator.tags.insert("decision".to_string());
continue;
}
if upper.contains("USER_ID") && (upper.contains("DO NOT NEED") || upper.contains("AVOID")) {
accumulator.add_relation(
&decision_subject,
"PROJECT",
"avoids",
"user_id",
"TOPIC",
0.82,
chunk_index,
order_index,
Map::new(),
);
accumulator
.decisions
.insert(format!("{decision_subject} avoids user_id"));
accumulator.tags.insert("decision".to_string());
}
}
for unit in build_units(&chunks, config.extraction_mode) {
if let Some(captures) = recipient_regex().captures(&unit.text) {
let giver = captures
.name("giver")
.map(|value| value.as_str())
.unwrap_or("");
let object = captures
.name("object")
.map(|value| value.as_str())
.unwrap_or("");
let recipient = captures
.name("recipient")
.map(|value| value.as_str())
.unwrap_or("");
accumulator.add_relation(
giver,
"PERSON",
"uses",
object,
classify_entity(object, &accumulator.known_people),
config.adjacency_threshold.max(0.62),
unit.chunk_index,
unit.order_index,
Map::new(),
);
accumulator.add_relation(
recipient,
"PERSON",
"uses",
object,
classify_entity(object, &accumulator.known_people),
(config.adjacency_threshold * 0.9).max(0.55),
unit.chunk_index,
unit.order_index,
Map::new(),
);
}
if let Some(captures) = spatial_regex().captures(&unit.text) {
let head = captures
.name("head")
.map(|value| value.as_str())
.unwrap_or("");
let direction = captures
.name("direction")
.map(|value| value.as_str())
.unwrap_or("");
let tail = captures
.name("tail")
.map(|value| value.as_str())
.unwrap_or("");
let inverse = match direction.to_ascii_lowercase().as_str() {
"north" => "south_of",
"south" => "north_of",
"east" => "west_of",
"west" => "east_of",
_ => "",
};
let predicate = format!("{direction}_of");
accumulator.add_relation(
head,
"ROOM",
&predicate,
tail,
"ROOM",
config.adjacency_threshold.max(0.70),
unit.chunk_index,
unit.order_index,
Map::new(),
);
if !inverse.is_empty() {
accumulator.add_relation(
tail,
"ROOM",
inverse,
head,
"ROOM",
config.adjacency_threshold.max(0.70),
unit.chunk_index,
unit.order_index,
Map::new(),
);
}
}
}
let aliases = build_alias_map(&accumulator.entities);
let reverse_alias = reverse_aliases(&aliases);
let mut canonical_entities = BTreeMap::<String, RawEntity>::new();
for entity in accumulator.entities.values() {
let canonical = resolve_alias(&entity.name, &aliases);
let entry = canonical_entities
.entry(canonical.clone())
.or_insert_with(|| RawEntity {
name: canonical.clone(),
entity_type: entity.entity_type.clone(),
confidence: entity.confidence,
});
if entity.confidence > entry.confidence {
entry.confidence = entity.confidence;
entry.entity_type = entity.entity_type.clone();
}
}
let mut aggregated_relations = BTreeMap::<(String, String, String), _>::new();
for relation in accumulator.relations {
let subject = resolve_alias(&relation.subject, &aliases);
let object = resolve_alias(&relation.object, &aliases);
if subject == object {
continue;
}
let key = (subject.clone(), relation.predicate.clone(), object.clone());
let entry = aggregated_relations
.entry(key)
.or_insert_with(|| RawRelation {
subject,
subject_type: relation.subject_type.clone(),
predicate: relation.predicate.clone(),
object,
object_type: relation.object_type.clone(),
confidence: relation.confidence,
chunk_indexes: relation.chunk_indexes.clone(),
order_index: relation.order_index,
metadata: relation.metadata.clone(),
});
entry.confidence = entry.confidence.max(relation.confidence);
entry.order_index = entry.order_index.min(relation.order_index);
entry.chunk_indexes.extend(relation.chunk_indexes);
}
let entities = canonical_entities
.into_values()
.filter(|entity| entity.confidence >= config.entity_threshold)
.map(|entity| ExtractedEntity {
name: entity.name.clone(),
entity_type: entity.entity_type,
aliases: reverse_alias.get(&entity.name).cloned().unwrap_or_default(),
})
.collect::<Vec<_>>();
let relations = aggregated_relations
.into_values()
.filter(|relation| relation.confidence >= config.relation_threshold)
.map(|relation| ExtractedRelation {
subject: relation.subject,
subject_type: relation.subject_type,
predicate: relation.predicate,
object: relation.object,
object_type: relation.object_type,
confidence: relation.confidence,
evidence_count: u32::try_from(relation.chunk_indexes.len()).unwrap_or(u32::MAX),
chunk_ids: relation
.chunk_indexes
.iter()
.map(|index| format!("chunk:{index}"))
.collect::<Vec<_>>(),
order_index: Some(relation.order_index),
metadata: Value::Object(relation.metadata),
})
.collect::<Vec<_>>();
let mut tags = accumulator.tags.into_iter().collect::<Vec<_>>();
tags.sort();
let metadata = json!({
"kind": accumulator.doc_kind.or_else(|| {
if !accumulator.preferences.is_empty() || !accumulator.decisions.is_empty() {
Some("profile".to_string())
} else {
None
}
}),
"primary_subject": accumulator.primary_subject,
"decisions": accumulator.decisions.iter().cloned().collect::<Vec<_>>(),
"preferences": accumulator.preferences.iter().cloned().collect::<Vec<_>>(),
"extracted_entities": entities.iter().map(|entity| {
json!({
"name": entity.name,
"entity_type": entity.entity_type,
"aliases": entity.aliases,
})
}).collect::<Vec<_>>(),
});
log::debug!(
"[memory:ingestion] parse_document complete title={title:?} \
entities={} relations={} preferences={} decisions={}",
entities.len(),
relations.len(),
accumulator.preferences.len(),
accumulator.decisions.len(),
);
ParsedIngestion {
tags,
metadata,
entities,
relations,
chunk_count: chunks.len(),
preference_count: accumulator.preferences.len(),
decision_count: accumulator.decisions.len(),
}
}
+189
View File
@@ -0,0 +1,189 @@
//! Lazily-initialised regex patterns and text-sanitization helpers for document ingestion.
use std::collections::HashMap;
use std::sync::OnceLock;
use regex::Regex;
use crate::openhuman::memory::UnifiedMemory;
/// Regex for identifying standard email headers (From, To, Cc).
pub(super) fn email_header_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX
.get_or_init(|| Regex::new(r"^(From|To|Cc):\s*(?P<value>.+)$").expect("email header regex"))
}
/// Regex for identifying named email addresses (e.g., "John Doe <john@example.com>").
pub(super) fn named_email_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"(?P<name>[^,<]+?)\s*<(?P<email>[^>]+)>").expect("named email regex")
})
}
/// Regex for identifying explicit graph facts (e.g., "Alice works_on Project-X").
pub(super) fn graph_fact_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
r"^(?P<subject>[A-Za-z0-9][A-Za-z0-9 ._\-/]+?)\s+(?P<predicate>works_on|depends_on|uses|evaluates|owns|prefers)\s+(?P<object>.+)$",
)
.expect("graph fact regex")
})
}
/// Regex for identifying ownership patterns (e.g., "Bob owns the repository").
pub(super) fn explicit_owner_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"^(?P<subject>[A-Za-z][A-Za-z ._-]+?) owns (?P<object>.+)$")
.expect("explicit owner regex")
})
}
/// Regex for identifying preference patterns (e.g., "Carol prefers light mode").
pub(super) fn explicit_preference_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"^(?P<subject>[A-Za-z][A-Za-z ._-]+?) prefers (?P<object>.+)$")
.expect("explicit preference regex")
})
}
/// Regex for identifying action items or assignments (e.g., "Dave: finish the API").
pub(super) fn action_item_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"^(?P<subject>[A-Za-z][A-Za-z ._-]+?):\s*(?P<object>.+)$")
.expect("action item regex")
})
}
/// Regex for identifying review assignments.
pub(super) fn will_review_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"^(?P<subject>[A-Za-z][A-Za-z ._-]+?) will review (?P<object>.+)$")
.expect("will review regex")
})
}
/// Regex for identifying complex giving/receiving interactions.
pub(super) fn recipient_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
r"(?i)(?P<giver>[A-Z][A-Za-z]+(?: [A-Z][A-Za-z]+)?)\s+(gave|sent|handed|passed)\s+(?P<object>.+?)\s+to\s+(?P<recipient>[A-Z][A-Za-z]+(?: [A-Z][A-Za-z]+)?)",
)
.expect("recipient regex")
})
}
/// Regex for identifying spatial relationships (e.g., "Kitchen is north of the Garden").
pub(super) fn spatial_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
r"(?i)(?P<head>[A-Za-z][A-Za-z0-9 _-]+?)\s+is\s+(?P<direction>north|south|east|west)\s+of\s+(?P<tail>[A-Za-z][A-Za-z0-9 _-]+)",
)
.expect("spatial regex")
})
}
/// Regex for identifying dates in "Month DD, YYYY" format.
pub(super) fn month_date_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"(?i)\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\s+\d{1,2},\s+\d{4}\b")
.expect("month date regex")
})
}
/// Regex for identifying ISO-8601 dates (YYYY-MM-DD).
pub(super) fn iso_date_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"\b\d{4}-\d{2}-\d{2}\b").expect("iso date regex"))
}
/// Regex for identifying potential person names (Title Case).
pub(super) fn person_name_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX
.get_or_init(|| Regex::new(r"\b[A-Z][a-z]+(?: [A-Z][a-z]+)+\b").expect("person name regex"))
}
/// Normalizes an entity name by trimming punctuation, collapsing whitespace, and converting to uppercase.
pub(super) fn sanitize_entity_name(name: &str) -> String {
let trimmed = name.trim().trim_matches(|ch: char| {
matches!(ch, '-' | ':' | ';' | ',' | '.' | '"' | '\'' | '(' | ')')
});
if trimmed.is_empty() {
return String::new();
}
UnifiedMemory::collapse_whitespace(trimmed).to_uppercase()
}
/// Normalizes text content by trimming and collapsing whitespace.
pub(super) fn sanitize_fact_text(text: &str) -> String {
let trimmed = text
.trim()
.trim_start_matches('-')
.trim()
.trim_matches(|ch: char| matches!(ch, ':' | ';' | ',' | '.'));
UnifiedMemory::collapse_whitespace(trimmed)
}
/// Heuristically classifies an entity based on its name and known person map.
pub(super) fn classify_entity(name: &str, known_people: &HashMap<String, String>) -> &'static str {
let upper = sanitize_entity_name(name);
if upper.is_empty() {
return "TOPIC";
}
if month_date_regex().is_match(name) || iso_date_regex().is_match(name) {
return "DATE";
}
if upper.contains('@') {
return "ORGANIZATION";
}
if known_people.contains_key(&upper) || person_name_regex().is_match(name) {
return "PERSON";
}
if matches!(
upper.as_str(),
"OPENHUMAN" | "JSON-RPC" | "JSON-RPC 2.0" | "NEOCORTEX_V2" | "NEOCORTEX V2"
) {
return "PRODUCT";
}
if upper.contains("MODEL") {
return "TOOL";
}
if upper.contains("MODE") {
return "MODE";
}
if upper.contains("MILESTONE")
|| upper.contains("ROADMAP")
|| upper.contains("CONTRACT")
|| upper.contains("API")
|| upper.contains("MEMORY")
|| upper.contains("FIXTURE")
|| upper.contains("THREAD")
|| upper.contains("WORK")
{
return "WORK_ITEM";
}
if upper.contains("OFFICE")
|| upper.contains("ROOM")
|| upper.contains("GARDEN")
|| upper.contains("KITCHEN")
{
return "ROOM";
}
if upper.contains("TINYHUMANS") || upper.ends_with("CORE") {
return "ORGANIZATION";
}
if (upper.contains('-') || upper.contains('_')) && !upper.contains(' ') {
return "PROJECT";
}
"TOPIC"
}
+222
View File
@@ -0,0 +1,222 @@
//! Semantic validation rules for knowledge-graph relations and `ExtractionAccumulator` impl.
use std::collections::BTreeSet;
use serde_json::{Map, Value};
use super::ingestion_regex::sanitize_entity_name;
use super::ingestion_types::{ExtractionAccumulator, RawEntity, RawRelation};
use crate::openhuman::memory::UnifiedMemory;
/// A validation rule for semantic relationships.
#[derive(Debug)]
pub(super) struct RelationRule {
/// Canonical predicate name (uppercase snake_case).
pub(super) canonical: &'static str,
/// Allowed classifications for the subject.
pub(super) allowed_head: &'static [&'static str],
/// Allowed classifications for the object.
pub(super) allowed_tail: &'static [&'static str],
}
const PERSON_TYPES: &[&str] = &["PERSON"];
const ORG_TYPES: &[&str] = &[
"ORGANIZATION",
"PROJECT",
"PRODUCT",
"TOOL",
"TOPIC",
"WORK_ITEM",
];
const PLACE_TYPES: &[&str] = &["PLACE", "LOCATION", "ROOM"];
const DATE_TYPES: &[&str] = &["DATE"];
/// Returns the semantic validation rule for a given predicate name.
pub(super) fn relation_rule(predicate: &str) -> Option<RelationRule> {
let normalized = UnifiedMemory::normalize_graph_predicate(predicate);
let rule = match normalized.as_str() {
"OWNS" | "WORKS_ON" | "RESPONSIBLE_FOR" | "REVIEWS" => RelationRule {
canonical: "OWNS",
allowed_head: PERSON_TYPES,
allowed_tail: ORG_TYPES,
},
"USES" | "KEEPS" | "ADOPTS" => RelationRule {
canonical: "USES",
allowed_head: ORG_TYPES,
allowed_tail: ORG_TYPES,
},
"WORKS_FOR" => RelationRule {
canonical: "WORKS_FOR",
allowed_head: PERSON_TYPES,
allowed_tail: &["ORGANIZATION", "PROJECT", "PRODUCT"],
},
"DEPENDS_ON" => RelationRule {
canonical: "DEPENDS_ON",
allowed_head: ORG_TYPES,
allowed_tail: ORG_TYPES,
},
"PREFERS" => RelationRule {
canonical: "PREFERS",
allowed_head: PERSON_TYPES,
allowed_tail: &["TOPIC", "WORK_ITEM", "MODE", "PRODUCT", "TOOL"],
},
"HAS_DEADLINE" | "DUE_ON" => RelationRule {
canonical: "HAS_DEADLINE",
allowed_head: ORG_TYPES,
allowed_tail: DATE_TYPES,
},
"COMMUNICATES_WITH" => RelationRule {
canonical: "COMMUNICATES_WITH",
allowed_head: PERSON_TYPES,
allowed_tail: PERSON_TYPES,
},
"INVESTIGATES" | "EVALUATES" => RelationRule {
canonical: "INVESTIGATES",
allowed_head: PERSON_TYPES,
allowed_tail: ORG_TYPES,
},
"NORTH_OF" => RelationRule {
canonical: "NORTH_OF",
allowed_head: PLACE_TYPES,
allowed_tail: PLACE_TYPES,
},
"SOUTH_OF" => RelationRule {
canonical: "SOUTH_OF",
allowed_head: PLACE_TYPES,
allowed_tail: PLACE_TYPES,
},
"EAST_OF" => RelationRule {
canonical: "EAST_OF",
allowed_head: PLACE_TYPES,
allowed_tail: PLACE_TYPES,
},
"WEST_OF" => RelationRule {
canonical: "WEST_OF",
allowed_head: PLACE_TYPES,
allowed_tail: PLACE_TYPES,
},
"AVOIDS" => RelationRule {
canonical: "AVOIDS",
allowed_head: ORG_TYPES,
allowed_tail: ORG_TYPES,
},
_ => return None,
};
Some(rule)
}
/// Helper to check if a classification is allowed by a rule.
pub(super) fn type_allowed(actual: &str, allowed: &[&str]) -> bool {
allowed.is_empty() || allowed.iter().any(|candidate| candidate == &actual)
}
/// Resolves a person's name using the known alias map.
pub(super) fn resolve_person_alias(
name: &str,
known_people: &std::collections::HashMap<String, String>,
) -> String {
let upper = name.to_uppercase();
known_people.get(&upper).cloned().unwrap_or(upper)
}
impl ExtractionAccumulator {
/// Ingests a full name and its components (e.g., first name) into the alias map.
pub(super) fn remember_person_aliases(&mut self, canonical_name: &str) {
let parts = canonical_name.split_whitespace().collect::<Vec<_>>();
if let Some(first_name) = parts.first() {
self.known_people
.entry(first_name.to_uppercase())
.or_insert_with(|| canonical_name.to_string());
}
}
/// Records a new entity, updating confidence if already known.
pub(super) fn add_entity(
&mut self,
name: &str,
entity_type: &str,
confidence: f32,
) -> Option<String> {
let cleaned = sanitize_entity_name(name);
if cleaned.is_empty() {
return None;
}
let resolved_name = if entity_type == "PERSON" {
resolve_person_alias(&cleaned, &self.known_people)
} else {
cleaned.clone()
};
let entry = self
.entities
.entry(resolved_name.clone())
.or_insert_with(|| RawEntity {
name: resolved_name.clone(),
entity_type: entity_type.to_string(),
confidence,
});
if confidence > entry.confidence {
entry.confidence = confidence;
}
if entity_type == "PERSON" {
self.remember_person_aliases(&resolved_name);
}
Some(resolved_name)
}
/// Records a new relationship, applying semantic validation rules.
#[allow(clippy::too_many_arguments)]
pub(super) fn add_relation(
&mut self,
subject: &str,
subject_type: &str,
predicate: &str,
object: &str,
object_type: &str,
confidence: f32,
chunk_index: usize,
order_index: i64,
metadata: Map<String, Value>,
) {
let Some(rule) = relation_rule(predicate) else {
return;
};
let Some(subject_name) = self.add_entity(subject, subject_type, confidence) else {
return;
};
let Some(object_name) = self.add_entity(object, object_type, confidence) else {
return;
};
if subject_name == object_name {
return;
}
let actual_subject_type = self
.entities
.get(&subject_name)
.map(|value| value.entity_type.as_str())
.unwrap_or(subject_type);
let actual_object_type = self
.entities
.get(&object_name)
.map(|value| value.entity_type.as_str())
.unwrap_or(object_type);
if !type_allowed(actual_subject_type, rule.allowed_head)
|| !type_allowed(actual_object_type, rule.allowed_tail)
{
return;
}
let mut chunk_indexes = BTreeSet::new();
chunk_indexes.insert(chunk_index);
self.relations.push(RawRelation {
subject: subject_name,
subject_type: actual_subject_type.to_string(),
predicate: rule.canonical.to_string(),
object: object_name,
object_type: actual_object_type.to_string(),
confidence,
chunk_indexes,
order_index,
metadata,
});
}
}
+245
View File
@@ -0,0 +1,245 @@
//! Public and private types for the memory ingestion pipeline.
use std::collections::{BTreeSet, HashMap};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::openhuman::memory::store::types::NamespaceDocumentInput;
/// Default extraction backend label reported in ingestion metadata.
pub const DEFAULT_MEMORY_EXTRACTION_MODEL: &str = "heuristic-only";
/// Default number of tokens per text chunk during ingestion.
pub(super) const DEFAULT_CHUNK_TOKENS: usize = 225;
/// Granularity of extraction for heuristic parsing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ExtractionMode {
/// Extract from each individual sentence (higher precision).
#[default]
Sentence,
/// Extract from the entire chunk at once (faster, better for context).
Chunk,
}
impl ExtractionMode {
/// Returns the string representation of the extraction mode.
pub(super) fn as_str(self) -> &'static str {
match self {
Self::Sentence => "sentence",
Self::Chunk => "chunk",
}
}
}
/// Configuration for the memory ingestion process.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryIngestionConfig {
/// Extraction backend label recorded in metadata/results.
pub model_name: String,
/// The granularity of heuristic extraction.
#[serde(default)]
pub extraction_mode: ExtractionMode,
/// Minimum confidence threshold for entity extraction (0.0 to 1.0).
#[serde(default = "default_entity_threshold")]
pub entity_threshold: f32,
/// Minimum confidence threshold for relation extraction (0.0 to 1.0).
#[serde(default = "default_relation_threshold")]
pub relation_threshold: f32,
/// Threshold for adjacency-based heuristics.
#[serde(default = "default_adjacency_threshold")]
pub adjacency_threshold: f32,
/// Reserved batch-size knob kept for config compatibility.
#[serde(default = "default_batch_size")]
pub batch_size: usize,
}
fn default_entity_threshold() -> f32 {
0.45
}
fn default_relation_threshold() -> f32 {
0.30
}
fn default_adjacency_threshold() -> f32 {
0.50
}
fn default_batch_size() -> usize {
16
}
impl Default for MemoryIngestionConfig {
fn default() -> Self {
Self {
model_name: DEFAULT_MEMORY_EXTRACTION_MODEL.to_string(),
extraction_mode: ExtractionMode::Sentence,
entity_threshold: default_entity_threshold(),
relation_threshold: default_relation_threshold(),
adjacency_threshold: default_adjacency_threshold(),
batch_size: default_batch_size(),
}
}
}
/// A request to ingest a single document.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryIngestionRequest {
/// The document input to process.
pub document: NamespaceDocumentInput,
/// Ingestion configuration.
#[serde(default)]
pub config: MemoryIngestionConfig,
}
/// An entity identified during the ingestion process.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtractedEntity {
/// Normalized name of the entity (all-caps).
pub name: String,
/// Classification (e.g., PERSON, ORGANIZATION).
pub entity_type: String,
/// Known aliases for this entity.
#[serde(default)]
pub aliases: Vec<String>,
}
/// A relation identified during the ingestion process.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtractedRelation {
/// Name of the subject entity.
pub subject: String,
/// Classification of the subject.
pub subject_type: String,
/// Relationship type (e.g., OWNS, WORKS_ON).
pub predicate: String,
/// Name of the object entity.
pub object: String,
/// Classification of the object.
pub object_type: String,
/// Extraction confidence (0.0 to 1.0).
pub confidence: f32,
/// Number of distinct occurrences of this relation.
pub evidence_count: u32,
/// IDs of the chunks where this relation was found.
pub chunk_ids: Vec<String>,
/// Sequential order index for reconstruction.
pub order_index: Option<i64>,
/// Additional metadata about the extraction.
pub metadata: Value,
}
/// The comprehensive result of an ingestion operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryIngestionResult {
/// ID of the document that was ingested.
pub document_id: String,
/// Namespace containing the document.
pub namespace: String,
/// Extraction backend label recorded for the ingestion run.
pub model_name: String,
/// Mode used for extraction.
pub extraction_mode: String,
/// Total number of chunks processed.
pub chunk_count: usize,
/// Total number of distinct entities found.
pub entity_count: usize,
/// Total number of distinct relations found.
pub relation_count: usize,
/// Number of identified user preferences.
pub preference_count: usize,
/// Number of identified decisions.
pub decision_count: usize,
/// Auto-generated tags for the document.
#[serde(default)]
pub tags: Vec<String>,
/// Complete list of identified entities.
#[serde(default)]
pub entities: Vec<ExtractedEntity>,
/// Complete list of identified relations.
#[serde(default)]
pub relations: Vec<ExtractedRelation>,
}
/// Intermediate representation of an entity before normalization and alias resolution.
#[derive(Debug, Clone)]
pub(super) struct RawEntity {
pub(super) name: String,
pub(super) entity_type: String,
pub(super) confidence: f32,
}
/// Intermediate representation of a relationship before aggregation.
#[derive(Debug, Clone)]
pub(super) struct RawRelation {
pub(super) subject: String,
pub(super) subject_type: String,
pub(super) predicate: String,
pub(super) object: String,
pub(super) object_type: String,
pub(super) confidence: f32,
/// Indices of the chunks where this relation was found.
pub(super) chunk_indexes: BTreeSet<usize>,
/// Global sequential index for ordering within the document.
pub(super) order_index: i64,
/// JSON metadata for the relation.
pub(super) metadata: Map<String, Value>,
}
/// A single unit of text (sentence or chunk) passed to the extractor.
#[derive(Debug, Clone)]
pub(super) struct ExtractionUnit {
pub(super) text: String,
pub(super) chunk_index: usize,
pub(super) order_index: i64,
}
/// Accumulates extraction results across multiple chunks or units.
///
/// Handles entity and relation deduplication, alias tracking, and
/// basic document understanding (e.g., identifying the primary subject).
#[derive(Debug, Default)]
pub(super) struct ExtractionAccumulator {
/// Mapping of normalized entity name to its highest-confidence raw extraction.
pub(super) entities: HashMap<String, RawEntity>,
/// Collected relations before final canonicalization.
pub(super) relations: Vec<RawRelation>,
/// Tags identified during processing.
pub(super) tags: BTreeSet<String>,
/// Decisions identified during processing.
pub(super) decisions: BTreeSet<String>,
/// User preferences identified during processing.
pub(super) preferences: BTreeSet<String>,
/// Inferred document kind (e.g., "profile").
pub(super) doc_kind: Option<String>,
/// The document's inferred primary subject.
pub(super) primary_subject: Option<String>,
/// Sanitized document title.
pub(super) document_title: Option<String>,
/// The subject of the current markdown section.
pub(super) current_subject: Option<String>,
/// Current sender if processing a message/thread.
pub(super) current_sender: Option<String>,
/// Mapping of names to their canonicalized full name.
pub(super) known_people: HashMap<String, String>,
}
/// The result of the parsing stage of ingestion.
#[derive(Debug)]
pub(super) struct ParsedIngestion {
pub(super) tags: Vec<String>,
pub(super) metadata: Value,
pub(super) entities: Vec<ExtractedEntity>,
pub(super) relations: Vec<ExtractedRelation>,
pub(super) chunk_count: usize,
pub(super) preference_count: usize,
pub(super) decision_count: usize,
}
+29 -725
View File
@@ -2,6 +2,18 @@
//! Most LLM APIs follow the same `/v1/chat/completions` format.
//! This module provides a single implementation that works for all of them.
#[path = "compatible_dump.rs"]
mod compatible_dump;
#[path = "compatible_parse.rs"]
mod compatible_parse;
#[path = "compatible_stream.rs"]
mod compatible_stream;
#[path = "compatible_types.rs"]
mod compatible_types;
pub(crate) use compatible_parse::{parse_sse_line, strip_think_tags};
pub(crate) use compatible_types::ResponsesResponse;
use crate::openhuman::providers::traits::{
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
Provider, StreamChunk, StreamError, StreamOptions, StreamResult, ToolCall as ProviderToolCall,
@@ -13,122 +25,18 @@ use reqwest::{
header::{HeaderMap, HeaderValue, USER_AGENT},
Client,
};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
/// Monotonic sequence so multiple requests in the same millisecond sort
/// deterministically in the dump directory.
static PROMPT_DUMP_SEQ: AtomicU64 = AtomicU64::new(0);
/// When `OPENHUMAN_PROMPT_DUMP_DIR` is set, write `body` (the exact JSON
/// payload we're about to POST to the provider) to a timestamped file
/// under that directory. Best-effort: failures are logged and swallowed
/// so a dump outage never breaks inference.
///
/// Intended for KV-cache debugging — diff consecutive turns to see which
/// bytes of the prefix drifted and broke the cache hit.
/// Write raw response bytes to the dump dir paired with the most-recent
/// prompt file (same `seq` prefix, `.response.json` suffix). `seq` must
/// be the value reserved via `reserve_dump_seq` and passed to
/// `dump_prompt_if_enabled` so request/response files sort next to
/// each other.
fn dump_response_if_enabled(provider: &str, model: &str, seq: u64, bytes: &[u8]) {
let Ok(dir) = std::env::var("OPENHUMAN_PROMPT_DUMP_DIR") else {
return;
};
let dir = std::path::PathBuf::from(dir);
if let Err(err) = std::fs::create_dir_all(&dir) {
log::warn!(
"[prompt_dump] failed to create dir {}: {err}",
dir.display()
);
return;
}
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
let safe_model: String = model
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
let filename = format!("{ts}_{seq:06}_{provider}_{safe_model}.response.json");
let path = dir.join(filename);
// Re-pretty-print if it parses as JSON so diffs are stable; otherwise
// write raw bytes (SSE fragments, error HTML, etc).
let payload: Vec<u8> = match serde_json::from_slice::<serde_json::Value>(bytes) {
Ok(v) => serde_json::to_vec_pretty(&v).unwrap_or_else(|_| bytes.to_vec()),
Err(_) => bytes.to_vec(),
};
if let Err(err) = std::fs::write(&path, &payload) {
log::warn!(
"[prompt_dump] response write failed {}: {err}",
path.display()
);
} else {
log::debug!(
"[prompt_dump] wrote response {} bytes -> {}",
payload.len(),
path.display()
);
}
}
/// Atomically reserve the next dump sequence number. This is the single
/// source of truth for seq allocation — both the prompt dump and its
/// paired response dump must use the value returned here. A non-atomic
/// peek-then-increment split would race under concurrent requests (two
/// callers could reserve the same seq or correlate a request/response
/// pair across different turns).
fn reserve_dump_seq() -> u64 {
PROMPT_DUMP_SEQ.fetch_add(1, Ordering::Relaxed)
}
fn dump_prompt_if_enabled<T: Serialize>(provider: &str, model: &str, seq: u64, body: &T) {
let Ok(dir) = std::env::var("OPENHUMAN_PROMPT_DUMP_DIR") else {
return;
};
let dir = std::path::PathBuf::from(dir);
if let Err(err) = std::fs::create_dir_all(&dir) {
log::warn!(
"[prompt_dump] failed to create dir {}: {err}",
dir.display()
);
return;
}
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
let safe_model: String = model
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
let filename = format!("{ts}_{seq:06}_{provider}_{safe_model}.json");
let path = dir.join(filename);
match serde_json::to_vec_pretty(body) {
Ok(bytes) => {
if let Err(err) = std::fs::write(&path, &bytes) {
log::warn!("[prompt_dump] write failed {}: {err}", path.display());
} else {
log::debug!(
"[prompt_dump] wrote {} bytes -> {}",
bytes.len(),
path.display()
);
}
}
Err(err) => {
log::warn!("[prompt_dump] serialize failed: {err}");
}
}
}
use compatible_dump::{dump_prompt_if_enabled, dump_response_if_enabled, reserve_dump_seq};
use compatible_parse::{
build_responses_prompt, extract_responses_text, normalize_function_arguments,
parse_chat_response_body, parse_responses_response_body, parse_tool_calls_from_content_json,
};
use compatible_stream::sse_bytes_to_chunks;
use compatible_types::{
ApiChatRequest, ApiChatResponse, ApiUsage, Choice, Function, Message, NativeChatRequest,
NativeMessage, OpenHumanMeta, ResponseMessage, ResponsesRequest, StreamChunkResponse,
StreamingToolCall, ToolCall,
};
/// A provider that speaks the OpenAI-compatible chat completions API.
/// Used by: Venice, Vercel AI Gateway, Cloudflare AI Gateway, Moonshot,
@@ -380,597 +288,7 @@ impl OpenAiCompatibleProvider {
})
.collect()
}
}
#[derive(Debug, Serialize)]
struct ApiChatRequest {
model: String,
messages: Vec<Message>,
temperature: f64,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
}
#[derive(Debug, Serialize)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ApiChatResponse {
choices: Vec<Choice>,
/// Standard OpenAI usage block.
#[serde(default)]
usage: Option<ApiUsage>,
/// OpenHuman backend metadata (usage + billing summary).
#[serde(default)]
openhuman: Option<OpenHumanMeta>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: ResponseMessage,
}
/// Standard OpenAI `usage` block on a chat completion response.
#[derive(Debug, Deserialize, Default)]
struct ApiUsage {
#[serde(default)]
prompt_tokens: u64,
#[serde(default)]
completion_tokens: u64,
#[serde(default)]
total_tokens: u64,
#[serde(default)]
prompt_tokens_details: Option<PromptTokensDetails>,
}
#[derive(Debug, Deserialize, Default)]
struct PromptTokensDetails {
#[serde(default)]
cached_tokens: u64,
}
/// OpenHuman backend metadata appended to the response JSON.
#[derive(Debug, Deserialize, Default)]
struct OpenHumanMeta {
#[serde(default)]
usage: Option<OpenHumanUsage>,
#[serde(default)]
billing: Option<OpenHumanBilling>,
}
#[derive(Debug, Deserialize, Default)]
struct OpenHumanUsage {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
#[allow(dead_code)]
total_tokens: Option<u64>,
cached_input_tokens: Option<u64>,
}
#[derive(Debug, Deserialize, Default)]
struct OpenHumanBilling {
#[serde(default)]
charged_amount_usd: f64,
}
/// Remove `<think>...</think>` blocks from model output.
/// Some reasoning models (e.g. MiniMax) embed their chain-of-thought inline
/// in the `content` field rather than a separate `reasoning_content` field.
/// The resulting `<think>` tags must be stripped before returning to the user.
fn strip_think_tags(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut rest = s;
loop {
if let Some(start) = rest.find("<think>") {
result.push_str(&rest[..start]);
if let Some(end) = rest[start..].find("</think>") {
rest = &rest[start + end + "</think>".len()..];
} else {
// Unclosed tag: drop the rest to avoid leaking partial reasoning.
break;
}
} else {
result.push_str(rest);
break;
}
}
result.trim().to_string()
}
#[derive(Debug, Deserialize, Serialize)]
struct ResponseMessage {
#[serde(default)]
content: Option<String>,
/// Reasoning/thinking models (e.g. Qwen3, GLM-4) may return their output
/// in `reasoning_content` instead of `content`. Used as automatic fallback.
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<ToolCall>>,
#[serde(default)]
function_call: Option<Function>,
}
impl ResponseMessage {
/// Extract text content, falling back to `reasoning_content` when `content`
/// is missing or empty. Reasoning/thinking models (Qwen3, GLM-4, etc.)
/// often return their output solely in `reasoning_content`.
/// Strips `<think>...</think>` blocks that some models (e.g. MiniMax) embed
/// inline in `content` instead of using a separate field.
fn effective_content(&self) -> String {
if let Some(content) = self.content.as_ref().filter(|c| !c.is_empty()) {
let stripped = strip_think_tags(content);
if !stripped.is_empty() {
return stripped;
}
}
self.reasoning_content
.as_ref()
.map(|c| strip_think_tags(c))
.filter(|c| !c.is_empty())
.unwrap_or_default()
}
fn effective_content_optional(&self) -> Option<String> {
if let Some(content) = self.content.as_ref().filter(|c| !c.is_empty()) {
let stripped = strip_think_tags(content);
if !stripped.is_empty() {
return Some(stripped);
}
}
self.reasoning_content
.as_ref()
.map(|c| strip_think_tags(c))
.filter(|c| !c.is_empty())
}
}
#[derive(Debug, Deserialize, Serialize)]
struct ToolCall {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(rename = "type")]
kind: Option<String>,
function: Option<Function>,
}
#[derive(Debug, Deserialize, Serialize)]
struct Function {
name: Option<String>,
arguments: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
struct NativeChatRequest {
model: String,
messages: Vec<NativeMessage>,
temperature: f64,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
}
#[derive(Debug, Serialize)]
struct NativeMessage {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Debug, Serialize)]
struct ResponsesRequest {
model: String,
input: Vec<ResponsesInput>,
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
}
#[derive(Debug, Serialize)]
struct ResponsesInput {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ResponsesResponse {
#[serde(default)]
output: Vec<ResponsesOutput>,
#[serde(default)]
output_text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ResponsesOutput {
#[serde(default)]
content: Vec<ResponsesContent>,
}
#[derive(Debug, Deserialize)]
struct ResponsesContent {
#[serde(rename = "type")]
kind: Option<String>,
text: Option<String>,
}
// ---------------------------------------------------------------
// Streaming support (SSE parser)
// ---------------------------------------------------------------
/// Server-Sent Event stream chunk for OpenAI-compatible streaming.
#[derive(Debug, Deserialize)]
struct StreamChunkResponse {
choices: Vec<StreamChoice>,
#[serde(default)]
usage: Option<ApiUsage>,
#[serde(default)]
openhuman: Option<OpenHumanMeta>,
}
#[derive(Debug, Deserialize)]
struct StreamChoice {
delta: StreamDelta,
#[allow(dead_code)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct StreamDelta {
#[serde(default)]
content: Option<String>,
/// Reasoning/thinking models may stream output via `reasoning_content`.
#[serde(default)]
reasoning_content: Option<String>,
/// Native tool-call chunks. Each entry is keyed by `index`; the first
/// chunk for a given index carries `id`/`type`/`function.name`, later
/// chunks only carry fragments of `function.arguments`.
#[serde(default)]
tool_calls: Option<Vec<StreamToolCallDelta>>,
}
#[derive(Debug, Deserialize)]
struct StreamToolCallDelta {
/// Index of this tool call within the assistant message. Multiple
/// concurrent tool calls share the same message and are distinguished
/// by index — not id (which may only appear on the first chunk).
#[serde(default)]
index: Option<u32>,
#[serde(default)]
id: Option<String>,
#[serde(default, rename = "type")]
#[allow(dead_code)]
kind: Option<String>,
#[serde(default)]
function: Option<StreamToolCallFunction>,
}
#[derive(Debug, Deserialize)]
struct StreamToolCallFunction {
#[serde(default)]
name: Option<String>,
/// Arguments are streamed as a raw JSON string fragment; we accumulate
/// them as-is and only parse at the end of the stream.
#[serde(default)]
arguments: Option<String>,
}
/// Parse SSE (Server-Sent Events) stream from OpenAI-compatible providers.
/// Handles the `data: {...}` format and `[DONE]` sentinel.
fn parse_sse_line(line: &str) -> StreamResult<Option<String>> {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with(':') {
return Ok(None);
}
// SSE format: "data: {...}"
if let Some(data) = line.strip_prefix("data:") {
let data = data.trim();
// Check for [DONE] sentinel
if data == "[DONE]" {
return Ok(None);
}
// Parse JSON delta
let chunk: StreamChunkResponse = serde_json::from_str(data).map_err(StreamError::Json)?;
// Extract content from delta
if let Some(choice) = chunk.choices.first() {
if let Some(content) = &choice.delta.content {
if !content.is_empty() {
return Ok(Some(content.clone()));
}
}
// Fallback to reasoning_content for thinking models
if let Some(reasoning) = &choice.delta.reasoning_content {
return Ok(Some(reasoning.clone()));
}
}
}
Ok(None)
}
/// Convert SSE byte stream to text chunks.
fn sse_bytes_to_chunks(
response: reqwest::Response,
count_tokens: bool,
) -> stream::BoxStream<'static, StreamResult<StreamChunk>> {
// Create a channel to send chunks
let (tx, rx) = tokio::sync::mpsc::channel::<StreamResult<StreamChunk>>(100);
tokio::spawn(async move {
// Buffer for incomplete lines
let mut buffer = String::new();
// Get response body as bytes stream
match response.error_for_status_ref() {
Ok(_) => {}
Err(e) => {
let _ = tx.send(Err(StreamError::Http(e))).await;
return;
}
}
let mut bytes_stream = response.bytes_stream();
while let Some(item) = bytes_stream.next().await {
match item {
Ok(bytes) => {
// Convert bytes to string and process line by line
let text = match String::from_utf8(bytes.to_vec()) {
Ok(t) => t,
Err(e) => {
let _ = tx
.send(Err(StreamError::InvalidSse(format!(
"Invalid UTF-8: {}",
e
))))
.await;
break;
}
};
buffer.push_str(&text);
// Process complete lines
while let Some(pos) = buffer.find('\n') {
let line = buffer.drain(..=pos).collect::<String>();
buffer = buffer[pos + 1..].to_string();
match parse_sse_line(&line) {
Ok(Some(content)) => {
let mut chunk = StreamChunk::delta(content);
if count_tokens {
chunk = chunk.with_token_estimate();
}
if tx.send(Ok(chunk)).await.is_err() {
return; // Receiver dropped
}
}
Ok(None) => {}
Err(e) => {
let _ = tx.send(Err(e)).await;
return;
}
}
}
}
Err(e) => {
let _ = tx.send(Err(StreamError::Http(e))).await;
break;
}
}
}
// Send final chunk
let _ = tx.send(Ok(StreamChunk::final_chunk())).await;
});
// Convert channel receiver to stream
stream::unfold(rx, |mut rx| async {
rx.recv().await.map(|chunk| (chunk, rx))
})
.boxed()
}
fn first_nonempty(text: Option<&str>) -> Option<String> {
text.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn normalize_responses_role(role: &str) -> &'static str {
match role {
"assistant" => "assistant",
"tool" => "assistant",
_ => "user",
}
}
fn build_responses_prompt(messages: &[ChatMessage]) -> (Option<String>, Vec<ResponsesInput>) {
let mut instructions_parts = Vec::new();
let mut input = Vec::new();
for message in messages {
if message.content.trim().is_empty() {
continue;
}
if message.role == "system" {
instructions_parts.push(message.content.clone());
continue;
}
input.push(ResponsesInput {
role: normalize_responses_role(&message.role).to_string(),
content: message.content.clone(),
});
}
let instructions = if instructions_parts.is_empty() {
None
} else {
Some(instructions_parts.join("\n\n"))
};
(instructions, input)
}
fn extract_responses_text(response: ResponsesResponse) -> Option<String> {
if let Some(text) = first_nonempty(response.output_text.as_deref()) {
return Some(text);
}
for item in &response.output {
for content in &item.content {
if content.kind.as_deref() == Some("output_text") {
if let Some(text) = first_nonempty(content.text.as_deref()) {
return Some(text);
}
}
}
}
for item in &response.output {
for content in &item.content {
if let Some(text) = first_nonempty(content.text.as_deref()) {
return Some(text);
}
}
}
None
}
fn compact_sanitized_body_snippet(body: &str) -> String {
super::sanitize_api_error(body)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn parse_chat_response_body(provider_name: &str, body: &str) -> anyhow::Result<ApiChatResponse> {
serde_json::from_str::<ApiChatResponse>(body).map_err(|error| {
let snippet = compact_sanitized_body_snippet(body);
anyhow::anyhow!(
"{provider_name} API returned an unexpected chat-completions payload: {error}; body={snippet}"
)
})
}
fn normalize_function_arguments(arguments: Option<serde_json::Value>) -> String {
match arguments {
Some(serde_json::Value::String(raw)) => {
if raw.trim().is_empty() {
"{}".to_string()
} else {
raw
}
}
Some(serde_json::Value::Null) | None => "{}".to_string(),
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "{}".to_string()),
}
}
fn parse_provider_tool_call_from_value(value: &serde_json::Value) -> Option<ProviderToolCall> {
if let Ok(call) = serde_json::from_value::<ProviderToolCall>(value.clone()) {
if !call.name.trim().is_empty() {
return Some(ProviderToolCall {
id: if call.id.trim().is_empty() {
uuid::Uuid::new_v4().to_string()
} else {
call.id
},
name: call.name,
arguments: if call.arguments.trim().is_empty() {
"{}".to_string()
} else {
call.arguments
},
});
}
}
let function = value.get("function")?;
let name = function.get("name").and_then(serde_json::Value::as_str)?;
if name.trim().is_empty() {
return None;
}
let id = value
.get("id")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
Some(ProviderToolCall {
id,
name: name.to_string(),
arguments: normalize_function_arguments(function.get("arguments").cloned()),
})
}
fn parse_tool_calls_from_content_json(
content: &str,
) -> Option<(Option<String>, Vec<ProviderToolCall>)> {
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
let tool_calls_value = value.get("tool_calls")?.as_array()?;
let tool_calls: Vec<ProviderToolCall> = tool_calls_value
.iter()
.filter_map(parse_provider_tool_call_from_value)
.collect();
if tool_calls.is_empty() {
return None;
}
let text = value
.get("content")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
Some((text, tool_calls))
}
fn parse_responses_response_body(
provider_name: &str,
body: &str,
) -> anyhow::Result<ResponsesResponse> {
serde_json::from_str::<ResponsesResponse>(body).map_err(|error| {
let snippet = compact_sanitized_body_snippet(body);
anyhow::anyhow!(
"{provider_name} Responses API returned an unexpected payload: {error}; body={snippet}"
)
})
}
impl OpenAiCompatibleProvider {
fn apply_auth_header(
&self,
req: reqwest::RequestBuilder,
@@ -1063,7 +381,9 @@ impl OpenAiCompatibleProvider {
kind: Some("function".to_string()),
function: Some(Function {
name: Some(tc.name),
arguments: Some(serde_json::Value::String(tc.arguments)),
arguments: Some(serde_json::Value::String(
tc.arguments,
)),
}),
})
.collect::<Vec<_>>();
@@ -1085,7 +405,9 @@ impl OpenAiCompatibleProvider {
}
if message.role == "tool" {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&message.content) {
if let Ok(value) =
serde_json::from_str::<serde_json::Value>(&message.content)
{
let tool_call_id = value
.get("tool_call_id")
.and_then(serde_json::Value::as_str)
@@ -1662,24 +984,6 @@ impl OpenAiCompatibleProvider {
}
}
/// Per-index tool-call accumulator used while consuming an SSE stream.
///
/// `arguments` holds the full cumulative JSON text fragments seen so
/// far. `emitted_start` tracks whether we've surfaced the synthetic
/// `ProviderDelta::ToolCallStart` event yet (we only do once we know
/// both `id` and `name`). `emitted_chars` is the byte offset within
/// `arguments` that we've already flushed as `ToolCallArgsDelta`
/// events — used to avoid re-sending buffered fragments after the
/// start event fires.
#[derive(Debug, Default)]
struct StreamingToolCall {
id: Option<String>,
name: Option<String>,
arguments: String,
emitted_start: bool,
emitted_chars: usize,
}
#[async_trait]
impl Provider for OpenAiCompatibleProvider {
fn capabilities(&self) -> crate::openhuman::providers::traits::ProviderCapabilities {
+128
View File
@@ -0,0 +1,128 @@
//! Prompt and response dump utilities for KV-cache debugging.
//!
//! When `OPENHUMAN_PROMPT_DUMP_DIR` is set, both the outbound request payload
//! and the inbound response body are written to timestamped files under that
//! directory. Best-effort: failures are logged and swallowed so a dump outage
//! never breaks inference.
use serde::Serialize;
use std::sync::atomic::{AtomicU64, Ordering};
/// Monotonic sequence so multiple requests in the same millisecond sort
/// deterministically in the dump directory.
pub(crate) static PROMPT_DUMP_SEQ: AtomicU64 = AtomicU64::new(0);
/// Atomically reserve the next dump sequence number. This is the single
/// source of truth for seq allocation — both the prompt dump and its
/// paired response dump must use the value returned here. A non-atomic
/// peek-then-increment split would race under concurrent requests (two
/// callers could reserve the same seq or correlate a request/response
/// pair across different turns).
pub(crate) fn reserve_dump_seq() -> u64 {
PROMPT_DUMP_SEQ.fetch_add(1, Ordering::Relaxed)
}
/// When `OPENHUMAN_PROMPT_DUMP_DIR` is set, write `body` (the exact JSON
/// payload we're about to POST to the provider) to a timestamped file
/// under that directory. Best-effort: failures are logged and swallowed
/// so a dump outage never breaks inference.
///
/// Intended for KV-cache debugging — diff consecutive turns to see which
/// bytes of the prefix drifted and broke the cache hit.
pub(crate) fn dump_prompt_if_enabled<T: Serialize>(
provider: &str,
model: &str,
seq: u64,
body: &T,
) {
let Ok(dir) = std::env::var("OPENHUMAN_PROMPT_DUMP_DIR") else {
return;
};
let dir = std::path::PathBuf::from(dir);
if let Err(err) = std::fs::create_dir_all(&dir) {
log::warn!(
"[prompt_dump] failed to create dir {}: {err}",
dir.display()
);
return;
}
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
let safe_model: String = model
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
let filename = format!("{ts}_{seq:06}_{provider}_{safe_model}.json");
let path = dir.join(filename);
match serde_json::to_vec_pretty(body) {
Ok(bytes) => {
if let Err(err) = std::fs::write(&path, &bytes) {
log::warn!("[prompt_dump] write failed {}: {err}", path.display());
} else {
log::debug!(
"[prompt_dump] wrote {} bytes -> {}",
bytes.len(),
path.display()
);
}
}
Err(err) => {
log::warn!("[prompt_dump] serialize failed: {err}");
}
}
}
/// Write raw response bytes to the dump dir paired with the most-recent
/// prompt file (same `seq` prefix, `.response.json` suffix). `seq` must
/// be the value reserved via `reserve_dump_seq` and passed to
/// `dump_prompt_if_enabled` so request/response files sort next to
/// each other.
pub(crate) fn dump_response_if_enabled(provider: &str, model: &str, seq: u64, bytes: &[u8]) {
let Ok(dir) = std::env::var("OPENHUMAN_PROMPT_DUMP_DIR") else {
return;
};
let dir = std::path::PathBuf::from(dir);
if let Err(err) = std::fs::create_dir_all(&dir) {
log::warn!(
"[prompt_dump] failed to create dir {}: {err}",
dir.display()
);
return;
}
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
let safe_model: String = model
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
let filename = format!("{ts}_{seq:06}_{provider}_{safe_model}.response.json");
let path = dir.join(filename);
// Re-pretty-print if it parses as JSON so diffs are stable; otherwise
// write raw bytes (SSE fragments, error HTML, etc).
let payload: Vec<u8> = match serde_json::from_slice::<serde_json::Value>(bytes) {
Ok(v) => serde_json::to_vec_pretty(&v).unwrap_or_else(|_| bytes.to_vec()),
Err(_) => bytes.to_vec(),
};
if let Err(err) = std::fs::write(&path, &payload) {
log::warn!(
"[prompt_dump] response write failed {}: {err}",
path.display()
);
} else {
log::debug!(
"[prompt_dump] wrote response {} bytes -> {}",
payload.len(),
path.display()
);
}
}
+270
View File
@@ -0,0 +1,270 @@
//! Parsing and response-extraction free functions for the OpenAI-compatible provider.
//!
//! All functions here are stateless transforms — no I/O, no HTTP. They take
//! raw strings or deserialized values and return structured results.
use crate::openhuman::providers::traits::{
ChatMessage, StreamError, StreamResult, ToolCall as ProviderToolCall,
};
use super::compatible_types::{
ApiChatResponse, ResponsesInput, ResponsesResponse, StreamChunkResponse,
};
// ── Think-tag stripping ───────────────────────────────────────────────────────
/// Remove `<think>...</think>` blocks from model output.
/// Some reasoning models (e.g. MiniMax) embed their chain-of-thought inline
/// in the `content` field rather than a separate `reasoning_content` field.
/// The resulting `<think>` tags must be stripped before returning to the user.
pub(crate) fn strip_think_tags(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut rest = s;
loop {
if let Some(start) = rest.find("<think>") {
result.push_str(&rest[..start]);
if let Some(end) = rest[start..].find("</think>") {
rest = &rest[start + end + "</think>".len()..];
} else {
// Unclosed tag: drop the rest to avoid leaking partial reasoning.
break;
}
} else {
result.push_str(rest);
break;
}
}
result.trim().to_string()
}
// ── SSE line parser ───────────────────────────────────────────────────────────
/// Parse a single SSE (Server-Sent Events) line from OpenAI-compatible providers.
/// Handles the `data: {...}` format and `[DONE]` sentinel.
pub(crate) fn parse_sse_line(line: &str) -> StreamResult<Option<String>> {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with(':') {
return Ok(None);
}
// SSE format: "data: {...}"
if let Some(data) = line.strip_prefix("data:") {
let data = data.trim();
// Check for [DONE] sentinel
if data == "[DONE]" {
return Ok(None);
}
// Parse JSON delta
let chunk: StreamChunkResponse = serde_json::from_str(data).map_err(StreamError::Json)?;
// Extract content from delta
if let Some(choice) = chunk.choices.first() {
if let Some(content) = &choice.delta.content {
if !content.is_empty() {
return Ok(Some(content.clone()));
}
}
// Fallback to reasoning_content for thinking models
if let Some(reasoning) = &choice.delta.reasoning_content {
return Ok(Some(reasoning.clone()));
}
}
}
Ok(None)
}
// ── Response body parsers ─────────────────────────────────────────────────────
pub(crate) fn compact_sanitized_body_snippet(body: &str) -> String {
// super = compatible module; super::super = providers module (where sanitize_api_error lives)
super::super::sanitize_api_error(body)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn parse_chat_response_body(
provider_name: &str,
body: &str,
) -> anyhow::Result<ApiChatResponse> {
serde_json::from_str::<ApiChatResponse>(body).map_err(|error| {
let snippet = compact_sanitized_body_snippet(body);
anyhow::anyhow!(
"{provider_name} API returned an unexpected chat-completions payload: {error}; body={snippet}"
)
})
}
pub(crate) fn parse_responses_response_body(
provider_name: &str,
body: &str,
) -> anyhow::Result<ResponsesResponse> {
serde_json::from_str::<ResponsesResponse>(body).map_err(|error| {
let snippet = compact_sanitized_body_snippet(body);
anyhow::anyhow!(
"{provider_name} Responses API returned an unexpected payload: {error}; body={snippet}"
)
})
}
// ── Tool-call argument normalisation ─────────────────────────────────────────
pub(crate) fn normalize_function_arguments(arguments: Option<serde_json::Value>) -> String {
match arguments {
Some(serde_json::Value::String(raw)) => {
if raw.trim().is_empty() {
"{}".to_string()
} else {
raw
}
}
Some(serde_json::Value::Null) | None => "{}".to_string(),
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "{}".to_string()),
}
}
pub(crate) fn parse_provider_tool_call_from_value(
value: &serde_json::Value,
) -> Option<ProviderToolCall> {
if let Ok(call) = serde_json::from_value::<ProviderToolCall>(value.clone()) {
if !call.name.trim().is_empty() {
return Some(ProviderToolCall {
id: if call.id.trim().is_empty() {
uuid::Uuid::new_v4().to_string()
} else {
call.id
},
name: call.name,
arguments: if call.arguments.trim().is_empty() {
"{}".to_string()
} else {
call.arguments
},
});
}
}
let function = value.get("function")?;
let name = function.get("name").and_then(serde_json::Value::as_str)?;
if name.trim().is_empty() {
return None;
}
let id = value
.get("id")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
Some(ProviderToolCall {
id,
name: name.to_string(),
arguments: normalize_function_arguments(function.get("arguments").cloned()),
})
}
pub(crate) fn parse_tool_calls_from_content_json(
content: &str,
) -> Option<(Option<String>, Vec<ProviderToolCall>)> {
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
let tool_calls_value = value.get("tool_calls")?.as_array()?;
let tool_calls: Vec<ProviderToolCall> = tool_calls_value
.iter()
.filter_map(parse_provider_tool_call_from_value)
.collect();
if tool_calls.is_empty() {
return None;
}
let text = value
.get("content")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
Some((text, tool_calls))
}
// ── Responses API helpers ─────────────────────────────────────────────────────
pub(crate) fn first_nonempty(text: Option<&str>) -> Option<String> {
text.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
pub(crate) fn normalize_responses_role(role: &str) -> &'static str {
match role {
"assistant" => "assistant",
"tool" => "assistant",
_ => "user",
}
}
pub(crate) fn build_responses_prompt(
messages: &[ChatMessage],
) -> (Option<String>, Vec<ResponsesInput>) {
let mut instructions_parts = Vec::new();
let mut input = Vec::new();
for message in messages {
if message.content.trim().is_empty() {
continue;
}
if message.role == "system" {
instructions_parts.push(message.content.clone());
continue;
}
input.push(ResponsesInput {
role: normalize_responses_role(&message.role).to_string(),
content: message.content.clone(),
});
}
let instructions = if instructions_parts.is_empty() {
None
} else {
Some(instructions_parts.join("\n\n"))
};
(instructions, input)
}
pub(crate) fn extract_responses_text(response: ResponsesResponse) -> Option<String> {
if let Some(text) = first_nonempty(response.output_text.as_deref()) {
return Some(text);
}
for item in &response.output {
for content in &item.content {
if content.kind.as_deref() == Some("output_text") {
if let Some(text) = first_nonempty(content.text.as_deref()) {
return Some(text);
}
}
}
}
for item in &response.output {
for content in &item.content {
if let Some(text) = first_nonempty(content.text.as_deref()) {
return Some(text);
}
}
}
None
}
@@ -0,0 +1,92 @@
//! SSE streaming support for the OpenAI-compatible provider.
//!
//! Converts a raw `reqwest::Response` byte stream into a typed
//! `StreamChunk` stream via Server-Sent Events parsing.
use crate::openhuman::providers::traits::{StreamChunk, StreamError, StreamResult};
use futures_util::{stream, StreamExt};
use super::compatible_parse::parse_sse_line;
/// Convert SSE byte stream to text chunks.
pub(crate) fn sse_bytes_to_chunks(
response: reqwest::Response,
count_tokens: bool,
) -> stream::BoxStream<'static, StreamResult<StreamChunk>> {
// Create a channel to send chunks
let (tx, rx) = tokio::sync::mpsc::channel::<StreamResult<StreamChunk>>(100);
tokio::spawn(async move {
// Buffer for incomplete lines
let mut buffer = String::new();
// Get response body as bytes stream
match response.error_for_status_ref() {
Ok(_) => {}
Err(e) => {
let _ = tx.send(Err(StreamError::Http(e))).await;
return;
}
}
let mut bytes_stream = response.bytes_stream();
while let Some(item) = bytes_stream.next().await {
match item {
Ok(bytes) => {
// Convert bytes to string and process line by line
let text = match String::from_utf8(bytes.to_vec()) {
Ok(t) => t,
Err(e) => {
let _ = tx
.send(Err(StreamError::InvalidSse(format!(
"Invalid UTF-8: {}",
e
))))
.await;
break;
}
};
buffer.push_str(&text);
// Process complete lines
while let Some(pos) = buffer.find('\n') {
let line = buffer.drain(..=pos).collect::<String>();
buffer = buffer[pos + 1..].to_string();
match parse_sse_line(&line) {
Ok(Some(content)) => {
let mut chunk = StreamChunk::delta(content);
if count_tokens {
chunk = chunk.with_token_estimate();
}
if tx.send(Ok(chunk)).await.is_err() {
return; // Receiver dropped
}
}
Ok(None) => {}
Err(e) => {
let _ = tx.send(Err(e)).await;
return;
}
}
}
}
Err(e) => {
let _ = tx.send(Err(StreamError::Http(e))).await;
break;
}
}
}
// Send final chunk
let _ = tx.send(Ok(StreamChunk::final_chunk())).await;
});
// Convert channel receiver to stream
stream::unfold(rx, |mut rx| async {
rx.recv().await.map(|chunk| (chunk, rx))
})
.boxed()
}
+292
View File
@@ -0,0 +1,292 @@
//! Serde request/response structs for the OpenAI-compatible provider.
//!
//! All types in this module are crate-internal (`pub(crate)` or `pub(crate)`
//! as appropriate). External code only sees the public API on
//! [`super::OpenAiCompatibleProvider`].
use serde::{Deserialize, Serialize};
// ── Request bodies ────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub(crate) struct ApiChatRequest {
pub(crate) model: String,
pub(crate) messages: Vec<Message>,
pub(crate) temperature: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tool_choice: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct Message {
pub(crate) role: String,
pub(crate) content: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct NativeChatRequest {
pub(crate) model: String,
pub(crate) messages: Vec<NativeMessage>,
pub(crate) temperature: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tool_choice: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct NativeMessage {
pub(crate) role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ResponsesRequest {
pub(crate) model: String,
pub(crate) input: Vec<ResponsesInput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) stream: Option<bool>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ResponsesInput {
pub(crate) role: String,
pub(crate) content: String,
}
// ── Response bodies ───────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub(crate) struct ApiChatResponse {
pub(crate) choices: Vec<Choice>,
/// Standard OpenAI usage block.
#[serde(default)]
pub(crate) usage: Option<ApiUsage>,
/// OpenHuman backend metadata (usage + billing summary).
#[serde(default)]
pub(crate) openhuman: Option<OpenHumanMeta>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct Choice {
pub(crate) message: ResponseMessage,
}
/// Standard OpenAI `usage` block on a chat completion response.
#[derive(Debug, Deserialize, Default)]
pub(crate) struct ApiUsage {
#[serde(default)]
pub(crate) prompt_tokens: u64,
#[serde(default)]
pub(crate) completion_tokens: u64,
#[serde(default)]
pub(crate) total_tokens: u64,
#[serde(default)]
pub(crate) prompt_tokens_details: Option<PromptTokensDetails>,
}
#[derive(Debug, Deserialize, Default)]
pub(crate) struct PromptTokensDetails {
#[serde(default)]
pub(crate) cached_tokens: u64,
}
/// OpenHuman backend metadata appended to the response JSON.
#[derive(Debug, Deserialize, Default)]
pub(crate) struct OpenHumanMeta {
#[serde(default)]
pub(crate) usage: Option<OpenHumanUsage>,
#[serde(default)]
pub(crate) billing: Option<OpenHumanBilling>,
}
#[derive(Debug, Deserialize, Default)]
pub(crate) struct OpenHumanUsage {
pub(crate) input_tokens: Option<u64>,
pub(crate) output_tokens: Option<u64>,
#[allow(dead_code)]
pub(crate) total_tokens: Option<u64>,
pub(crate) cached_input_tokens: Option<u64>,
}
#[derive(Debug, Deserialize, Default)]
pub(crate) struct OpenHumanBilling {
#[serde(default)]
pub(crate) charged_amount_usd: f64,
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ResponseMessage {
#[serde(default)]
pub(crate) content: Option<String>,
/// Reasoning/thinking models (e.g. Qwen3, GLM-4) may return their output
/// in `reasoning_content` instead of `content`. Used as automatic fallback.
#[serde(default)]
pub(crate) reasoning_content: Option<String>,
#[serde(default)]
pub(crate) tool_calls: Option<Vec<ToolCall>>,
#[serde(default)]
pub(crate) function_call: Option<Function>,
}
impl ResponseMessage {
/// Extract text content, falling back to `reasoning_content` when `content`
/// is missing or empty. Reasoning/thinking models (Qwen3, GLM-4, etc.)
/// often return their output solely in `reasoning_content`.
/// Strips `<think>...</think>` blocks that some models (e.g. MiniMax) embed
/// inline in `content` instead of using a separate field.
pub(crate) fn effective_content(&self) -> String {
if let Some(content) = self.content.as_ref().filter(|c| !c.is_empty()) {
let stripped = super::compatible_parse::strip_think_tags(content);
if !stripped.is_empty() {
return stripped;
}
}
self.reasoning_content
.as_ref()
.map(|c| super::compatible_parse::strip_think_tags(c))
.filter(|c| !c.is_empty())
.unwrap_or_default()
}
pub(crate) fn effective_content_optional(&self) -> Option<String> {
if let Some(content) = self.content.as_ref().filter(|c| !c.is_empty()) {
let stripped = super::compatible_parse::strip_think_tags(content);
if !stripped.is_empty() {
return Some(stripped);
}
}
self.reasoning_content
.as_ref()
.map(|c| super::compatible_parse::strip_think_tags(c))
.filter(|c| !c.is_empty())
}
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ToolCall {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) id: Option<String>,
#[serde(rename = "type")]
pub(crate) kind: Option<String>,
pub(crate) function: Option<Function>,
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct Function {
pub(crate) name: Option<String>,
pub(crate) arguments: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ResponsesResponse {
#[serde(default)]
pub(crate) output: Vec<ResponsesOutput>,
#[serde(default)]
pub(crate) output_text: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ResponsesOutput {
#[serde(default)]
pub(crate) content: Vec<ResponsesContent>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ResponsesContent {
#[serde(rename = "type")]
pub(crate) kind: Option<String>,
pub(crate) text: Option<String>,
}
// ── Streaming types ───────────────────────────────────────────────────────────
/// Server-Sent Event stream chunk for OpenAI-compatible streaming.
#[derive(Debug, Deserialize)]
pub(crate) struct StreamChunkResponse {
pub(crate) choices: Vec<StreamChoice>,
#[serde(default)]
pub(crate) usage: Option<ApiUsage>,
#[serde(default)]
pub(crate) openhuman: Option<OpenHumanMeta>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct StreamChoice {
pub(crate) delta: StreamDelta,
#[allow(dead_code)]
pub(crate) finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct StreamDelta {
#[serde(default)]
pub(crate) content: Option<String>,
/// Reasoning/thinking models may stream output via `reasoning_content`.
#[serde(default)]
pub(crate) reasoning_content: Option<String>,
/// Native tool-call chunks. Each entry is keyed by `index`; the first
/// chunk for a given index carries `id`/`type`/`function.name`, later
/// chunks only carry fragments of `function.arguments`.
#[serde(default)]
pub(crate) tool_calls: Option<Vec<StreamToolCallDelta>>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct StreamToolCallDelta {
/// Index of this tool call within the assistant message. Multiple
/// concurrent tool calls share the same message and are distinguished
/// by index — not id (which may only appear on the first chunk).
#[serde(default)]
pub(crate) index: Option<u32>,
#[serde(default)]
pub(crate) id: Option<String>,
#[serde(default, rename = "type")]
#[allow(dead_code)]
pub(crate) kind: Option<String>,
#[serde(default)]
pub(crate) function: Option<StreamToolCallFunction>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct StreamToolCallFunction {
#[serde(default)]
pub(crate) name: Option<String>,
/// Arguments are streamed as a raw JSON string fragment; we accumulate
/// them as-is and only parse at the end of the stream.
#[serde(default)]
pub(crate) arguments: Option<String>,
}
/// Per-index tool-call accumulator used while consuming an SSE stream.
///
/// `arguments` holds the full cumulative JSON text fragments seen so
/// far. `emitted_start` tracks whether we've surfaced the synthetic
/// `ProviderDelta::ToolCallStart` event yet (we only do once we know
/// both `id` and `name`). `emitted_chars` is the byte offset within
/// `arguments` that we've already flushed as `ToolCallArgsDelta`
/// events — used to avoid re-sending buffered fragments after the
/// start event fires.
#[derive(Debug, Default)]
pub(crate) struct StreamingToolCall {
pub(crate) id: Option<String>,
pub(crate) name: Option<String>,
pub(crate) arguments: String,
pub(crate) emitted_start: bool,
pub(crate) emitted_chars: usize,
}
+5
View File
@@ -3,6 +3,11 @@
pub mod bus;
pub mod inject;
pub mod ops;
pub mod ops_create;
pub mod ops_discover;
pub mod ops_install;
pub mod ops_parse;
pub mod ops_types;
pub mod schemas;
pub mod types;
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
//! Skill creation: scaffolding new SKILL.md-based skills on disk.
use serde::Deserialize;
use std::path::Path;
use super::ops_discover::{discover_skills_inner, is_workspace_trusted};
use super::ops_types::{
Skill, SkillFrontmatter, SkillScope, MAX_DESCRIPTION_LEN, MAX_NAME_LEN, RESOURCE_DIRS, SKILL_MD,
};
/// Input for [`create_skill`]. Mirrors the `skills.create` JSON-RPC payload.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CreateSkillParams {
/// Human-readable name — slugified into the on-disk folder.
pub name: String,
/// One-line description written into the frontmatter.
pub description: String,
/// Where to install: `user`, `project`, or `legacy`. Defaults to `user`.
#[serde(default)]
pub scope: SkillScope,
/// Optional SPDX license (written to frontmatter `license`).
#[serde(default)]
pub license: Option<String>,
/// Optional author name (written under frontmatter `metadata.author`).
#[serde(default)]
pub author: Option<String>,
/// Optional tags (written under frontmatter `metadata.tags`).
#[serde(default)]
pub tags: Vec<String>,
/// Optional tool hints (written to frontmatter `allowed-tools`).
#[serde(default, rename = "allowed-tools", alias = "allowed_tools")]
pub allowed_tools: Vec<String>,
}
/// Scaffold a new SKILL.md-based skill on disk.
///
/// Writes `<scope-root>/<slug>/SKILL.md` with frontmatter derived from
/// `params` and creates empty `scripts/`, `references/`, `assets/` subdirs
/// so the author has somewhere to drop bundled resources.
///
/// Scope resolution:
/// * [`SkillScope::User`] → `~/.openhuman/skills/`
/// * [`SkillScope::Project`] → `<workspace>/.openhuman/skills/`. Requires the
/// trust marker at `<workspace>/.openhuman/trust` to be present; otherwise
/// rejected with an error.
/// * [`SkillScope::Legacy`] → rejected. Callers must pick one of the
/// above; the legacy `<workspace>/skills/` layout is read-only going
/// forward.
///
/// Name hardening:
/// * Slug is derived from `params.name` (lowercased, `[a-z0-9-]` only,
/// non-alphanumeric runs collapsed to a single `-`).
/// * Empty / non-alphanumeric-only names are rejected.
/// * Slug is length-bounded by [`MAX_NAME_LEN`].
/// * The resolved `<scope-root>/<slug>` path is canonicalized and verified
/// to stay inside the canonical scope root (same `starts_with` guard used
/// by [`read_skill_resource`]) to defeat `..` or absolute-path inputs.
/// * Collisions with an existing directory are rejected outright — this
/// function never overwrites.
///
/// On success the freshly created skill is re-discovered through the standard
/// pipeline and returned so callers can drop it straight into the UI list.
pub fn create_skill(workspace_dir: &Path, params: CreateSkillParams) -> Result<Skill, String> {
let home = dirs::home_dir();
create_skill_inner(home.as_deref(), workspace_dir, params)
}
pub(crate) fn create_skill_inner(
home_dir: Option<&Path>,
workspace_dir: &Path,
params: CreateSkillParams,
) -> Result<Skill, String> {
tracing::debug!(
name = %params.name,
scope = ?params.scope,
workspace = %workspace_dir.display(),
"[skills] create_skill: entry"
);
let display_name = params.name.trim();
if display_name.is_empty() {
return Err("name must not be empty".to_string());
}
if display_name.len() > MAX_NAME_LEN {
return Err(format!("name exceeds max {MAX_NAME_LEN} chars"));
}
let description = params.description.trim();
if description.is_empty() {
return Err("description must not be empty".to_string());
}
if description.len() > MAX_DESCRIPTION_LEN {
return Err(format!(
"description exceeds max {MAX_DESCRIPTION_LEN} chars"
));
}
let slug = slugify_skill_name(display_name)?;
let scope_root = match params.scope {
SkillScope::User => {
let home =
home_dir.ok_or_else(|| "could not resolve user home directory".to_string())?;
home.join(".openhuman").join("skills")
}
SkillScope::Project => {
if !is_workspace_trusted(workspace_dir) {
return Err(format!(
"workspace {} is not trusted; create {}/.openhuman/trust to enable project-scope skills",
workspace_dir.display(),
workspace_dir.display(),
));
}
workspace_dir.join(".openhuman").join("skills")
}
SkillScope::Legacy => {
return Err(
"cannot create skill in legacy scope; choose 'user' or 'project'".to_string(),
);
}
};
std::fs::create_dir_all(&scope_root)
.map_err(|e| format!("failed to create skills root {}: {e}", scope_root.display()))?;
let canonical_root = std::fs::canonicalize(&scope_root).map_err(|e| {
format!(
"failed to canonicalize skills root {}: {e}",
scope_root.display()
)
})?;
let skill_dir = canonical_root.join(&slug);
if !skill_dir.starts_with(&canonical_root) {
return Err(format!(
"resolved skill dir {} escapes scope root {}",
skill_dir.display(),
canonical_root.display(),
));
}
if skill_dir.exists() {
return Err(format!(
"skill '{slug}' already exists at {}",
skill_dir.display()
));
}
std::fs::create_dir_all(&skill_dir)
.map_err(|e| format!("failed to create skill dir {}: {e}", skill_dir.display()))?;
let skill_md_path = skill_dir.join(SKILL_MD);
let skill_md = render_skill_md(
&slug,
description,
params.license.as_deref(),
params.author.as_deref(),
&params.tags,
&params.allowed_tools,
);
std::fs::write(&skill_md_path, skill_md)
.map_err(|e| format!("failed to write {}: {e}", skill_md_path.display()))?;
for sub in RESOURCE_DIRS {
let sub_path = skill_dir.join(sub);
std::fs::create_dir_all(&sub_path)
.map_err(|e| format!("failed to create {}: {e}", sub_path.display()))?;
}
tracing::info!(
slug = %slug,
scope = ?params.scope,
location = %skill_md_path.display(),
"[skills] create_skill: wrote SKILL.md"
);
let trusted = is_workspace_trusted(workspace_dir);
let created = discover_skills_inner(home_dir, Some(workspace_dir), trusted)
.into_iter()
.find(|s| s.name == slug)
.ok_or_else(|| format!("created skill '{slug}' but failed to re-discover"))?;
Ok(created)
}
/// Convert a human-readable skill name to a filesystem-safe slug.
///
/// Rules:
/// * ASCII alphanumeric characters are lowercased and kept.
/// * Whitespace, `-`, and `_` collapse to a single `-`.
/// * Any other character is dropped.
/// * Leading / trailing `-` are trimmed.
/// * The empty slug (i.e. the name had no `[a-z0-9]` characters) is rejected.
pub(crate) fn slugify_skill_name(name: &str) -> Result<String, String> {
let mut out = String::new();
let mut prev_hyphen = true;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_hyphen = false;
} else if (ch == '-' || ch == '_' || ch.is_whitespace()) && !prev_hyphen {
out.push('-');
prev_hyphen = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
return Err(format!(
"name '{name}' has no alphanumeric characters; cannot derive slug"
));
}
if out.len() > MAX_NAME_LEN {
return Err(format!("slug '{out}' exceeds max {MAX_NAME_LEN} chars"));
}
Ok(out)
}
/// Render a minimal SKILL.md body for a freshly scaffolded skill.
pub(crate) fn render_skill_md(
slug: &str,
description: &str,
license: Option<&str>,
author: Option<&str>,
tags: &[String],
allowed_tools: &[String],
) -> String {
let mut out = String::new();
out.push_str("---\n");
out.push_str(&format!("name: {slug}\n"));
out.push_str(&format!("description: {}\n", yaml_scalar(description)));
if let Some(v) = license {
out.push_str(&format!("license: {}\n", yaml_scalar(v)));
}
let has_metadata = author.is_some() || !tags.is_empty();
if has_metadata {
out.push_str("metadata:\n");
if let Some(v) = author {
out.push_str(&format!(" author: {}\n", yaml_scalar(v)));
}
if !tags.is_empty() {
out.push_str(" tags:\n");
for t in tags {
out.push_str(&format!(" - {}\n", yaml_scalar(t)));
}
}
}
if !allowed_tools.is_empty() {
out.push_str("allowed-tools:\n");
for t in allowed_tools {
out.push_str(&format!(" - {}\n", yaml_scalar(t)));
}
}
out.push_str("---\n\n");
out.push_str(&format!("# {slug}\n\n"));
out.push_str(description);
if !description.ends_with('\n') {
out.push('\n');
}
out.push_str("\n## Instructions\n\n");
out.push_str("_Describe when and how this skill should be used._\n");
out
}
/// Best-effort YAML scalar encoder: pass plain-safe strings through,
/// double-quote anything with structure / whitespace / control chars.
pub(crate) fn yaml_scalar(s: &str) -> String {
let needs_quote = s.is_empty()
|| s.chars().any(|c| {
matches!(
c,
':' | '#'
| '\''
| '"'
| '\n'
| '\r'
| '\t'
| '['
| ']'
| '{'
| '}'
| ','
| '&'
| '*'
| '!'
| '|'
| '>'
| '%'
| '@'
| '`'
)
})
|| s.starts_with(|c: char| c.is_ascii_whitespace() || c == '-' || c == '?')
|| s.ends_with(|c: char| c.is_ascii_whitespace());
if !needs_quote {
return s.to_string();
}
let escaped = s
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
format!("\"{escaped}\"")
}
+378
View File
@@ -0,0 +1,378 @@
//! Skill discovery: scanning root directories, scope resolution, collision handling,
//! and skill resource reading.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::ops_parse::{load_from_legacy_manifest, load_from_skill_md};
use super::ops_types::{
Skill, SkillScope, MAX_SKILL_RESOURCE_BYTES, SKILL_JSON, SKILL_MD, TRUST_MARKER,
};
/// Initialize the legacy skills directory in the specified workspace.
///
/// Creates `<workspace>/skills/` and a placeholder `README.md` so the folder
/// is visible to the user. New-style skills should live under
/// `<workspace>/.openhuman/skills/` instead, but this directory is kept for
/// backward compatibility.
pub fn init_skills_dir(workspace_dir: &Path) -> Result<(), String> {
let skills_dir = workspace_dir.join("skills");
std::fs::create_dir_all(&skills_dir).map_err(|e| {
format!(
"failed to create skills directory {}: {e}",
skills_dir.display()
)
})?;
let readme_path = skills_dir.join("README.md");
if !readme_path.exists() {
let content = "# Skills\n\nPut one skill per directory under this folder.\n";
std::fs::write(&readme_path, content)
.map_err(|e| format!("failed to write {}: {e}", readme_path.display()))?;
}
Ok(())
}
/// Backwards-compatible shim for callers that only have a workspace path.
///
/// Delegates to [`discover_skills`] with the current user's home directory
/// so user-scope skills (`~/.openhuman/skills/`, `~/.agents/skills/`) are
/// surfaced for existing production callers (`agent::harness::session::builder`,
/// `channels::runtime::startup`). Previously this shim passed `None` for the
/// home directory, which silently dropped user-installed skills from the
/// main runtime path.
///
/// Project-scope (workspace) skills still take precedence over user-scope
/// on name collisions.
pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> {
let trusted = is_workspace_trusted(workspace_dir);
let home = dirs::home_dir();
discover_skills_inner(home.as_deref(), Some(workspace_dir), trusted)
}
/// Discover skills from every supported location.
///
/// * `home_dir` — user home (typically `dirs::home_dir()`), scanned for
/// `~/.openhuman/skills/` and `~/.agents/skills/`.
/// * `workspace_dir` — current workspace, scanned for project-scope paths.
/// * `trusted` — whether the caller has verified the project trust marker.
/// Project-scope skills are silently skipped when `false`.
///
/// On name collisions, project-scope wins over user-scope and a warning is
/// attached to the retained skill.
pub fn discover_skills(
home_dir: Option<&Path>,
workspace_dir: Option<&Path>,
trusted: bool,
) -> Vec<Skill> {
discover_skills_inner(home_dir, workspace_dir, trusted)
}
/// Whether the workspace has opted into loading project-scope skills.
///
/// Looks for `<workspace>/.openhuman/trust`. The marker file's contents are
/// ignored — presence is sufficient.
pub fn is_workspace_trusted(workspace_dir: &Path) -> bool {
workspace_dir.join(".openhuman").join(TRUST_MARKER).exists()
}
pub(crate) fn discover_skills_inner(
home_dir: Option<&Path>,
workspace_dir: Option<&Path>,
trusted: bool,
) -> Vec<Skill> {
// Scan order matters for collision resolution: the last scope to register
// a name wins, so we scan user first, then project, then legacy.
let mut by_name: HashMap<String, Skill> = HashMap::new();
if let Some(home) = home_dir {
for root in user_roots(home) {
absorb(&mut by_name, scan_root(&root, SkillScope::User));
}
}
if let Some(ws) = workspace_dir {
if trusted {
for root in project_roots(ws) {
absorb(&mut by_name, scan_root(&root, SkillScope::Project));
}
}
// Legacy `<workspace>/skills/` is always scanned so existing setups
// keep working without requiring users to move files or add the trust
// marker. Flagged with `legacy = true` so the UI can nudge migration.
absorb(
&mut by_name,
scan_root(&ws.join("skills"), SkillScope::Legacy),
);
}
let mut out: Vec<Skill> = by_name.into_values().collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn user_roots(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".openhuman").join("skills"),
home.join(".agents").join("skills"),
]
}
fn project_roots(workspace: &Path) -> Vec<PathBuf> {
vec![
workspace.join(".openhuman").join("skills"),
workspace.join(".agents").join("skills"),
]
}
fn absorb(by_name: &mut HashMap<String, Skill>, incoming: Vec<Skill>) {
for mut skill in incoming {
let key = skill.name.clone();
if let Some(existing) = by_name.remove(&key) {
// Higher-precedence scope wins; lower loses and is dropped.
let (winner, loser) = if precedence(skill.scope) >= precedence(existing.scope) {
(&mut skill, existing)
} else {
// Put existing back; discard incoming.
let mut kept = existing;
kept.warnings.push(format!(
"name '{}' also declared in {:?} scope at {} (ignored)",
kept.name,
skill.scope,
skill
.location
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unknown>".to_string())
));
by_name.insert(key, kept);
continue;
};
winner.warnings.push(format!(
"shadowed {:?}-scope skill at {} with same name",
loser.scope,
loser
.location
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unknown>".to_string())
));
}
by_name.insert(key, skill);
}
}
fn precedence(scope: SkillScope) -> u8 {
match scope {
SkillScope::Legacy => 0,
SkillScope::User => 1,
SkillScope::Project => 2,
}
}
fn scan_root(root: &Path, scope: SkillScope) -> Vec<Skill> {
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
// `read_dir` order is unspecified. When two sibling directories declare
// the same logical `frontmatter.name` (which can differ from the folder
// name), cross-scope/same-scope deduplication downstream would otherwise
// pick a non-deterministic winner across runs. Sort by on-disk directory
// name for a stable, reproducible order.
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(|entry| entry.file_name());
let mut out = Vec::new();
for entry in entries {
// Use `file_type()` rather than `path.is_dir()` so a symlinked
// child cannot be loaded as a skill. `is_dir()` dereferences
// symlinks, which would re-open out-of-tree loading even though
// `walk_files` already rejects symlinks deeper in the resource
// walker. Skip both symlinks and non-directory entries here; if
// the `file_type()` call itself fails (rare — transient I/O),
// treat it as "not safe to traverse" and skip.
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() || !file_type.is_dir() {
continue;
}
let path = entry.path();
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.starts_with('.') {
continue;
}
if let Some(skill) = load_skill_dir(&path, &dir_name, scope) {
out.push(skill);
}
}
out
}
fn load_skill_dir(dir: &Path, dir_name: &str, scope: SkillScope) -> Option<Skill> {
let skill_md = dir.join(SKILL_MD);
let legacy_manifest = dir.join(SKILL_JSON);
if skill_md.exists() {
return Some(load_from_skill_md(&skill_md, dir, dir_name, scope));
}
if legacy_manifest.exists() {
return Some(load_from_legacy_manifest(
&legacy_manifest,
dir,
dir_name,
scope,
));
}
None
}
/// Read a bundled skill resource as UTF-8 text, hardened against directory
/// traversal, symlink escape, and oversized payloads.
///
/// `skill_id` identifies the skill by its discovered `name` — the same field
/// surfaced on [`Skill::name`]. The skill is resolved by running the standard
/// discovery pipeline (`dirs::home_dir()` + `workspace_dir`, honoring the
/// `.openhuman/trust` marker) and locating the matching entry; this keeps the
/// read scoped to legitimately installed skills and reuses all the symlink /
/// traversal hardening already baked into discovery.
///
/// `relative_path` is resolved relative to the skill's on-disk directory
/// (the parent of its `SKILL.md` / `skill.json`). All of the following are
/// rejected with an error:
///
/// * paths that canonicalize outside the skill root (traversal),
/// * paths whose final component or any intermediate component is a symlink
/// (link-follow escape),
/// * non-file targets (directories, sockets, fifos),
/// * files larger than [`MAX_SKILL_RESOURCE_BYTES`],
/// * non-UTF-8 byte contents (binary files must be surfaced some other way —
/// no lossy replacement).
///
/// On success returns the file's contents as an owned `String`.
pub fn read_skill_resource(
workspace_dir: &Path,
skill_id: &str,
relative_path: &Path,
) -> Result<String, String> {
tracing::debug!(
skill_id = %skill_id,
relative_path = %relative_path.display(),
workspace = %workspace_dir.display(),
"[skills] read_skill_resource: entry"
);
if skill_id.trim().is_empty() {
return Err("skill_id must not be empty".to_string());
}
let relative_str = relative_path.to_string_lossy();
if relative_str.trim().is_empty() {
return Err("relative_path must not be empty".to_string());
}
if relative_path.is_absolute() {
return Err("relative_path must be relative, not absolute".to_string());
}
// Reject any component that is `..`, is empty, starts with `.`, or is the
// root. `..` is the obvious traversal vector; the others are defense in
// depth against unusual path inputs (e.g. `./`, `//foo`, Windows `C:`).
for component in relative_path.components() {
use std::path::Component;
match component {
Component::Normal(_) => {}
Component::ParentDir => {
return Err("relative_path must not contain '..' components".to_string());
}
Component::CurDir | Component::RootDir | Component::Prefix(_) => {
return Err("relative_path must be a plain relative path".to_string());
}
}
}
// Resolve the skill by running the standard discovery pipeline. We reuse
// `load_skills` (which honors both user and workspace roots plus the
// trust marker) so the resource read is scoped to the exact same set of
// skills the UI would already have shown the user.
let skills = load_skills(workspace_dir);
let skill = skills
.into_iter()
.find(|s| s.name == skill_id)
.ok_or_else(|| format!("skill '{skill_id}' not found"))?;
let skill_root = skill
.location
.as_deref()
.and_then(|p| p.parent())
.ok_or_else(|| format!("skill '{skill_id}' has no on-disk location"))?
.to_path_buf();
// Canonicalize the root first. The root must itself be a real directory
// on disk (not a symlink). Reject early if this fails.
let canonical_root = std::fs::canonicalize(&skill_root).map_err(|e| {
format!(
"failed to canonicalize skill root {}: {e}",
skill_root.display()
)
})?;
let requested = canonical_root.join(relative_path);
// Pre-check the immediate target with `symlink_metadata` so we catch
// symlinked leaves before `canonicalize` silently follows them.
let leaf_meta = std::fs::symlink_metadata(&requested)
.map_err(|e| format!("failed to stat resource {}: {e}", requested.display()))?;
if leaf_meta.file_type().is_symlink() {
return Err("resource path is a symlink".to_string());
}
if !leaf_meta.is_file() {
return Err("resource path is not a regular file".to_string());
}
// Size gate — check via metadata before reading so we never allocate the
// buffer for an oversized file.
let size = leaf_meta.len();
if size > MAX_SKILL_RESOURCE_BYTES {
return Err(format!(
"resource file is {size} bytes, exceeds limit of {MAX_SKILL_RESOURCE_BYTES}"
));
}
// Canonicalize the full path and verify it stays within the skill root.
// This catches any symlink reachable via an intermediate path component
// that was created after our initial checks (race-ish, but the
// `is_symlink` check above makes the obvious attack infeasible).
let canonical_requested = std::fs::canonicalize(&requested).map_err(|e| {
format!(
"failed to canonicalize resource {}: {e}",
requested.display()
)
})?;
if !canonical_requested.starts_with(&canonical_root) {
return Err(format!(
"resource path escapes skill root: {}",
canonical_requested.display()
));
}
// Read the bytes and enforce strict UTF-8 (no lossy replacement — we
// would rather refuse a binary file than silently mangle it).
let bytes = std::fs::read(&canonical_requested).map_err(|e| {
format!(
"failed to read resource {}: {e}",
canonical_requested.display()
)
})?;
let content = std::str::from_utf8(&bytes)
.map_err(|e| format!("resource is not valid UTF-8 text: {e}"))?
.to_string();
tracing::debug!(
skill_id = %skill_id,
bytes = bytes.len(),
"[skills] read_skill_resource: success"
);
Ok(content)
}
+740
View File
@@ -0,0 +1,740 @@
//! URL-based skill installation: fetch, validate, and write SKILL.md from a remote URL.
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::ops_discover::{discover_skills_inner, is_workspace_trusted};
use super::ops_parse::parse_skill_md_str;
use super::ops_types::{SkillFrontmatter, SkillScope, MAX_NAME_LEN, SKILL_MD};
/// Default wall-clock budget for the SKILL.md fetch.
pub const DEFAULT_INSTALL_TIMEOUT_SECS: u64 = 60;
/// Hard ceiling callers can request via `timeout_secs`.
pub const MAX_INSTALL_TIMEOUT_SECS: u64 = 600;
/// Upper bound on raw URL length accepted by [`validate_install_url`].
pub const MAX_INSTALL_URL_LEN: usize = 2048;
/// Upper bound on the fetched SKILL.md body. Single-file skills rarely exceed
/// a few KB; the 1 MiB cap here is a defensive limit against a hostile or
/// misconfigured host streaming an unbounded response into memory.
pub const MAX_SKILL_MD_BYTES: usize = 1024 * 1024;
/// Input for [`install_skill_from_url`]. Mirrors the `skills.install_from_url`
/// JSON-RPC payload.
#[derive(Debug, Clone, Deserialize)]
pub struct InstallSkillFromUrlParams {
/// Remote SKILL.md URL. Must be `https://`, resolve to a non-private host
/// (see [`validate_install_url`]), and point at a `.md` file after
/// github.com `/blob/` normalization.
pub url: String,
/// Optional wall-clock budget override, in seconds. Defaults to
/// [`DEFAULT_INSTALL_TIMEOUT_SECS`] and is capped at
/// [`MAX_INSTALL_TIMEOUT_SECS`].
#[serde(default)]
pub timeout_secs: Option<u64>,
}
/// Outcome of a successful install. `new_skills` is the set of skill slugs
/// that appeared in the catalog since the start of the call (post-discovery
/// minus pre-discovery).
#[derive(Debug, Clone, Serialize)]
pub struct InstallSkillFromUrlOutcome {
/// The URL the caller submitted, trimmed.
pub url: String,
/// Human-readable install log — typically `Fetched N bytes from <url>\n
/// Installed to <path>`. Repurposed from the old npx stdout field so the
/// UI success panel keeps the same `<details>` layout.
pub stdout: String,
/// Non-fatal warnings surfaced during parse (e.g. deprecated top-level
/// `version`/`author`/`tags`). Empty on the happy path. Repurposed from
/// the old npx stderr field.
pub stderr: String,
/// Slugs that appeared in the workspace skill catalog as a result of the
/// install. Usually one, empty only when the SKILL.md could not be
/// enumerated by discovery (rare — indicates workspace trust mismatch).
pub new_skills: Vec<String>,
}
/// Install a skill by fetching its `SKILL.md` directly over HTTPS and writing
/// it to `<workspace>/.openhuman/skills/<slug>/SKILL.md`.
///
/// Design rationale: openhuman's skill discovery scans
/// `<workspace>/.openhuman/skills/` (plus `~/.openhuman/skills/` and legacy
/// paths), **not** the per-agent subdirectories that the vercel-labs `skills`
/// CLI writes to (`./claude-code/skills/`, `./cursor/skills/`, …). The CLI's
/// agent ecosystem is incompatible with openhuman's skill layout, so we fetch
/// the SKILL.md file directly and install it into a layout discovery sees.
///
/// Validation applied before any network I/O:
/// * URL length, scheme (`https` only), and host safety via
/// [`validate_install_url`] — rejects loopback, private, link-local,
/// multicast, shared-address ranges, `localhost`, and `.local` / `.localhost`
/// mDNS-style hostnames.
/// * `github.com/<o>/<r>/blob/<b>/<p>` is rewritten to the raw
/// `raw.githubusercontent.com/<o>/<r>/<b>/<p>` equivalent so humans can
/// paste the URL they see in the browser.
/// * The path must end in `.md` (case-insensitive). Repo/tree URLs and
/// tarballs are rejected with `unsupported url form:`.
/// * `timeout_secs` is clamped to [`MAX_INSTALL_TIMEOUT_SECS`].
///
/// Runtime:
/// * Body size is capped by [`MAX_SKILL_MD_BYTES`] (1 MiB). The advertised
/// `Content-Length` is checked up front; the buffered body length is
/// checked again after the download as defense against a lying header.
/// * Frontmatter is validated — `name` and `description` are required per
/// the agentskills.io spec.
/// * The slug is derived from `metadata.id` when present, otherwise the
/// sanitized `name` field. Collision with an existing directory is fatal
/// (no silent overwrite).
/// * Write is atomic: `SKILL.md.tmp` in the target dir, then `rename` on
/// success.
///
/// On success the full post-install skills catalog is re-discovered and the
/// outcome includes the list of skill slugs that appeared since the start of
/// the call.
pub async fn install_skill_from_url(
workspace_dir: &Path,
params: InstallSkillFromUrlParams,
) -> Result<InstallSkillFromUrlOutcome, String> {
let raw_url = params.url.trim().to_string();
validate_install_url(&raw_url)?;
let timeout_secs = params
.timeout_secs
.unwrap_or(DEFAULT_INSTALL_TIMEOUT_SECS)
.clamp(1, MAX_INSTALL_TIMEOUT_SECS);
let fetch_url = normalize_install_url(&raw_url)?;
// Second-layer SSRF guard: a public-looking hostname can still resolve
// to a loopback / private / link-local address (DNS-to-private-IP). We
// resolve the host up-front and reject if any returned IP is private.
// Known caveat: this does not fully prevent DNS rebinding — reqwest's
// resolver may see different answers than ours. Closing that gap requires
// pinning a `SocketAddr` and passing it to reqwest via a custom resolver,
// tracked separately.
validate_resolved_host(&fetch_url).await?;
tracing::debug!(
raw_url = %raw_url,
fetch_url = %fetch_url,
workspace = %workspace_dir.display(),
timeout_secs = timeout_secs,
"[skills] install_skill_from_url: entry"
);
let home = dirs::home_dir();
let trusted_before = is_workspace_trusted(workspace_dir);
let before: std::collections::HashSet<String> =
discover_skills_inner(home.as_deref(), Some(workspace_dir), trusted_before)
.into_iter()
.map(|s| s.name)
.collect();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()
.map_err(|e| format!("fetch failed: build http client: {e}"))?;
tracing::info!(
fetch_url = %fetch_url,
"[skills] install_skill_from_url: fetching SKILL.md"
);
let response = match client.get(&fetch_url).send().await {
Ok(resp) => resp,
Err(e) => {
if e.is_timeout() {
return Err(format!("fetch timed out after {timeout_secs}s"));
}
return Err(format!("fetch failed: {e}"));
}
};
let status = response.status();
if !status.is_success() {
return Err(format!(
"fetch failed: {fetch_url} returned status {}",
status.as_u16()
));
}
if let Some(len) = response.content_length() {
if len > MAX_SKILL_MD_BYTES as u64 {
return Err(format!(
"fetch too large: {} bytes exceeds {MAX_SKILL_MD_BYTES} limit",
len
));
}
}
let bytes = match response.bytes().await {
Ok(b) => b,
Err(e) => {
if e.is_timeout() {
return Err(format!("fetch timed out after {timeout_secs}s"));
}
return Err(format!("fetch failed: reading body: {e}"));
}
};
if bytes.len() > MAX_SKILL_MD_BYTES {
return Err(format!(
"fetch too large: {} bytes exceeds {MAX_SKILL_MD_BYTES} limit",
bytes.len()
));
}
let content = String::from_utf8(bytes.to_vec())
.map_err(|e| format!("invalid SKILL.md: body is not valid utf-8: {e}"))?;
let (frontmatter, _body, parse_warnings) = parse_skill_md_str(&content).ok_or_else(|| {
"invalid SKILL.md: frontmatter block opened with `---` but never terminated".to_string()
})?;
if frontmatter.name.trim().is_empty() {
return Err("invalid SKILL.md: missing required field 'name'".to_string());
}
if frontmatter.description.trim().is_empty() {
return Err("invalid SKILL.md: missing required field 'description'".to_string());
}
let slug = derive_install_slug(&frontmatter)?;
// Install to user scope (`~/.openhuman/skills/<slug>`), which `discover_skills`
// scans unconditionally. Project scope (`<ws>/.openhuman/skills/`) is gated on
// a `<ws>/.openhuman/trust` marker and would render the install invisible to the
// skills list until the user opts the workspace into trust.
let skills_root = home
.as_deref()
.ok_or_else(|| "write failed: unable to resolve home directory".to_string())?
.join(".openhuman")
.join("skills");
let target_dir = skills_root.join(&slug);
if target_dir.exists() {
return Err(format!(
"skill already installed as {slug:?} at {}",
target_dir.display()
));
}
std::fs::create_dir_all(&target_dir).map_err(|e| {
format!(
"write failed: create directory {}: {e}",
target_dir.display()
)
})?;
let target_file = target_dir.join(SKILL_MD);
let temp_file = target_dir.join("SKILL.md.tmp");
// Roll the partial install back if either filesystem op fails so the
// next retry isn't blocked by a leftover empty directory. Cleanup is
// best-effort — if it fails, we surface the original write error.
let write_result: Result<(), String> = std::fs::write(&temp_file, &content)
.map_err(|e| format!("write failed: {}: {e}", temp_file.display()))
.and_then(|_| {
std::fs::rename(&temp_file, &target_file)
.map_err(|e| format!("write failed: rename {}: {e}", target_file.display()))
});
if let Err(e) = write_result {
let _ = std::fs::remove_file(&temp_file);
if let Err(rm_err) = std::fs::remove_dir(&target_dir) {
tracing::warn!(
target_dir = %target_dir.display(),
error = %rm_err,
"[skills] install_skill_from_url: rollback remove_dir failed (non-fatal)"
);
} else {
tracing::warn!(
target_dir = %target_dir.display(),
"[skills] install_skill_from_url: rolled back partial install after write failure"
);
}
return Err(e);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o644);
if let Err(e) = std::fs::set_permissions(&target_file, perms) {
tracing::warn!(
target = %target_file.display(),
error = %e,
"[skills] install_skill_from_url: chmod 0644 failed (non-fatal)"
);
}
}
let trusted_after = is_workspace_trusted(workspace_dir);
let after = discover_skills_inner(home.as_deref(), Some(workspace_dir), trusted_after);
let new_skills: Vec<String> = after
.into_iter()
.map(|s| s.name)
.filter(|name| !before.contains(name))
.collect();
tracing::info!(
raw_url = %raw_url,
fetch_url = %fetch_url,
slug = %slug,
bytes = content.len(),
new_count = new_skills.len(),
"[skills] install_skill_from_url: completed"
);
let stdout = format!(
"Fetched {} bytes from {fetch_url}\nInstalled to {}",
content.len(),
target_file.display()
);
let stderr = parse_warnings.join("\n");
Ok(InstallSkillFromUrlOutcome {
url: raw_url,
stdout,
stderr,
new_skills,
})
}
/// Input for [`uninstall_skill`]. Mirrors the `skills.uninstall` JSON-RPC payload.
#[derive(Debug, Clone, Deserialize)]
pub struct UninstallSkillParams {
/// On-disk slug of the installed skill — the directory name under
/// `~/.openhuman/skills/<slug>/`. Retained as `name` for wire-format
/// back-compat with pre-existing clients; semantics are slug-only.
pub name: String,
}
/// Outcome of a successful uninstall.
#[derive(Debug, Clone, Serialize)]
pub struct UninstallSkillOutcome {
/// The normalised slug that was removed.
pub name: String,
/// Absolute on-disk path that was deleted (post-canonicalisation).
pub removed_path: String,
/// Scope the uninstall applied to. Always `User` today.
pub scope: SkillScope,
}
/// Remove an installed user-scope SKILL.md skill from `~/.openhuman/skills/`.
///
/// Only user-scope uninstalls are supported. Resolution is defensive:
/// canonicalises paths, refuses symlinks, requires SKILL.md to be present.
///
/// `home_dir_override` is for tests; production callers pass `None`.
pub fn uninstall_skill(
params: UninstallSkillParams,
home_dir_override: Option<&Path>,
) -> Result<UninstallSkillOutcome, String> {
let trimmed = params.name.trim().to_string();
if trimmed.is_empty() {
return Err("skill name is required".to_string());
}
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
log::warn!(
"[skills] uninstall_skill: rejected name with path separators name={:?}",
trimmed
);
return Err(format!(
"skill name '{trimmed}' must not contain path separators"
));
}
if trimmed.len() > MAX_NAME_LEN {
return Err(format!(
"skill name is {} chars (max {MAX_NAME_LEN})",
trimmed.len()
));
}
let home = match home_dir_override
.map(|p| p.to_path_buf())
.or_else(dirs::home_dir)
{
Some(h) => h,
None => return Err("could not resolve user home directory".to_string()),
};
let skills_root = home.join(".openhuman").join("skills");
if !skills_root.exists() {
return Err(format!(
"no user skills directory at {}",
skills_root.display()
));
}
let root_meta = std::fs::symlink_metadata(&skills_root)
.map_err(|e| format!("stat {} failed: {e}", skills_root.display()))?;
if root_meta.file_type().is_symlink() {
log::warn!(
"[skills] uninstall_skill: refused symlinked skills root path={}",
skills_root.display()
);
return Err(format!(
"skills root {} is a symlink — refusing to resolve",
skills_root.display()
));
}
let canonical_root = std::fs::canonicalize(&skills_root)
.map_err(|e| format!("canonicalize {} failed: {e}", skills_root.display()))?;
let candidate = skills_root.join(&trimmed);
match std::fs::symlink_metadata(&candidate) {
Ok(m) if m.file_type().is_symlink() => {
log::warn!(
"[skills] uninstall_skill: refused symlinked alias name={trimmed} path={}",
candidate.display()
);
return Err(format!(
"skill '{trimmed}' is a symlinked alias — refusing to resolve"
));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(format!("skill '{trimmed}' is not installed"));
}
Err(e) => {
return Err(format!("stat {} failed: {e}", candidate.display()));
}
}
let canonical_candidate = std::fs::canonicalize(&candidate).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
format!("skill '{trimmed}' is not installed")
} else {
format!("canonicalize {} failed: {e}", candidate.display())
}
})?;
if !canonical_candidate.starts_with(&canonical_root) {
log::warn!(
"[skills] uninstall_skill: path escape rejected candidate={} root={}",
canonical_candidate.display(),
canonical_root.display()
);
return Err(format!(
"refused to remove {} — path escapes skills root",
canonical_candidate.display()
));
}
let meta = std::fs::symlink_metadata(&canonical_candidate)
.map_err(|e| format!("stat {} failed: {e}", canonical_candidate.display()))?;
if meta.file_type().is_symlink() || !meta.is_dir() {
return Err(format!(
"{} is not a directory — refusing to remove",
canonical_candidate.display()
));
}
let skill_md = canonical_candidate.join(SKILL_MD);
if !skill_md.exists() {
return Err(format!(
"{} does not look like a SKILL.md skill (missing {SKILL_MD})",
canonical_candidate.display()
));
}
log::info!(
"[skills] uninstall_skill: removing name={trimmed} path={}",
canonical_candidate.display()
);
std::fs::remove_dir_all(&canonical_candidate)
.map_err(|e| format!("remove {} failed: {e}", canonical_candidate.display()))?;
Ok(UninstallSkillOutcome {
name: trimmed,
removed_path: canonical_candidate.display().to_string(),
scope: SkillScope::User,
})
}
/// Rewrite `github.com/<o>/<r>/blob/<branch>/<path>` into its raw counterpart
/// so a URL copied from a browser's GitHub page resolves to the file body
/// instead of the HTML wrapper. Any other host is returned unchanged.
///
/// Also enforces that the final path ends in `.md` (case-insensitive). Tree,
/// commit, and whole-repo URLs are rejected here — they require a
/// fundamentally different install path (recursive fetch / tarball) that is
/// out of scope for single-file SKILL.md installs.
pub(crate) fn normalize_install_url(raw: &str) -> Result<String, String> {
let parsed =
url::Url::parse(raw).map_err(|e| format!("unsupported url form: parse {raw:?}: {e}"))?;
let host = parsed.host_str().unwrap_or("").to_ascii_lowercase();
let normalized = if host == "github.com" {
let segments: Vec<&str> = parsed
.path_segments()
.map(|it| it.collect())
.unwrap_or_default();
if segments.len() >= 5 && segments[2] == "blob" {
let owner = segments[0];
let repo = segments[1];
let branch = segments[3];
let rest = segments[4..].join("/");
format!("https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{rest}")
} else if segments.len() >= 3 && (segments[2] == "tree" || segments[2] == "raw") {
return Err(format!(
"unsupported url form: only direct SKILL.md links are supported, got {raw:?} (tree/dir URLs are not yet supported)"
));
} else if segments.len() <= 2 {
return Err(format!(
"unsupported url form: only direct SKILL.md links are supported, got {raw:?} (whole-repo URLs are not yet supported)"
));
} else {
raw.to_string()
}
} else {
raw.to_string()
};
let check = url::Url::parse(&normalized)
.map_err(|e| format!("unsupported url form: parse normalized {normalized:?}: {e}"))?;
let path_lower = check.path().to_ascii_lowercase();
if !path_lower.ends_with(".md") {
return Err(format!(
"unsupported url form: path must end in .md, got {normalized:?}"
));
}
Ok(normalized)
}
/// Derive the install directory slug from the SKILL.md frontmatter.
///
/// Prefers `metadata.id` (the spec-aligned identifier) when present. Falls
/// back to a sanitized form of `name`:
/// * lowercase ASCII
/// * non-alphanumeric runs collapsed to a single `-`
/// * leading/trailing `-` trimmed
///
/// Rejects the empty string and paths that would escape the skills root
/// (`..`, `/`, `\`). Max length is [`MAX_NAME_LEN`].
pub(crate) fn derive_install_slug(fm: &SkillFrontmatter) -> Result<String, String> {
let candidate = fm
.metadata
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| fm.name.clone());
let mut out = String::with_capacity(candidate.len());
let mut last_dash = false;
for ch in candidate.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
last_dash = false;
} else if !last_dash && !out.is_empty() {
out.push('-');
last_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
return Err(
"invalid SKILL.md: cannot derive slug from empty name/id — set a value in frontmatter"
.to_string(),
);
}
if out.len() > MAX_NAME_LEN {
return Err(format!(
"invalid SKILL.md: derived slug {out:?} exceeds {MAX_NAME_LEN} chars"
));
}
if out.contains("..") || out.contains('/') || out.contains('\\') {
return Err(format!(
"invalid SKILL.md: derived slug {out:?} contains forbidden path components"
));
}
Ok(out)
}
/// Validate a remote skill install URL. Returns `Ok(())` when the URL is
/// well-formed, uses `https`, and points at a public host.
///
/// Rejects:
/// * empty string or > [`MAX_INSTALL_URL_LEN`] bytes
/// * non-`https` schemes (including `http`, `ftp`, `file`, `git+ssh`)
/// * missing or empty host
/// * `localhost`, `*.localhost`, `*.local`
/// * IPv4 literals in loopback (127.0.0.0/8), private (10/8, 172.16/12,
/// 192.168/16), link-local (169.254/16), shared-address (100.64/10),
/// multicast, broadcast, or unspecified (0.0.0.0) ranges
/// * IPv6 literals in loopback (::1), unspecified (::), unique-local
/// (fc00::/7), link-local (fe80::/10), or multicast (ff00::/8)
pub fn validate_install_url(raw: &str) -> Result<(), String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("url must not be empty".to_string());
}
if trimmed.len() > MAX_INSTALL_URL_LEN {
return Err(format!(
"url exceeds max {MAX_INSTALL_URL_LEN} chars (got {})",
trimmed.len()
));
}
let parsed = url::Url::parse(trimmed).map_err(|e| format!("invalid url {trimmed:?}: {e}"))?;
if parsed.scheme() != "https" {
return Err(format!(
"url scheme {:?} not allowed; https only",
parsed.scheme()
));
}
let host = parsed
.host_str()
.ok_or_else(|| format!("url {trimmed:?} has no host"))?;
if host.is_empty() {
return Err(format!("url {trimmed:?} has empty host"));
}
if is_blocked_install_host(host) {
return Err(format!(
"host {host:?} not allowed (loopback/private/link-local/multicast)"
));
}
Ok(())
}
/// Resolve the host in the given URL and reject if any returned IP falls in
/// loopback / private / link-local / multicast / unspecified ranges.
///
/// Covers the DNS-to-private-IP SSRF vector: a public-looking hostname can
/// still resolve to 127.0.0.1 / 169.254.x / fc00::/7 etc., which
/// [`validate_install_url`] alone cannot detect because it only inspects
/// literal IP hosts.
///
/// Caveat: does **not** close the DNS-rebinding gap. `reqwest` performs its
/// own DNS lookup on the GET below, and a rebinding server can answer the
/// check with a public IP and answer reqwest with a private one. Full
/// mitigation requires resolving to a `SocketAddr` here and passing it to
/// reqwest via a custom resolver that only honours the pinned address.
pub async fn validate_resolved_host(raw_url: &str) -> Result<(), String> {
let parsed = url::Url::parse(raw_url)
.map_err(|e| format!("invalid url {raw_url:?} during DNS guard: {e}"))?;
let host = parsed
.host_str()
.ok_or_else(|| format!("url {raw_url:?} has no host (DNS guard)"))?;
// `tokio::net::lookup_host` wants "host:port". Default https → 443.
let port = parsed.port_or_known_default().unwrap_or(443);
// IPv6 literal hosts come back bracketed from `url::Url`; `lookup_host`
// needs the bracketed form for IPv6 to parse correctly.
let lookup_target = if parsed
.host()
.map(|h| matches!(h, url::Host::Ipv6(_)))
.unwrap_or(false)
{
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
};
tracing::debug!(
host = %host,
port = port,
"[skills] validate_resolved_host: resolving"
);
let mut addrs = tokio::net::lookup_host(&lookup_target)
.await
.map_err(|e| format!("dns lookup failed for {host:?}: {e}"))?
.peekable();
if addrs.peek().is_none() {
return Err(format!("host {host:?} resolved to no IP addresses"));
}
for addr in addrs {
let ip = addr.ip();
match ip {
std::net::IpAddr::V4(v4) => {
if is_private_v4(&v4) {
tracing::warn!(
host = %host,
resolved = %v4,
"[skills] validate_resolved_host: rejected private IPv4"
);
return Err(format!(
"host {host:?} resolved to non-public IPv4 {v4} (loopback/private/link-local)"
));
}
}
std::net::IpAddr::V6(v6) => {
if is_private_v6(&v6) {
tracing::warn!(
host = %host,
resolved = %v6,
"[skills] validate_resolved_host: rejected private IPv6"
);
return Err(format!(
"host {host:?} resolved to non-public IPv6 {v6} (loopback/ula/link-local)"
));
}
}
}
}
Ok(())
}
fn is_blocked_install_host(host: &str) -> bool {
let lower = host.to_ascii_lowercase();
// url::Url::host_str returns IPv6 literals wrapped in brackets (e.g. "[::1]").
// Strip them before attempting Ipv6Addr parse.
let stripped = lower
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(&lower);
if stripped == "localhost" || stripped.ends_with(".localhost") || stripped.ends_with(".local") {
return true;
}
if let Ok(v4) = stripped.parse::<Ipv4Addr>() {
return is_private_v4(&v4);
}
if let Ok(v6) = stripped.parse::<Ipv6Addr>() {
return is_private_v6(&v6);
}
false
}
fn is_private_v4(ip: &Ipv4Addr) -> bool {
if ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_broadcast()
|| ip.is_unspecified()
|| ip.is_multicast()
{
return true;
}
let [a, b, _, _] = ip.octets();
// 100.64.0.0/10 shared address (CGN)
if a == 100 && (64..=127).contains(&b) {
return true;
}
// 0.0.0.0/8
if a == 0 {
return true;
}
false
}
fn is_private_v6(ip: &Ipv6Addr) -> bool {
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
return true;
}
let first = ip.segments()[0];
// fc00::/7 unique-local
if (first & 0xfe00) == 0xfc00 {
return true;
}
// fe80::/10 link-local
if (first & 0xffc0) == 0xfe80 {
return true;
}
false
}
+308
View File
@@ -0,0 +1,308 @@
//! SKILL.md parsing, resource inventory, and skill-resource reading.
use std::path::{Path, PathBuf};
use super::ops_types::{extract_author, extract_tags, extract_version};
use super::ops_types::{
LegacySkillManifest, Skill, SkillFrontmatter, SkillScope, MAX_DESCRIPTION_LEN, MAX_NAME_LEN,
RESOURCE_DIRS,
};
/// Split a `SKILL.md` file into parsed frontmatter and the remaining body.
///
/// Accepts frontmatter delimited by leading `---` lines. Returns `None` when
/// the file cannot be read or the frontmatter block is unterminated.
///
/// The third element of the tuple carries parse-level diagnostics — for now
/// just the YAML deserialisation error when frontmatter exists but is
/// malformed. Callers merge these into the skill's user-visible warnings so
/// the catalog surfaces the real cause instead of a generic "could not parse"
/// placeholder.
pub fn parse_skill_md(path: &Path) -> Option<(SkillFrontmatter, String, Vec<String>)> {
let content = std::fs::read_to_string(path).ok()?;
parse_skill_md_str(&content)
}
/// Content-only variant of [`parse_skill_md`] used when the SKILL.md has been
/// fetched over HTTPS (see [`install_skill_from_url`]) and has not yet landed
/// on disk. Returns `None` when the frontmatter block is opened with `---` but
/// never terminated — the same failure mode the file-based parser rejects.
pub fn parse_skill_md_str(content: &str) -> Option<(SkillFrontmatter, String, Vec<String>)> {
let mut lines = content.lines();
let first = lines.next()?;
if first.trim() != "---" {
// No frontmatter — treat whole file as body.
return Some((SkillFrontmatter::default(), content.to_string(), Vec::new()));
}
let mut yaml = String::new();
let mut terminated = false;
let mut body = String::new();
for line in lines {
if line.trim() == "---" {
terminated = true;
continue;
}
if !terminated {
yaml.push_str(line);
yaml.push('\n');
} else {
body.push_str(line);
body.push('\n');
}
}
if !terminated {
return None;
}
let mut parse_warnings = Vec::new();
let frontmatter = match serde_yaml::from_str::<SkillFrontmatter>(&yaml) {
Ok(fm) => fm,
Err(err) => {
log::warn!("[skills] failed to parse frontmatter: {err}");
parse_warnings.push(format!("frontmatter parse error: {err}"));
SkillFrontmatter::default()
}
};
Some((frontmatter, body, parse_warnings))
}
/// Shallow-scan a skill directory for bundled resources.
///
/// Returns every file (relative to `dir`) under any of the conventional
/// resource subdirectories (`scripts/`, `references/`, `assets/`). Deeper
/// nesting is walked recursively.
pub fn inventory_resources(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for sub in RESOURCE_DIRS {
let root = dir.join(sub);
// `root.is_dir()` follows symlinks, so a `scripts -> /some/other/tree`
// symlink would still pass and `walk_files` would inventory the
// external tree. Use `symlink_metadata` for a non-dereferencing check
// and reject symlinked roots outright; `walk_files` already guards
// deeper symlinks inside the tree.
let meta = match std::fs::symlink_metadata(&root) {
Ok(m) => m,
Err(_) => continue,
};
if meta.file_type().is_symlink() || !meta.is_dir() {
continue;
}
walk_files(&root, dir, &mut out);
}
out.sort();
out
}
pub(crate) fn walk_files(current: &Path, base: &Path, out: &mut Vec<PathBuf>) {
let entries = match std::fs::read_dir(current) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
// Use `file_type()` — not `is_dir()` / `is_file()` — so we can detect and
// skip symlinks before traversing. `is_dir()`/`is_file()` follow symlinks
// and would cause unbounded recursion on a cycle (e.g. `resources/self ->
// resources/`) or silent leakage outside the skill directory when a
// symlink points at `/`, `/etc`, or another skill's tree.
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
let path = entry.path();
if file_type.is_dir() {
walk_files(&path, base, out);
} else if file_type.is_file() {
if let Ok(rel) = path.strip_prefix(base) {
out.push(rel.to_path_buf());
}
}
}
}
pub(crate) fn first_body_line(body: &str) -> Option<String> {
for line in body.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
return Some(trimmed.to_string());
}
None
}
/// Load a skill from a `SKILL.md` file.
pub(crate) fn load_from_skill_md(
skill_md: &Path,
dir: &Path,
dir_name: &str,
scope: SkillScope,
) -> Skill {
let mut warnings = Vec::new();
let (frontmatter, body) = match parse_skill_md(skill_md) {
Some((fm, body, parse_warnings)) => {
warnings.extend(parse_warnings);
(fm, body)
}
None => {
warnings.push(format!(
"could not parse {} — exposing directory as placeholder",
skill_md.display()
));
(SkillFrontmatter::default(), String::new())
}
};
let name = if frontmatter.name.trim().is_empty() {
warnings.push("frontmatter missing 'name'; using directory name".to_string());
dir_name.to_string()
} else {
if frontmatter.name != dir_name {
warnings.push(format!(
"frontmatter name '{}' does not match directory '{}'",
frontmatter.name, dir_name
));
}
if frontmatter.name.len() > MAX_NAME_LEN {
warnings.push(format!(
"frontmatter name is {} chars (max recommended: {})",
frontmatter.name.len(),
MAX_NAME_LEN
));
}
frontmatter.name.clone()
};
let description = if frontmatter.description.trim().is_empty() {
warnings
.push("frontmatter missing 'description'; falling back to first body line".to_string());
first_body_line(&body).unwrap_or_else(|| "No description provided".to_string())
} else {
if frontmatter.description.len() > MAX_DESCRIPTION_LEN {
warnings.push(format!(
"description is {} chars (max recommended: {})",
frontmatter.description.len(),
MAX_DESCRIPTION_LEN
));
}
frontmatter.description.clone()
};
let version = extract_version(&frontmatter, &mut warnings);
let author = extract_author(&frontmatter, &mut warnings);
let tags = extract_tags(&frontmatter, &mut warnings);
let tools = frontmatter.allowed_tools.clone();
Skill {
name,
dir_name: dir_name.to_string(),
description,
version,
author,
tags,
tools,
prompts: Vec::new(),
location: Some(skill_md.to_path_buf()),
frontmatter,
resources: inventory_resources(dir),
scope,
legacy: false,
warnings,
}
}
/// Load a skill from a legacy `skill.json` manifest.
pub(crate) fn load_from_legacy_manifest(
manifest_path: &Path,
dir: &Path,
dir_name: &str,
scope: SkillScope,
) -> Skill {
let mut warnings = vec![format!(
"skill uses legacy skill.json; migrate to SKILL.md frontmatter"
)];
let parsed = std::fs::read_to_string(manifest_path)
.ok()
.and_then(|content| serde_json::from_str::<LegacySkillManifest>(&content).ok());
let manifest = parsed.unwrap_or_else(|| {
warnings.push(format!(
"could not parse {} as JSON; using directory name",
manifest_path.display()
));
LegacySkillManifest {
name: dir_name.to_string(),
description: String::new(),
version: String::new(),
author: None,
tags: Vec::new(),
tools: Vec::new(),
prompts: Vec::new(),
}
});
let name = if manifest.name.trim().is_empty() {
dir_name.to_string()
} else {
manifest.name
};
// `load_from_legacy_manifest` is only called when SKILL.md is absent
// (see load_skill_dir), so there is no SKILL.md to fall back to here.
let description = if manifest.description.is_empty() {
"No description provided".to_string()
} else {
manifest.description
};
let location = Some(manifest_path.to_path_buf());
Skill {
name,
dir_name: dir_name.to_string(),
description,
version: manifest.version,
author: manifest.author,
tags: manifest.tags,
tools: manifest.tools,
prompts: manifest.prompts,
location,
frontmatter: SkillFrontmatter::default(),
resources: inventory_resources(dir),
scope,
legacy: true,
warnings,
}
}
impl Skill {
/// Re-read the SKILL.md body (everything after the YAML frontmatter
/// block) from disk. Returns `None` for legacy `skill.json` skills,
/// for skills whose `location` points nowhere, or when the file
/// cannot be parsed as a SKILL.md document.
pub fn read_body(&self) -> Option<String> {
if self.legacy {
log::debug!(
"[skills:inject] read_body skipped for legacy skill.json skill name={}",
self.name
);
return None;
}
let path = self.location.as_ref()?;
match parse_skill_md(path) {
Some((_, body, _)) => Some(body),
None => {
log::warn!(
"[skills:inject] read_body failed to parse {} for skill {}",
path.display(),
self.name
);
None
}
}
}
}
+192
View File
@@ -0,0 +1,192 @@
//! Core types, constants, and frontmatter helpers for the skills subsystem.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
pub(crate) const TRUST_MARKER: &str = "trust";
pub(crate) const SKILL_MD: &str = "SKILL.md";
pub(crate) const SKILL_JSON: &str = "skill.json";
pub(crate) const MAX_NAME_LEN: usize = 64;
pub(crate) const MAX_DESCRIPTION_LEN: usize = 1024;
pub(crate) const RESOURCE_DIRS: &[&str] = &["scripts", "references", "assets"];
/// Upper bound on resource payload size (in bytes) returned by
/// [`read_skill_resource`]. 128 KB is large enough for a typical SKILL-bundled
/// script or reference doc but small enough to keep the JSON-RPC payload and
/// UI memory footprint bounded even when a skill author bundles something
/// unusually chonky (e.g. a minified binary fixture). Requests for files
/// larger than this limit are rejected outright — callers must stream or
/// download the file via another mechanism.
pub const MAX_SKILL_RESOURCE_BYTES: u64 = 128 * 1024;
/// Where the skill was discovered. Determines precedence on name collision.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillScope {
/// Skill shipped with the user's global config (`~/.openhuman/skills/...`).
User,
/// Skill shipped with the current workspace (`<ws>/.openhuman/skills/...`).
/// Requires the trust marker to be loaded.
Project,
/// Skill discovered under the legacy `<workspace>/skills/` layout.
Legacy,
}
impl Default for SkillScope {
fn default() -> Self {
Self::User
}
}
/// Parsed frontmatter of a `SKILL.md` file.
///
/// Matches the agentskills.io SKILL.md spec: `name` and `description` are
/// required; `license`, `compatibility`, `metadata`, and `allowed-tools` are
/// optional. Spec additions land in [`Self::extra`] via `#[serde(flatten)]`.
///
/// Version, author, tags, and other non-required fields belong under
/// [`Self::metadata`]. Writers that still put them at the top level are
/// accepted with a migration warning.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SkillFrontmatter {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub compatibility: Option<String>,
/// Spec-compliant metadata map. Version, author, tags, and other
/// non-required fields live here.
#[serde(default)]
pub metadata: HashMap<String, serde_yaml::Value>,
/// Tools the skill author asserts their instructions rely on
/// (non-binding hint; the host decides what to expose).
#[serde(default, rename = "allowed-tools", alias = "allowed_tools")]
pub allowed_tools: Vec<String>,
/// Forward-compat hatch for spec additions. Non-spec top-level keys
/// (including legacy `version`, `author`, `tags`) land here and trigger
/// a migration warning when read.
#[serde(flatten)]
pub extra: HashMap<String, serde_yaml::Value>,
}
pub(crate) fn metadata_string(fm: &SkillFrontmatter, key: &str) -> Option<String> {
fm.metadata
.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
pub(crate) fn metadata_string_seq(value: &serde_yaml::Value) -> Vec<String> {
value
.as_sequence()
.map(|seq| {
seq.iter()
.filter_map(|t| t.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn extract_version(fm: &SkillFrontmatter, warnings: &mut Vec<String>) -> String {
if let Some(v) = metadata_string(fm, "version") {
return v;
}
if let Some(v) = fm.extra.get("version").and_then(|v| v.as_str()) {
log::warn!("[skills] top-level 'version' is deprecated; move under 'metadata.version'");
warnings
.push("top-level 'version' is deprecated; move under 'metadata.version'".to_string());
return v.to_string();
}
String::new()
}
pub(crate) fn extract_author(fm: &SkillFrontmatter, warnings: &mut Vec<String>) -> Option<String> {
if let Some(v) = metadata_string(fm, "author") {
return Some(v);
}
if let Some(v) = fm.extra.get("author").and_then(|v| v.as_str()) {
log::warn!("[skills] top-level 'author' is deprecated; move under 'metadata.author'");
warnings.push("top-level 'author' is deprecated; move under 'metadata.author'".to_string());
return Some(v.to_string());
}
None
}
pub(crate) fn extract_tags(fm: &SkillFrontmatter, warnings: &mut Vec<String>) -> Vec<String> {
if let Some(v) = fm.metadata.get("tags") {
return metadata_string_seq(v);
}
if let Some(v) = fm.extra.get("tags") {
log::warn!("[skills] top-level 'tags' is deprecated; move under 'metadata.tags'");
warnings.push("top-level 'tags' is deprecated; move under 'metadata.tags'".to_string());
return metadata_string_seq(v);
}
Vec::new()
}
/// A discovered skill.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Skill {
/// Display name (from frontmatter, falls back to directory name).
pub name: String,
/// On-disk slug — the directory name under `~/.openhuman/skills/` (user
/// scope) or the workspace skills directory (project scope). This is the
/// identifier the uninstall RPC resolves against; it may differ from
/// [`Skill::name`] when frontmatter declares a mismatched display name.
#[serde(default)]
pub dir_name: String,
/// Short description used in the catalog summary.
pub description: String,
/// Version string, if declared.
pub version: String,
/// Author string, if declared.
pub author: Option<String>,
/// Tags declared in frontmatter.
pub tags: Vec<String>,
/// Tool hint declared in frontmatter (`allowed-tools`).
#[serde(default)]
pub tools: Vec<String>,
/// Prompt files declared in legacy `skill.json`. Unused for SKILL.md skills.
#[serde(default)]
pub prompts: Vec<String>,
/// Path to the `SKILL.md` (or `skill.json`) file.
pub location: Option<PathBuf>,
/// Full parsed frontmatter when sourced from `SKILL.md`.
#[serde(default)]
pub frontmatter: SkillFrontmatter,
/// Bundled resource files (relative to the skill directory).
#[serde(default)]
pub resources: Vec<PathBuf>,
/// Where the skill came from.
#[serde(default)]
pub scope: SkillScope,
/// True when loaded from the legacy `skill.json` / `<ws>/skills/` layout.
#[serde(default)]
pub legacy: bool,
/// Non-fatal parse warnings, surfaced in the catalog for user debugging.
#[serde(default)]
pub warnings: Vec<String>,
}
/// Internal structure for parsing legacy `skill.json` manifests.
#[derive(Debug, Deserialize)]
pub(crate) struct LegacySkillManifest {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub prompts: Vec<String>,
}
@@ -0,0 +1,211 @@
use super::types::{BrowserAction, ResolvedBackend};
use serde_json::Value;
/// Parse a JSON `args` object into a typed `BrowserAction`.
pub(crate) fn parse_browser_action(
action_str: &str,
args: &Value,
) -> anyhow::Result<BrowserAction> {
match action_str {
"open" => {
let url = args
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'url' for open action"))?;
Ok(BrowserAction::Open { url: url.into() })
}
"snapshot" => Ok(BrowserAction::Snapshot {
interactive_only: args
.get("interactive_only")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true),
compact: args
.get("compact")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true),
depth: args
.get("depth")
.and_then(serde_json::Value::as_u64)
.map(|d| u32::try_from(d).unwrap_or(u32::MAX)),
}),
"click" => {
let selector = args
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for click"))?;
Ok(BrowserAction::Click {
selector: selector.into(),
})
}
"fill" => {
let selector = args
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for fill"))?;
let value = args
.get("value")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'value' for fill"))?;
Ok(BrowserAction::Fill {
selector: selector.into(),
value: value.into(),
})
}
"type" => {
let selector = args
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for type"))?;
let text = args
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'text' for type"))?;
Ok(BrowserAction::Type {
selector: selector.into(),
text: text.into(),
})
}
"get_text" => {
let selector = args
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for get_text"))?;
Ok(BrowserAction::GetText {
selector: selector.into(),
})
}
"get_title" => Ok(BrowserAction::GetTitle),
"get_url" => Ok(BrowserAction::GetUrl),
"screenshot" => Ok(BrowserAction::Screenshot {
path: args.get("path").and_then(|v| v.as_str()).map(String::from),
full_page: args
.get("full_page")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
}),
"wait" => Ok(BrowserAction::Wait {
selector: args
.get("selector")
.and_then(|v| v.as_str())
.map(String::from),
ms: args.get("ms").and_then(serde_json::Value::as_u64),
text: args.get("text").and_then(|v| v.as_str()).map(String::from),
}),
"press" => {
let key = args
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'key' for press"))?;
Ok(BrowserAction::Press { key: key.into() })
}
"hover" => {
let selector = args
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for hover"))?;
Ok(BrowserAction::Hover {
selector: selector.into(),
})
}
"scroll" => {
let direction = args
.get("direction")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'direction' for scroll"))?;
Ok(BrowserAction::Scroll {
direction: direction.into(),
pixels: args
.get("pixels")
.and_then(serde_json::Value::as_u64)
.map(|p| u32::try_from(p).unwrap_or(u32::MAX)),
})
}
"is_visible" => {
let selector = args
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for is_visible"))?;
Ok(BrowserAction::IsVisible {
selector: selector.into(),
})
}
"close" => Ok(BrowserAction::Close),
"find" => {
let by = args
.get("by")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'by' for find"))?;
let value = args
.get("value")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'value' for find"))?;
let action = args
.get("find_action")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'find_action' for find"))?;
Ok(BrowserAction::Find {
by: by.into(),
value: value.into(),
action: action.into(),
fill_value: args
.get("fill_value")
.and_then(|v| v.as_str())
.map(String::from),
})
}
other => anyhow::bail!("Unsupported browser action: {other}"),
}
}
pub(crate) fn is_supported_browser_action(action: &str) -> bool {
matches!(
action,
"open"
| "snapshot"
| "click"
| "fill"
| "type"
| "get_text"
| "get_title"
| "get_url"
| "screenshot"
| "wait"
| "press"
| "hover"
| "scroll"
| "is_visible"
| "close"
| "find"
| "mouse_move"
| "mouse_click"
| "mouse_drag"
| "key_type"
| "key_press"
| "screen_capture"
)
}
pub(crate) fn is_computer_use_only_action(action: &str) -> bool {
matches!(
action,
"mouse_move" | "mouse_click" | "mouse_drag" | "key_type" | "key_press" | "screen_capture"
)
}
pub(crate) fn backend_name(backend: ResolvedBackend) -> &'static str {
match backend {
ResolvedBackend::AgentBrowser => "agent_browser",
ResolvedBackend::RustNative => "rust_native",
ResolvedBackend::ComputerUse => "computer_use",
}
}
pub(crate) fn unavailable_action_for_backend_error(
action: &str,
backend: ResolvedBackend,
) -> String {
format!(
"Action '{action}' is unavailable for backend '{}'",
backend_name(backend)
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,663 @@
use super::BrowserAction;
use anyhow::{Context, Result};
use base64::Engine;
use fantoccini::actions::{InputSource, MouseActions, PointerAction};
use fantoccini::key::Key;
use fantoccini::{Client, ClientBuilder, Locator};
use serde_json::{json, Map, Value};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
#[derive(Default)]
pub struct NativeBrowserState {
client: Option<Client>,
}
impl NativeBrowserState {
pub fn is_available(_headless: bool, webdriver_url: &str, _chrome_path: Option<&str>) -> bool {
webdriver_endpoint_reachable(webdriver_url, Duration::from_millis(500))
}
#[allow(clippy::too_many_lines)]
pub async fn execute_action(
&mut self,
action: BrowserAction,
headless: bool,
webdriver_url: &str,
chrome_path: Option<&str>,
) -> Result<Value> {
match action {
BrowserAction::Open { url } => {
self.ensure_session(headless, webdriver_url, chrome_path)
.await?;
let client = self.active_client()?;
client
.goto(&url)
.await
.with_context(|| format!("Failed to open URL: {url}"))?;
let current_url = client
.current_url()
.await
.context("Failed to read current URL after navigation")?;
Ok(json!({
"backend": "rust_native",
"action": "open",
"url": current_url.as_str(),
}))
}
BrowserAction::Snapshot {
interactive_only,
compact,
depth,
} => {
let client = self.active_client()?;
let snapshot = client
.execute(
&snapshot_script(interactive_only, compact, depth.map(i64::from)),
vec![],
)
.await
.context("Failed to evaluate snapshot script")?;
Ok(json!({
"backend": "rust_native",
"action": "snapshot",
"data": snapshot,
}))
}
BrowserAction::Click { selector } => {
let client = self.active_client()?;
find_element(client, &selector).await?.click().await?;
Ok(json!({
"backend": "rust_native",
"action": "click",
"selector": selector,
}))
}
BrowserAction::Fill { selector, value } => {
let client = self.active_client()?;
let element = find_element(client, &selector).await?;
let _ = element.clear().await;
element.send_keys(&value).await?;
Ok(json!({
"backend": "rust_native",
"action": "fill",
"selector": selector,
}))
}
BrowserAction::Type { selector, text } => {
let client = self.active_client()?;
find_element(client, &selector)
.await?
.send_keys(&text)
.await?;
Ok(json!({
"backend": "rust_native",
"action": "type",
"selector": selector,
"typed": text.len(),
}))
}
BrowserAction::GetText { selector } => {
let client = self.active_client()?;
let text = find_element(client, &selector).await?.text().await?;
Ok(json!({
"backend": "rust_native",
"action": "get_text",
"selector": selector,
"text": text,
}))
}
BrowserAction::GetTitle => {
let client = self.active_client()?;
let title = client.title().await.context("Failed to read page title")?;
Ok(json!({
"backend": "rust_native",
"action": "get_title",
"title": title,
}))
}
BrowserAction::GetUrl => {
let client = self.active_client()?;
let url = client
.current_url()
.await
.context("Failed to read current URL")?;
Ok(json!({
"backend": "rust_native",
"action": "get_url",
"url": url.as_str(),
}))
}
BrowserAction::Screenshot { path, full_page } => {
let client = self.active_client()?;
let png = client
.screenshot()
.await
.context("Failed to capture screenshot")?;
let mut payload = json!({
"backend": "rust_native",
"action": "screenshot",
"full_page": full_page,
"bytes": png.len(),
});
if let Some(path_str) = path {
tokio::fs::write(&path_str, &png)
.await
.with_context(|| format!("Failed to write screenshot to {path_str}"))?;
payload["path"] = Value::String(path_str);
} else {
payload["png_base64"] =
Value::String(base64::engine::general_purpose::STANDARD.encode(&png));
}
Ok(payload)
}
BrowserAction::Wait { selector, ms, text } => {
let client = self.active_client()?;
if let Some(sel) = selector.as_ref() {
wait_for_selector(client, sel).await?;
Ok(json!({
"backend": "rust_native",
"action": "wait",
"selector": sel,
}))
} else if let Some(duration_ms) = ms {
tokio::time::sleep(Duration::from_millis(duration_ms)).await;
Ok(json!({
"backend": "rust_native",
"action": "wait",
"ms": duration_ms,
}))
} else if let Some(needle) = text.as_ref() {
let xpath = xpath_contains_text(needle);
client
.wait()
.for_element(Locator::XPath(&xpath))
.await
.with_context(|| {
format!("Timed out waiting for text to appear: {needle}")
})?;
Ok(json!({
"backend": "rust_native",
"action": "wait",
"text": needle,
}))
} else {
tokio::time::sleep(Duration::from_millis(250)).await;
Ok(json!({
"backend": "rust_native",
"action": "wait",
"ms": 250,
}))
}
}
BrowserAction::Press { key } => {
let client = self.active_client()?;
let key_input = webdriver_key(&key);
match client.active_element().await {
Ok(element) => {
element.send_keys(&key_input).await?;
}
Err(_) => {
find_element(client, "body")
.await?
.send_keys(&key_input)
.await?;
}
}
Ok(json!({
"backend": "rust_native",
"action": "press",
"key": key,
}))
}
BrowserAction::Hover { selector } => {
let client = self.active_client()?;
let element = find_element(client, &selector).await?;
hover_element(client, &element).await?;
Ok(json!({
"backend": "rust_native",
"action": "hover",
"selector": selector,
}))
}
BrowserAction::Scroll { direction, pixels } => {
let client = self.active_client()?;
let amount = i64::from(pixels.unwrap_or(600));
let (dx, dy) = match direction.as_str() {
"up" => (0, -amount),
"down" => (0, amount),
"left" => (-amount, 0),
"right" => (amount, 0),
_ => anyhow::bail!(
"Unsupported scroll direction '{direction}'. Use up/down/left/right"
),
};
let position = client
.execute(
"window.scrollBy(arguments[0], arguments[1]); return { x: window.scrollX, y: window.scrollY };",
vec![json!(dx), json!(dy)],
)
.await
.context("Failed to execute scroll script")?;
Ok(json!({
"backend": "rust_native",
"action": "scroll",
"position": position,
}))
}
BrowserAction::IsVisible { selector } => {
let client = self.active_client()?;
let visible = find_element(client, &selector)
.await?
.is_displayed()
.await?;
Ok(json!({
"backend": "rust_native",
"action": "is_visible",
"selector": selector,
"visible": visible,
}))
}
BrowserAction::Close => {
if let Some(client) = self.client.take() {
let _ = client.close().await;
}
Ok(json!({
"backend": "rust_native",
"action": "close",
"closed": true,
}))
}
BrowserAction::Find {
by,
value,
action,
fill_value,
} => {
let client = self.active_client()?;
let selector = selector_for_find(&by, &value);
let element = find_element(client, &selector).await?;
let payload = match action.as_str() {
"click" => {
element.click().await?;
json!({"result": "clicked"})
}
"fill" => {
let fill = fill_value.ok_or_else(|| {
anyhow::anyhow!("find_action='fill' requires fill_value")
})?;
let _ = element.clear().await;
element.send_keys(&fill).await?;
json!({"result": "filled", "typed": fill.len()})
}
"text" => {
let text = element.text().await?;
json!({"result": "text", "text": text})
}
"hover" => {
hover_element(client, &element).await?;
json!({"result": "hovered"})
}
"check" => {
let checked_before = element_checked(&element).await?;
if !checked_before {
element.click().await?;
}
let checked_after = element_checked(&element).await?;
json!({
"result": "checked",
"checked_before": checked_before,
"checked_after": checked_after,
})
}
_ => anyhow::bail!(
"Unsupported find_action '{action}'. Use click/fill/text/hover/check"
),
};
Ok(json!({
"backend": "rust_native",
"action": "find",
"by": by,
"value": value,
"selector": selector,
"data": payload,
}))
}
}
}
async fn ensure_session(
&mut self,
headless: bool,
webdriver_url: &str,
chrome_path: Option<&str>,
) -> Result<()> {
if self.client.is_some() {
return Ok(());
}
let mut capabilities: Map<String, Value> = Map::new();
let mut chrome_options: Map<String, Value> = Map::new();
let mut args: Vec<Value> = Vec::new();
if headless {
args.push(Value::String("--headless=new".to_string()));
args.push(Value::String("--disable-gpu".to_string()));
}
if !args.is_empty() {
chrome_options.insert("args".to_string(), Value::Array(args));
}
if let Some(path) = chrome_path {
let trimmed = path.trim();
if !trimmed.is_empty() {
chrome_options.insert("binary".to_string(), Value::String(trimmed.to_string()));
}
}
if !chrome_options.is_empty() {
capabilities.insert(
"goog:chromeOptions".to_string(),
Value::Object(chrome_options),
);
}
let mut builder =
ClientBuilder::rustls().context("Failed to initialize rustls connector")?;
if !capabilities.is_empty() {
builder.capabilities(capabilities);
}
let client = builder
.connect(webdriver_url)
.await
.with_context(|| {
format!(
"Failed to connect to WebDriver at {webdriver_url}. Start chromedriver/geckodriver first"
)
})?;
self.client = Some(client);
Ok(())
}
fn active_client(&self) -> Result<&Client> {
self.client.as_ref().ok_or_else(|| {
anyhow::anyhow!("No active native browser session. Run browser action='open' first")
})
}
}
fn webdriver_endpoint_reachable(webdriver_url: &str, timeout: Duration) -> bool {
let parsed = match reqwest::Url::parse(webdriver_url) {
Ok(url) => url,
Err(_) => return false,
};
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return false;
}
let host = match parsed.host_str() {
Some(h) if !h.is_empty() => h,
_ => return false,
};
let port = parsed.port_or_known_default().unwrap_or(4444);
let mut addrs = match (host, port).to_socket_addrs() {
Ok(iter) => iter,
Err(_) => return false,
};
let addr = match addrs.next() {
Some(a) => a,
None => return false,
};
TcpStream::connect_timeout(&addr, timeout).is_ok()
}
fn selector_for_find(by: &str, value: &str) -> String {
let escaped = css_attr_escape(value);
match by {
"role" => format!(r#"[role=\"{escaped}\"]"#),
"label" => format!("label={value}"),
"placeholder" => format!(r#"[placeholder=\"{escaped}\"]"#),
"testid" => format!(r#"[data-testid=\"{escaped}\"]"#),
_ => format!("text={value}"),
}
}
async fn wait_for_selector(client: &Client, selector: &str) -> Result<()> {
match parse_selector(selector) {
SelectorKind::Css(css) => {
client
.wait()
.for_element(Locator::Css(&css))
.await
.with_context(|| format!("Timed out waiting for selector '{selector}'"))?;
}
SelectorKind::XPath(xpath) => {
client
.wait()
.for_element(Locator::XPath(&xpath))
.await
.with_context(|| format!("Timed out waiting for selector '{selector}'"))?;
}
}
Ok(())
}
async fn find_element(client: &Client, selector: &str) -> Result<fantoccini::elements::Element> {
let element = match parse_selector(selector) {
SelectorKind::Css(css) => client
.find(Locator::Css(&css))
.await
.with_context(|| format!("Failed to find element by CSS '{css}'"))?,
SelectorKind::XPath(xpath) => client
.find(Locator::XPath(&xpath))
.await
.with_context(|| format!("Failed to find element by XPath '{xpath}'"))?,
};
Ok(element)
}
async fn hover_element(client: &Client, element: &fantoccini::elements::Element) -> Result<()> {
let actions = MouseActions::new("mouse".to_string()).then(PointerAction::MoveToElement {
element: element.clone(),
duration: Some(Duration::from_millis(150)),
x: 0.0,
y: 0.0,
});
client
.perform_actions(actions)
.await
.context("Failed to perform hover action")?;
let _ = client.release_actions().await;
Ok(())
}
async fn element_checked(element: &fantoccini::elements::Element) -> Result<bool> {
let checked = element
.prop("checked")
.await
.context("Failed to read checkbox checked property")?
.unwrap_or_default()
.to_ascii_lowercase();
Ok(matches!(checked.as_str(), "true" | "checked" | "1"))
}
enum SelectorKind {
Css(String),
XPath(String),
}
fn parse_selector(selector: &str) -> SelectorKind {
let trimmed = selector.trim();
if let Some(text_query) = trimmed.strip_prefix("text=") {
return SelectorKind::XPath(xpath_contains_text(text_query));
}
if let Some(label_query) = trimmed.strip_prefix("label=") {
let literal = xpath_literal(label_query);
return SelectorKind::XPath(format!(
"(//label[contains(normalize-space(.), {literal})]/following::*[self::input or self::textarea or self::select][1] | //*[@aria-label and contains(normalize-space(@aria-label), {literal})] | //label[contains(normalize-space(.), {literal})])"
));
}
if trimmed.starts_with('@') {
let escaped = css_attr_escape(trimmed);
return SelectorKind::Css(format!(r#"[data-zc-ref=\"{escaped}\"]"#));
}
SelectorKind::Css(trimmed.to_string())
}
fn css_attr_escape(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', " ")
}
fn xpath_contains_text(text: &str) -> String {
format!("//*[contains(normalize-space(.), {})]", xpath_literal(text))
}
fn xpath_literal(input: &str) -> String {
if !input.contains('"') {
return format!("\"{input}\"");
}
if !input.contains('\'') {
return format!("'{input}'");
}
let segments: Vec<&str> = input.split('"').collect();
let mut parts: Vec<String> = Vec::new();
for (index, part) in segments.iter().enumerate() {
if !part.is_empty() {
parts.push(format!("\"{part}\""));
}
if index + 1 < segments.len() {
parts.push("'\"'".to_string());
}
}
if parts.is_empty() {
"\"\"".to_string()
} else {
format!("concat({})", parts.join(","))
}
}
fn webdriver_key(key: &str) -> String {
match key.trim().to_ascii_lowercase().as_str() {
"enter" => Key::Enter.to_string(),
"return" => Key::Return.to_string(),
"tab" => Key::Tab.to_string(),
"escape" | "esc" => Key::Escape.to_string(),
"backspace" => Key::Backspace.to_string(),
"delete" => Key::Delete.to_string(),
"space" => Key::Space.to_string(),
"arrowup" | "up" => Key::Up.to_string(),
"arrowdown" | "down" => Key::Down.to_string(),
"arrowleft" | "left" => Key::Left.to_string(),
"arrowright" | "right" => Key::Right.to_string(),
"home" => Key::Home.to_string(),
"end" => Key::End.to_string(),
"pageup" => Key::PageUp.to_string(),
"pagedown" => Key::PageDown.to_string(),
other => other.to_string(),
}
}
fn snapshot_script(interactive_only: bool, compact: bool, depth: Option<i64>) -> String {
let depth_literal = depth
.map(|level| level.to_string())
.unwrap_or_else(|| "null".to_string());
format!(
r#"(() => {{
const interactiveOnly = {interactive_only};
const compact = {compact};
const maxDepth = {depth_literal};
const nodes = [];
const root = document.body || document.documentElement;
let counter = 0;
const isVisible = (el) => {{
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || 1) === 0) {{
return false;
}}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}};
const isInteractive = (el) => {{
if (el.matches('a,button,input,select,textarea,summary,[role],*[tabindex]')) return true;
return typeof el.onclick === 'function';
}};
const describe = (el, depth) => {{
const interactive = isInteractive(el);
const text = (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 140);
if (interactiveOnly && !interactive) return;
if (compact && !interactive && !text) return;
const ref = '@e' + (++counter);
el.setAttribute('data-zc-ref', ref);
nodes.push({{
ref,
depth,
tag: el.tagName.toLowerCase(),
id: el.id || null,
role: el.getAttribute('role'),
text,
interactive,
}});
}};
const walk = (el, depth) => {{
if (!(el instanceof Element)) return;
if (maxDepth !== null && depth > maxDepth) return;
if (isVisible(el)) {{
describe(el, depth);
}}
for (const child of el.children) {{
walk(child, depth + 1);
if (nodes.length >= 400) return;
}}
}};
if (root) walk(root, 0);
return {{
title: document.title,
url: window.location.href,
count: nodes.length,
nodes,
}};
}})();"#
)
}
@@ -0,0 +1,150 @@
use std::net::ToSocketAddrs;
use std::time::Duration;
pub(crate) fn normalize_domains(domains: Vec<String>) -> Vec<String> {
domains
.into_iter()
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect()
}
pub(crate) fn endpoint_reachable(endpoint: &reqwest::Url, timeout: Duration) -> bool {
let host = match endpoint.host_str() {
Some(host) if !host.is_empty() => host,
_ => return false,
};
let port = match endpoint.port_or_known_default() {
Some(port) => port,
None => return false,
};
let mut addrs = match (host, port).to_socket_addrs() {
Ok(addrs) => addrs,
Err(_) => return false,
};
let addr = match addrs.next() {
Some(addr) => addr,
None => return false,
};
std::net::TcpStream::connect_timeout(&addr, timeout).is_ok()
}
pub(crate) fn extract_host(url_str: &str) -> anyhow::Result<String> {
// Simple host extraction without url crate
let url = url_str.trim();
let without_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.or_else(|| url.strip_prefix("file://"))
.unwrap_or(url);
// Extract host — handle bracketed IPv6 addresses like [::1]:8080
let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
let host = if authority.starts_with('[') {
// IPv6: take everything up to and including the closing ']'
authority.find(']').map_or(authority, |i| &authority[..=i])
} else {
// IPv4 or hostname: take everything before the port separator
authority.split(':').next().unwrap_or(authority)
};
if host.is_empty() {
anyhow::bail!("Invalid URL: no host");
}
Ok(host.to_lowercase())
}
pub(crate) fn is_private_host(host: &str) -> bool {
// Strip brackets from IPv6 addresses like [::1]
let bare = host
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host);
if bare == "localhost" || bare.ends_with(".localhost") {
return true;
}
// .local TLD (mDNS)
if bare
.rsplit('.')
.next()
.is_some_and(|label| label == "local")
{
return true;
}
// Parse as IP address to catch all representations (decimal, hex, octal, mapped)
if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => is_non_global_v4(v4),
std::net::IpAddr::V6(v6) => is_non_global_v6(v6),
};
}
false
}
/// Returns `true` for any IPv4 address that is not globally routable.
pub(crate) fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool {
let [a, b, _, _] = v4.octets();
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_multicast()
// Shared address space (100.64/10)
|| (a == 100 && (64..=127).contains(&b))
// Reserved (240.0.0.0/4)
|| a >= 240
// Documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24)
|| (a == 192 && b == 0)
|| (a == 198 && b == 51)
|| (a == 203 && b == 0)
// Benchmarking (198.18.0.0/15)
|| (a == 198 && (18..=19).contains(&b))
}
/// Returns `true` for any IPv6 address that is not globally routable.
pub(crate) fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool {
let segs = v6.segments();
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
// Unique-local (fc00::/7) — IPv6 equivalent of RFC 1918
|| (segs[0] & 0xfe00) == 0xfc00
// Link-local (fe80::/10)
|| (segs[0] & 0xffc0) == 0xfe80
// IPv4-mapped addresses
|| v6.to_ipv4_mapped().is_some_and(is_non_global_v4)
}
pub(crate) fn allow_all_browser_domains() -> bool {
matches!(
std::env::var("OPENHUMAN_BROWSER_ALLOW_ALL").ok().as_deref(),
Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES")
)
}
pub(crate) fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool {
allowed.iter().any(|pattern| {
if pattern == "*" {
return true;
}
if pattern.starts_with("*.") {
// Wildcard subdomain match
let suffix = &pattern[1..]; // ".example.com"
host.ends_with(suffix) || host == &pattern[2..]
} else {
// Exact match or subdomain
host == pattern || host.ends_with(&format!(".{pattern}"))
}
})
}
+167
View File
@@ -0,0 +1,167 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Computer-use sidecar settings.
#[derive(Clone)]
pub struct ComputerUseConfig {
pub endpoint: String,
pub api_key: Option<String>,
pub timeout_ms: u64,
pub allow_remote_endpoint: bool,
pub window_allowlist: Vec<String>,
pub max_coordinate_x: Option<i64>,
pub max_coordinate_y: Option<i64>,
}
impl std::fmt::Debug for ComputerUseConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ComputerUseConfig")
.field("endpoint", &self.endpoint)
.field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
.field("timeout_ms", &self.timeout_ms)
.field("allow_remote_endpoint", &self.allow_remote_endpoint)
.field("window_allowlist", &self.window_allowlist)
.field("max_coordinate_x", &self.max_coordinate_x)
.field("max_coordinate_y", &self.max_coordinate_y)
.finish()
}
}
impl Default for ComputerUseConfig {
fn default() -> Self {
Self {
endpoint: "http://127.0.0.1:8787/v1/actions".into(),
api_key: None,
timeout_ms: 15_000,
allow_remote_endpoint: false,
window_allowlist: Vec::new(),
max_coordinate_x: None,
max_coordinate_y: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BrowserBackendKind {
AgentBrowser,
RustNative,
ComputerUse,
Auto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ResolvedBackend {
AgentBrowser,
RustNative,
ComputerUse,
}
impl BrowserBackendKind {
pub(crate) fn parse(raw: &str) -> anyhow::Result<Self> {
let key = raw.trim().to_ascii_lowercase().replace('-', "_");
match key.as_str() {
"agent_browser" | "agentbrowser" => Ok(Self::AgentBrowser),
"rust_native" | "native" => Ok(Self::RustNative),
"computer_use" | "computeruse" => Ok(Self::ComputerUse),
"auto" => Ok(Self::Auto),
_ => anyhow::bail!(
"Unsupported browser backend '{raw}'. Use 'agent_browser', 'rust_native', 'computer_use', or 'auto'"
),
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::AgentBrowser => "agent_browser",
Self::RustNative => "rust_native",
Self::ComputerUse => "computer_use",
Self::Auto => "auto",
}
}
}
/// Response from agent-browser --json commands
#[derive(Debug, Deserialize)]
pub(crate) struct AgentBrowserResponse {
pub success: bool,
pub data: Option<Value>,
pub error: Option<String>,
}
/// Response format from computer-use sidecar.
#[derive(Debug, Deserialize)]
pub(crate) struct ComputerUseResponse {
#[serde(default)]
pub success: Option<bool>,
#[serde(default)]
pub data: Option<Value>,
#[serde(default)]
pub error: Option<String>,
}
/// Supported browser actions
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BrowserAction {
/// Navigate to a URL
Open { url: String },
/// Get accessibility snapshot with refs
Snapshot {
#[serde(default)]
interactive_only: bool,
#[serde(default)]
compact: bool,
#[serde(default)]
depth: Option<u32>,
},
/// Click an element by ref or selector
Click { selector: String },
/// Fill a form field
Fill { selector: String, value: String },
/// Type text into focused element
Type { selector: String, text: String },
/// Get text content of element
GetText { selector: String },
/// Get page title
GetTitle,
/// Get current URL
GetUrl,
/// Take screenshot
Screenshot {
#[serde(default)]
path: Option<String>,
#[serde(default)]
full_page: bool,
},
/// Wait for element or time
Wait {
#[serde(default)]
selector: Option<String>,
#[serde(default)]
ms: Option<u64>,
#[serde(default)]
text: Option<String>,
},
/// Press a key
Press { key: String },
/// Hover over element
Hover { selector: String },
/// Scroll page
Scroll {
direction: String,
#[serde(default)]
pixels: Option<u32>,
},
/// Check if element is visible
IsVisible { selector: String },
/// Close browser
Close,
/// Find element by semantic locator
Find {
by: String, // role, text, label, placeholder, testid
value: String,
action: String, // click, fill, text, hover
#[serde(default)]
fill_value: Option<String>,
},
}