First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "core-api"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["sync", "macros"] }
tokio-util = { version = "0.7" }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
async-trait = "0.1"
anyhow = "1"
axum = { version = "0.8", default-features = false }
# SqlitePool exposed to plugins via PluginContext (plugin.md §12.1). Version
# pinned to match the rest of the workspace.
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
+22
View File
@@ -0,0 +1,22 @@
use async_trait::async_trait;
/// Minimal approval API exposed to plugins.
///
/// Plugins receive `Arc<dyn ApprovalApi>` via `PluginContext` and use it to
/// resolve pending tool-call approvals without depending on the main crate's
/// `ApprovalManager` directly.
#[async_trait]
pub trait ApprovalApi: Send + Sync {
/// Approve a pending tool-call request.
async fn approve(&self, request_id: i64);
/// Reject a pending tool-call request with an optional note.
async fn reject(&self, request_id: i64, note: String);
/// Approve + register a session bypass so future tool calls of the same
/// category/MCP-server are skipped automatically.
///
/// - `bypass_secs = Some(n)`: bypass lasts `n` seconds (e.g. 900 = 15 min)
/// - `bypass_secs = None`: bypass lasts until the session ends
async fn approve_with_bypass(&self, request_id: i64, bypass_secs: Option<u64>);
}
+160
View File
@@ -0,0 +1,160 @@
//! In-process event bus for chat turns and system events.
//!
//! Every completed chat turn (user message + assistant response) is published
//! here **after** both messages have been persisted to the DB successfully.
//! Context compaction events are published when the compactor completes a
//! summarisation cycle.
//!
//! Subscribers receive a clone of each [`BusEvent`] via a
//! `tokio::sync::broadcast` channel.
//!
//! # Current consumers
//! - `honcho` plugin: processes `UserMessage` and `AssistantResponse` variants.
//!
//! # Usage
//! ```rust,ignore
//! // Producer (ChatSessionHandler):
//! bus.user_message(ChatEvent { ... });
//! bus.assistant_response(ChatEvent { ... });
//!
//! // Producer (ContextCompactor):
//! bus.compaction_done(CompactionEvent { ... });
//!
//! // Consumer (spawn once, keep running):
//! let mut rx = bus.subscribe();
//! tokio::spawn(async move {
//! loop {
//! match rx.recv().await {
//! Ok(BusEvent::UserMessage(e)) => { /* ... */ }
//! Ok(BusEvent::AssistantResponse(e)) => { /* ... */ }
//! Ok(BusEvent::CompactionDone(e)) => { /* ... */ }
//! Err(RecvError::Lagged(n)) => warn!("lagged by {n}"),
//! Err(RecvError::Closed) => break,
//! }
//! }
//! });
//! ```
use chrono::{DateTime, Utc};
use tokio::sync::broadcast;
pub use tokio::sync::broadcast::error::RecvError;
/// Default channel capacity. At 256 events the bus can absorb a burst of 128
/// turns before a slow consumer starts lagging.
const DEFAULT_CAPACITY: usize = 256;
// ── Sub-event types ───────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum ChatEventRole {
User,
Assistant,
/// Sub-agent invocation (role = "agent" in DB).
Agent,
}
/// Per-tool-call detail attached to an assistant `ChatEvent`.
#[derive(Debug, Clone)]
pub struct ToolCallEvent {
pub name: String,
pub arguments: Option<String>,
pub result: Option<String>,
/// "done" | "failed"
pub status: String,
}
/// A single message in a completed chat turn.
#[derive(Debug, Clone)]
pub struct ChatEvent {
pub session_id: i64,
pub stack_id: i64,
/// `chat_history.id` for this message.
pub message_id: i64,
pub role: ChatEventRole,
pub content: String,
/// True for system-generated messages that look like user turns
/// (TicManager ticks, notification briefings).
pub is_synthetic: bool,
/// True when a real user is actively participating in the session
/// (web, telegram). False for automated sessions (cron, tic).
pub is_interactive: bool,
/// True for short-lived task sessions (cron, tic) that have no
/// long-term conversational value (e.g. skip Honcho memory sink).
pub is_ephemeral: bool,
/// Non-empty only for assistant messages that triggered tool calls.
pub tool_calls: Vec<ToolCallEvent>,
pub created_at: DateTime<Utc>,
}
/// Emitted after the compactor successfully persists a new summary.
#[derive(Debug, Clone)]
pub struct CompactionEvent {
pub session_id: i64,
pub stack_id: i64,
/// `chat_summaries.id` of the newly created summary row.
pub summary_id: i64,
/// All `chat_history` rows with `id <= covers_up_to_message_id` are now
/// covered by the summary and will no longer be sent to the LLM raw.
pub covers_up_to_message_id: i64,
/// Input token count of the turn that triggered this compaction.
pub triggered_by_tokens: u32,
}
// ── Top-level bus event ───────────────────────────────────────────────────────
/// All events that flow through the [`ChatEventBus`].
#[derive(Debug, Clone)]
pub enum BusEvent {
/// A user (or synthetic) message was saved after a completed turn.
UserMessage(ChatEvent),
/// The assistant's final response was saved after a completed turn.
AssistantResponse(ChatEvent),
/// The context compactor created a new summary for a stack.
CompactionDone(CompactionEvent),
}
// ── Bus ───────────────────────────────────────────────────────────────────────
pub struct ChatEventBus {
tx: broadcast::Sender<BusEvent>,
}
impl ChatEventBus {
pub fn new() -> Self {
Self::with_capacity(DEFAULT_CAPACITY)
}
pub fn with_capacity(capacity: usize) -> Self {
let (tx, _) = broadcast::channel(capacity);
Self { tx }
}
/// Publish a user message event. No-op if there are no active subscribers.
pub fn user_message(&self, event: ChatEvent) {
let _ = self.tx.send(BusEvent::UserMessage(event));
}
/// Publish an assistant response event. No-op if there are no active subscribers.
pub fn assistant_response(&self, event: ChatEvent) {
let _ = self.tx.send(BusEvent::AssistantResponse(event));
}
/// Publish a compaction-done event. No-op if there are no active subscribers.
pub fn compaction_done(&self, event: CompactionEvent) {
let _ = self.tx.send(BusEvent::CompactionDone(event));
}
/// Returns a new receiver. Each subscriber gets every future event
/// independently. If the subscriber falls behind by more than the channel
/// capacity it will receive `RecvError::Lagged(n)` — handle gracefully.
pub fn subscribe(&self) -> broadcast::Receiver<BusEvent> {
self.tx.subscribe()
}
}
impl Default for ChatEventBus {
fn default() -> Self {
Self::new()
}
}
+202
View File
@@ -0,0 +1,202 @@
use std::collections::HashMap;
use async_trait::async_trait;
use tokio::sync::broadcast;
use crate::events::GlobalEvent;
use crate::interface_tool::InterfaceTool;
use crate::message_meta::MessageMetadata;
// ── SendMessageOptions ────────────────────────────────────────────────────────
/// Optional parameters for a [`ChatHubApi::send_message`] call.
#[derive(Default)]
pub struct SendMessageOptions {
/// Agent to use for this source's session. Defaults to `"main"` if not set.
/// Only takes effect when a new session is created — ignored for existing sessions.
pub agent_id: Option<String>,
/// Named substitutions applied to the agent's system prompt.
/// Each entry replaces the sentinel `__KEY__` in the loaded prompt text.
/// Matches `<!-- KEY -->` placeholders that `agents::resolve_includes` converts to sentinels.
pub system_substitutions: HashMap<String, String>,
pub client_name: Option<String>,
/// Extra text prepended to the agent's system prompt for this turn only.
/// STATIC: safe to cache — use for interface-specific formatting rules that
/// never change turn-to-turn (e.g. Telegram HTML mode).
pub extra_system_context: Option<String>,
/// Extra system message injected AFTER the conversation history for this turn only.
/// DYNAMIC: not cached — use for per-turn context (e.g. notification framing).
pub extra_system_dynamic: Option<String>,
/// Short reminder injected near the tail of the message list to prevent drift.
pub tail_reminder: Option<String>,
pub interface_tools: Vec<InterfaceTool>,
/// True for system-generated messages injected as user turns (notification briefings).
pub is_synthetic: bool,
/// Opaque structured metadata persisted on the user turn (e.g. file attachments).
/// ChatHub forwards it verbatim; the MessageBuilder/UI derive their own views.
pub metadata: Option<MessageMetadata>,
}
// ── ChatHubApi ────────────────────────────────────────────────────────────────
/// Abstraction over [`ChatHub`](crate) that plugins and external crates depend on.
///
/// Implementing this trait for `ChatHub` in the main crate is the only coupling
/// point needed: plugins can accept `Arc<dyn ChatHubApi>` and stay independent.
#[async_trait]
pub trait ChatHubApi: Send + Sync {
/// Register a source. No-op for duplicate registrations.
async fn register(&self, source_id: &str);
/// Send a user message for a source, running a full LLM turn.
/// Creates a session lazily if none exists yet.
async fn send_message(
&self,
source_id: &str,
prompt: &str,
opts: SendMessageOptions,
) -> anyhow::Result<()>;
/// Create a new session for the source, discarding the previous one.
async fn clear(&self, source_id: &str) -> anyhow::Result<i64>;
/// Subscribe to the global event bus.
/// Filtering by source is the caller's responsibility.
fn events(&self, source_id: &str) -> broadcast::Receiver<GlobalEvent>;
/// Set which source is the "home" for background agent notifications.
async fn set_home(&self, source_id: &str) -> anyhow::Result<()>;
/// Returns token usage `(input, output)` for the last message in the source's session.
async fn context_info(
&self,
source_id: &str,
) -> anyhow::Result<(Option<i64>, Option<i64>)>;
/// Total spend (USD) of the source's active session, including synchronous
/// sub-agent frames and excluding asynchronous tasks (which run in their own
/// session). `None` when no provider reported a cost.
async fn cost_info(&self, source_id: &str) -> anyhow::Result<Option<f64>>;
/// Force compaction of the source's active session history.
/// Returns `true` if compaction occurred.
async fn force_compact(&self, source_id: &str) -> anyhow::Result<bool>;
/// Resume any interrupted turn for a source's active session.
async fn resume(&self, source_id: &str) -> anyhow::Result<()>;
/// Approve a pending tool-call approval request.
async fn approve(&self, request_id: i64);
/// Reject a pending tool-call approval request.
async fn reject(&self, request_id: i64, note: String);
/// Resolve a pending `ask_user_clarification` question.
/// Collapses `session_handler(source_id).resolve_question(...)` into a single
/// hub-level call so callers never need to know about `ChatSessionHandler`.
async fn resolve_question(&self, source_id: &str, request_id: i64, answer: String);
/// Cancel the active LLM turn for a source, clearing any pending approvals
/// and clarification questions. No-op if no session is active.
async fn cancel(&self, source_id: &str);
/// Revoke all session-scoped MCP grants for a source's active session.
/// The next LLM turn will start with no MCP tools activated.
async fn reset_mcp(&self, source_id: &str) -> anyhow::Result<()>;
/// Returns `(models, default)` where `models` is the ordered list of usable
/// LLM client names (first entry is always `"auto"`) and `default` is the
/// configured default client name. Used by `/models` and the web selector.
async fn list_clients(&self) -> (Vec<String>, String);
/// Returns the client name pinned for the source, or `None` when unset
/// (the caller should fall back to AUTO resolution).
async fn get_selected_client(&self, source_id: &str) -> Option<String>;
/// Pin a client name for the source and broadcast `ClientSelected` to every
/// client of the source. `client` must be a `list_clients()` entry (e.g.
/// `"auto"` or a model name); the caller is responsible for validation.
async fn set_selected_client(&self, source_id: &str, client: String);
/// Clear any pinned client for the source (revert to AUTO) and broadcast
/// `ClientSelected { client: "auto" }`.
async fn clear_selected_client(&self, source_id: &str);
/// Snapshot of the model list with the per-source current selection marked.
/// Returns `(index, name, is_current)` tuples — call sites format them as
/// HTML or Markdown without re-querying the LLM manager.
async fn list_clients_marked(
&self,
source_id: &str,
) -> Vec<(usize, String, bool)>;
/// Apply a `/model {arg}` command: resolve the argument, mutate the
/// per-source pinned client (broadcasting `ClientSelected`), return a
/// structured outcome the caller can format for its medium (HTML/Markdown).
async fn apply_model_command(
&self,
source_id: &str,
arg: &str,
) -> ModelCommandOutcome;
}
// ── Model command helpers (shared business logic) ────────────────────────────
/// Outcome of [`ChatHubApi::apply_model_command`]. The caller formats each
/// variant for its medium (Telegram HTML, web Markdown, …).
#[derive(Debug, Clone)]
pub enum ModelCommandOutcome {
/// A model was pinned. The backend has already broadcast `ClientSelected`.
Set(String),
/// The pin was cleared (back to AUTO). The backend has already broadcast
/// `ClientSelected { client: "auto" }`.
Cleared,
/// The argument was empty, out of range, or ambiguous. Carries a
/// user-facing message (no formatting — the caller wraps it as needed).
Error(String),
}
/// Resolve a `/model` (or analogous — e.g. a future `/reasoning`) argument
/// against an ordered list.
///
/// Returns:
/// - `Ok(Some(client))` for a unique match (caller pins it)
/// - `Ok(None)` for `auto` / index 0 (caller clears the pin)
/// - `Err(user_facing_message)` when the input is empty / out of range /
/// ambiguous
///
/// Accepts (in order):
/// 1. `auto` (case-insensitive) — or numeric `0` which is conventionally the
/// "auto" slot in `client_names()`
/// 2. Numeric index `N` → exact lookup
/// 3. Exact case-insensitive name match
/// 4. Substring match (case-insensitive) — must be unique
pub fn resolve_list_arg(models: &[String], arg: &str) -> Result<Option<String>, String> {
let arg = arg.trim();
if arg.is_empty() {
return Err("Usage: /model N or /model name or /model auto".to_string());
}
if arg.eq_ignore_ascii_case("auto") {
return Ok(None);
}
if let Ok(n) = arg.parse::<usize>() {
return match models.get(n) {
Some(m) if m == "auto" => Ok(None),
Some(m) => Ok(Some(m.clone())),
None => Err(format!("Index {n} out of range. Use /models to see the list.")),
};
}
if let Some(m) = models.iter().find(|m| m.eq_ignore_ascii_case(arg)) {
return Ok(if m == "auto" { None } else { Some(m.clone()) });
}
let lower = arg.to_ascii_lowercase();
let hits: Vec<&String> = models.iter().filter(|m| m.to_ascii_lowercase().contains(&lower)).collect();
match hits.len() {
1 => Ok(if hits[0] == "auto" { None } else { Some(hits[0].clone()) }),
0 => Err(format!("No model matches '{arg}'. Use /models to see the list.")),
_ => Err(format!(
"Multiple models match '{arg}': {}. Be more specific.",
hits.iter().map(|h| h.as_str()).collect::<Vec<_>>().join(", ")
)),
}
}
+156
View File
@@ -0,0 +1,156 @@
use async_trait::async_trait;
use serde_json::Value;
/// A single message in a conversation.
#[derive(Debug, Clone)]
pub struct Message {
pub role: Role,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Role {
System,
User,
Assistant,
}
impl Message {
pub fn system(content: impl Into<String>) -> Self {
Self { role: Role::System, content: content.into() }
}
pub fn user(content: impl Into<String>) -> Self {
Self { role: Role::User, content: content.into() }
}
pub fn assistant(content: impl Into<String>) -> Self {
Self { role: Role::Assistant, content: content.into() }
}
}
/// Options for a single chat completion request.
#[derive(Debug, Clone)]
pub struct ChatOptions {
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
/// Session/stack IDs for request logging. Set by the LLM loop; ignored by
/// providers — only the logging wrapper reads them.
pub session_id: Option<i64>,
pub stack_id: Option<i64>,
}
/// Raw HTTP metadata captured during a provider call.
/// Sensitive header values (api_key) are redacted before storage.
#[derive(Debug, Default)]
pub struct LlmRawMeta {
pub request_headers: Option<Value>,
pub request_body: Option<Value>,
pub response_headers: Option<Value>,
pub response_body: Option<Value>,
}
/// The response from a chat completion (text only).
#[derive(Debug, Clone)]
pub struct ChatResponse {
pub content: String,
pub input_tokens: Option<u32>,
pub output_tokens: Option<u32>,
/// True when the model stopped due to hitting the token limit.
pub truncated: bool,
/// Chain-of-thought produced by reasoning models (e.g. DeepSeek thinking mode).
/// Must be echoed back in the assistant message on subsequent turns.
pub reasoning_content: Option<String>,
/// Tokens served from the provider's prompt cache (Anthropic: cache_read_input_tokens,
/// OpenAI: prompt_tokens_details.cached_tokens). None when the provider does not
/// report cache metrics.
pub cache_read_tokens: Option<u32>,
/// Tokens written into the provider's prompt cache (Anthropic only:
/// cache_creation_input_tokens). None for providers that do not expose this.
pub cache_creation_tokens: Option<u32>,
/// Cost of the request in USD, when the provider reports it (OpenRouter
/// returns it under `usage.cost`). None for providers that do not bill
/// per-request or do not expose the figure.
pub cost: Option<f64>,
}
/// A single tool call requested by the LLM.
#[derive(Debug, Clone)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
/// Result of one LLM turn when tools are available.
#[derive(Debug)]
pub enum LlmTurn {
Message(ChatResponse),
ToolCalls {
content: String,
calls: Vec<ToolCall>,
input_tokens: Option<u32>,
output_tokens: Option<u32>,
reasoning_content: Option<String>,
cache_read_tokens: Option<u32>,
cache_creation_tokens: Option<u32>,
cost: Option<f64>,
},
}
/// Stateless LLM client. Implementations hold only connection config (base URL,
/// API key). No memory, no database, no session state.
#[async_trait]
pub trait ChatbotClient: Send + Sync {
async fn chat(
&self,
messages: &[Message],
options: &ChatOptions,
) -> anyhow::Result<ChatResponse>;
/// Extracts the request cost in USD from a provider's raw JSON response,
/// when the provider reports it. OpenRouter (and other OpenAI-compatible
/// gateways) return it under `usage.cost`; the default reads that path and
/// yields None when absent. Providers with a different shape override this.
fn extract_cost(&self, response: &Value) -> Option<f64> {
response["usage"]["cost"].as_f64()
}
/// Chat with tool support. Default implementation ignores tools and falls
/// back to `chat()`.
async fn chat_with_tools(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<LlmTurn> {
let simple: Vec<Message> = messages
.iter()
.filter_map(|m| {
let role = m["role"].as_str()?;
let content = m["content"].as_str().unwrap_or("").to_string();
match role {
"system" => Some(Message::system(content)),
"user" => Some(Message::user(content)),
"assistant" => Some(Message::assistant(content)),
_ => None,
}
})
.collect();
let _ = tools;
let resp = self.chat(&simple, options).await?;
Ok(LlmTurn::Message(resp))
}
/// Like `chat_with_tools` but also returns raw HTTP metadata for logging.
/// Providers that make real HTTP calls should override this.
async fn chat_with_tools_raw(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
self.chat_with_tools(messages, tools, options).await.map(|t| (t, None))
}
}
+58
View File
@@ -0,0 +1,58 @@
//! Custom slash-command abstraction shared between the main crate and plugins.
//!
//! `LlmCommandManager` (main crate) implements [`CommandApi`]; plugins (e.g. the
//! Telegram bot) accept `Arc<dyn CommandApi>` and stay decoupled from the manager's
//! concrete type and from the file-system discovery logic. Commands live in
//! `commands/<name>/` and are read at request time (see `src/core/command/mod.rs`).
//!
//! This module is intentionally synchronous: command discovery is trivial file I/O
//! over tiny files with no DB/network dependency, so it does not need the
//! `async_trait` ceremony of the other plugin API traits.
use serde::{Deserialize, Serialize};
/// One enabled custom command — the listing DTO (no template body). Returned by
/// [`CommandApi::list_enabled`] for `/help` and the composer autocomplete.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandInfo {
/// Canonical command name, without the leading `/` (e.g. `review`).
pub name: String,
/// One-line description shown in `/help` and the autocomplete dropdown.
pub description: String,
}
/// A resolved command ready for template expansion. Returned by
/// [`CommandApi::resolve`]. `name` is canonical (lowercase, no `/`); `template` is
/// the raw `COMMAND.md` body.
#[derive(Debug, Clone)]
pub struct ResolvedCommand {
pub name: String,
pub template: String,
}
/// Expand a command template by substituting the user's arguments. Replaces
/// `{{args}}` / `{{prompt}}` with `args`; if neither placeholder is present, the
/// arguments are appended after a `---` separator (opencode-like default). An empty
/// `args` with no placeholder leaves the template verbatim.
pub fn expand_template(template: &str, args: &str) -> String {
if template.contains("{{args}}") || template.contains("{{prompt}}") {
template.replace("{{args}}", args).replace("{{prompt}}", args)
} else if args.is_empty() {
template.to_string()
} else {
format!("{template}\n\n---\n\n{args}")
}
}
/// Abstraction over the command manager that plugins depend on. The main crate's
/// `LlmCommandManager` is the only implementation.
pub trait CommandApi: Send + Sync {
/// Every enabled, non-reserved command (metadata only — no template body),
/// sorted by name. Tolerant of a missing `commands/` directory (returns empty).
fn list_enabled(&self) -> Vec<CommandInfo>;
/// Resolve a command by name (case-insensitive), loading its template body.
/// Returns `None` when the command does not exist, is disabled, or is reserved
/// (collides with a hard-coded system command).
fn resolve(&self, name: &str) -> Option<ResolvedCommand>;
}
+29
View File
@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PropertyType {
String,
Int,
Bool,
SecurityGroup,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigProperty {
pub key: String,
pub name: String,
pub description: String,
pub property_type: PropertyType,
/// Value used when the key is absent from the DB config table.
pub default_value: Option<String>,
}
/// A named group of related [`ConfigProperty`] items, shown as a distinct
/// section in the Config UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigSet {
pub name: String,
pub description: String,
pub properties: Vec<ConfigProperty>,
}
+280
View File
@@ -0,0 +1,280 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::message_meta::Attachment;
// ── Client → Server ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ClientMessage {
pub content: String,
/// Files attached to this message (uploaded beforehand via `POST /api/{source}/uploads`).
#[serde(default)]
pub attachments: Vec<Attachment>,
}
/// Typed data push from remote clients (iOS app, etc.).
/// Sent over the existing WebSocket as `{"type":"data","stream":"...","payload":{...}}`.
#[derive(Deserialize)]
pub struct InboundDataMessage {
pub stream: String,
pub payload: Value,
}
// ── Global event envelope ─────────────────────────────────────────────────────
/// Envelope that wraps every event on the global broadcast bus.
/// `source` is `None` for system/background events (cron, tic, plugins).
#[derive(Clone)]
pub struct GlobalEvent {
pub source: Option<String>,
pub session_id: Option<i64>,
pub event: ServerEvent,
}
// ── Server → Client ───────────────────────────────────────────────────────────
#[derive(Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerEvent {
/// A tool call was started. DB status: running.
ToolStart {
tool_call_id: i64,
message_id: i64,
name: String,
arguments: Value,
/// Concise human-readable label (≤60 chars): tool + primary argument.
label_short: String,
/// Verbose human-readable label (≤120 chars): tool + all meaningful arguments.
label_full: String,
/// Path to a single viewable file this call targets, if any. The
/// frontend renders it as a clickable link to the file viewer.
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
},
/// A tool call completed successfully. DB status: done.
ToolDone {
tool_call_id: i64,
result: String,
/// Result type tag: `"string"` (plain text) or `"json"` (structured
/// payload, e.g. MCP `structuredContent`). The frontend uses it to render
/// typed results instead of a raw text blob. Always populated by the
/// server; the frontend treats an absent/unknown value as plain text, so
/// older clients degrade gracefully.
result_type: String,
},
/// A tool call failed. DB status: error.
ToolError {
tool_call_id: i64,
error: String,
},
/// A tool call was stopped by the user via `/stop`. DB status: cancelled.
/// Distinct from `ToolError`: a cancellation is deliberate, not a failure.
ToolCancelled {
tool_call_id: i64,
},
/// A tool call was denied by an approval policy or a human. DB status: rejected.
/// Distinct from `ToolError`: a denial is a policy decision, not a failure.
ToolRejected {
tool_call_id: i64,
reason: String,
},
/// A sub-agent stack frame was opened.
AgentStart {
stack_id: i64,
parent_tool_call_id: i64,
agent_id: String,
parent_agent_id: String,
depth: i64,
/// The prompt sent to the sub-agent (truncated to 500 chars by the sender).
prompt_preview: String,
},
/// A sub-agent stack frame was closed.
AgentDone {
stack_id: i64,
agent_id: String,
parent_agent_id: String,
/// The sub-agent's final response (truncated to 500 chars by the sender).
result_preview: String,
},
/// The assistant response is complete.
Done {
message_id: i64,
stack_id: i64,
content: String,
input_tokens: Option<u32>,
output_tokens: Option<u32>,
},
/// A fatal error occurred processing the request.
Error {
message: String,
},
/// The LLM was cut off by the token limit (finish_reason="length").
Truncated {
output_tokens: Option<u32>,
},
/// The LLM produced text alongside tool calls (reasoning before acting).
Thinking {
message_id: i64,
content: String,
input_tokens: Option<u32>,
output_tokens: Option<u32>,
},
/// A write operation requires user approval before executing (shows a diff).
PendingWrite {
request_id: i64,
tool_call_id: i64,
path: String,
old_content: Option<String>,
new_content: String,
},
/// A non-file tool call requires user approval before executing.
/// Used for MCP tools, execute_cmd, restart, and any other tool
/// that the ApprovalManager flags as `Require`.
ApprovalRequired {
request_id: i64,
tool_call_id: i64,
tool_name: String,
arguments: Value,
},
/// A sub-agent needs clarification from the user before continuing.
AgentQuestion {
request_id: i64,
tool_call_id: i64,
title: String,
question: String,
suggested_answers: Vec<String>,
},
/// A book file was written by a tool; the frontend should reload if it has it open.
FileChanged {
path: String,
},
/// Ask the frontend to open a file for the user. Behaves like
/// `window.openFile(path)`: navigates to the file viewer page for markdown /
/// text / images, or opens an HTML file in a new browser tab. Emitted by
/// the future `show_file_to_user` interface tool (not wired yet).
OpenFile {
path: String,
},
/// The active LLM model failed and the system switched to a fallback automatically.
ModelFallback {
from: String,
to: String,
reason: String,
},
/// All LLM fallback attempts were exhausted; the turn could not complete.
LlmFailed {
tried: Vec<String>,
last_error: String,
},
/// A new approval entered the Inbox. Emitted on the global bus when the
/// `ApprovalManager` registers a pending request, so bus subscribers (e.g.
/// the mobile-connector plugin) can re-snapshot the Inbox. Distinct from
/// `ApprovalRequired`, which is the per-session WS event carrying full args
/// for the active client.
ApprovalRequested {
request_id: i64,
tool_call_id: i64,
tool_name: String,
},
/// A pending approval or pending-write was resolved (approved or rejected).
/// Emitted on the global bus so all clients (e.g. Telegram) can update their UI.
ApprovalResolved {
request_id: i64,
tool_call_id: i64,
approved: bool,
},
/// A new clarification entered the Inbox. Emitted on the global bus when the
/// `ClarificationManager` registers a pending question. Distinct from
/// `AgentQuestion`, which is the per-session WS event for the active client.
ClarificationRequested {
request_id: i64,
title: String,
},
/// A pending clarification was resolved (answered). Emitted on the global bus
/// so all clients can update their Inbox view.
ClarificationResolved {
request_id: i64,
},
/// A server-initiated MCP elicitation entered the Inbox (e.g. an MCP server
/// asking for a sudo password mid tool-call). Carries only `request_id` +
/// `title` — never the requested value.
ElicitationRequested {
request_id: i64,
title: String,
},
/// A pending elicitation was resolved (accepted / declined / cancelled).
/// Emitted on the global bus so all clients can update their Inbox view.
ElicitationResolved {
request_id: i64,
},
/// The active session for a source was replaced (e.g. /new, /clear).
NewSession {
session_id: i64,
},
/// A user message was persisted to history; broadcast so every client (the
/// sender included) renders the bubble. Emitted at save time — when the row
/// is appended, either at turn start or at a round boundary for messages
/// injected mid-turn — so the bubble appears exactly where the agent saw it.
/// Clients render purely from this echo (no optimistic local rendering).
UserMessage {
/// Id of the `chat_history` row just written.
message_id: i64,
content: String,
/// Files attached to the message; lets secondary clients render chips live.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<Attachment>,
},
/// Sent to a client right after it (re)connects, reporting whether a turn is
/// currently in flight for its session. Lets a reloaded page restore the
/// SEND→STOP button state instead of assuming idle.
TurnRunning {
running: bool,
},
/// The selected LLM client (model) for a source changed. Broadcast to every
/// client of the source so dropdowns/selects stay in sync. `client` is a
/// `client_names()` entry (typically `"auto"` or a model name). Driven by
/// `ChatHub::set_selected_client` — the backend is the single source of truth.
ClientSelected {
client: String,
},
}
impl ServerEvent {
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("ServerEvent serialization failed")
}
pub fn type_name(&self) -> &'static str {
match self {
Self::ToolStart { .. } => "tool_start",
Self::ToolDone { .. } => "tool_done",
Self::ToolError { .. } => "tool_error",
Self::ToolCancelled { .. } => "tool_cancelled",
Self::ToolRejected { .. } => "tool_rejected",
Self::AgentStart { .. } => "agent_start",
Self::AgentDone { .. } => "agent_done",
Self::Done { .. } => "done",
Self::Error { .. } => "error",
Self::Thinking { .. } => "thinking",
Self::PendingWrite { .. } => "pending_write",
Self::ApprovalRequired { .. } => "approval_required",
Self::AgentQuestion { .. } => "agent_question",
Self::FileChanged { .. } => "file_changed",
Self::OpenFile { .. } => "open_file",
Self::Truncated { .. } => "truncated",
Self::ModelFallback { .. } => "model_fallback",
Self::LlmFailed { .. } => "llm_failed",
Self::ApprovalRequested { .. } => "approval_requested",
Self::ApprovalResolved { .. } => "approval_resolved",
Self::ClarificationRequested { .. } => "clarification_requested",
Self::ClarificationResolved { .. } => "clarification_resolved",
Self::ElicitationRequested { .. } => "elicitation_requested",
Self::ElicitationResolved { .. } => "elicitation_resolved",
Self::NewSession { .. } => "new_session",
Self::UserMessage { .. } => "user_message",
Self::TurnRunning { .. } => "turn_running",
Self::ClientSelected { .. } => "client_selected",
}
}
}
+49
View File
@@ -0,0 +1,49 @@
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
// ── Record types (DB ↔ manager) ───────────────────────────────────────────────
/// Full model record, mirroring one row in `image_generate_models`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ImageGenerateModelRecord {
pub id: i64,
pub provider_id: i64,
pub model_id: String,
/// Display alias (also used as the generator `id()`).
pub name: String,
/// Lower number = tried first by `get()`.
pub priority: i32,
}
/// Implemented by any provider that can generate an image from a text prompt.
/// Returns raw image bytes (PNG expected).
#[async_trait]
pub trait ImageGenerate: Send + Sync {
/// A stable, unique identifier for this provider (e.g. `"comfyui-portrait"`).
fn id(&self) -> &str;
/// Human-readable display name.
fn name(&self) -> &str;
/// Human-readable description shown to the LLM in `image_generate_providers_list`.
/// Should mention format, style, default dimensions, and ideal use cases.
fn description(&self) -> Option<&str> { None }
/// JSON Schema for the `extra_params` argument accepted by this provider.
/// Returned as-is in `image_generate_providers_list` so the LLM knows what to pass.
fn extra_params_schema(&self) -> Option<Value> { None }
/// Generate an image. `extra_params` is the provider-specific JSON object
/// passed by the LLM (validated against `extra_params_schema`).
async fn generate(&self, prompt: &str, extra_params: Option<&Value>) -> Result<Vec<u8>>;
}
/// Write-side of the image generator manager: register and remove ephemeral providers.
///
/// Implemented by `ImageGeneratorManager` in the main crate. Plugins that provide
/// their own image generation (e.g. a ComfyUI plugin) use this to register providers
/// at start and unregister them at stop.
#[async_trait]
pub trait ImageGenerateRegistry: Send + Sync {
async fn register(&self, provider: Arc<dyn ImageGenerate>);
async fn unregister(&self, id: &str);
}
+92
View File
@@ -0,0 +1,92 @@
//! Inbox API exposed to plugins.
//!
//! The Skald `Inbox` façade (src/core/inbox.rs) wraps the `ApprovalManager` and
//! `ClarificationManager`. Plugins receive `Arc<dyn InboxApi>` via `PluginContext`
//! and use it to read pending items and resolve them, without depending on the
//! main crate. `request_id` is the integer rowid used both for resolution and
//! for idempotency (plugin.md §2): `approve`/`reject`/`answer` on an already
//! resolved id are no-ops.
use async_trait::async_trait;
use serde::Serialize;
use serde_json::Value;
/// One pending approval, surfaced to plugins (mirrors the main crate's
/// `PendingApprovalInfo`, trimmed to the fields a plugin needs for the
/// `inbox_update` payload — see payloads.md §3.1).
#[derive(Debug, Clone, Serialize)]
pub struct InboxApprovalItem {
pub request_id: i64,
pub tool_name: String,
/// Human-readable one-line label for the tool call, from `Tool::describe(Short)`.
/// Used for the card / push notification text.
pub summary: String,
/// Raw tool arguments (untruncated). Source of truth for the detail dialog so
/// the user sees exactly what is being approved — critical for `execute_cmd`.
pub arguments: Value,
pub agent_id: String,
pub source: String,
pub context_label: Option<String>,
/// ISO-8601 timestamp string (UTC).
pub created_at: String,
}
/// One pending clarification, surfaced to plugins (mirrors the main crate's
/// `PendingClarificationInfo`).
#[derive(Debug, Clone, Serialize)]
pub struct InboxClarificationItem {
pub request_id: i64,
pub agent_id: String,
pub source: String,
pub context_label: Option<String>,
pub title: String,
pub question: String,
pub suggested_answers: Vec<String>,
/// ISO-8601 timestamp string (UTC).
pub created_at: String,
}
/// One pending elicitation, surfaced to plugins (mirrors the main crate's
/// `PendingElicitationInfo`). Holds only prompt metadata — never the value.
#[derive(Debug, Clone, Serialize)]
pub struct InboxElicitationItem {
pub request_id: i64,
pub server_name: String,
pub message: String,
pub field_name: Option<String>,
pub sensitive: bool,
pub is_confirmation: bool,
/// ISO-8601 timestamp string (UTC).
pub created_at: String,
}
/// A snapshot of all pending Inbox items.
#[derive(Debug, Clone, Serialize)]
pub struct InboxSnapshot {
pub total: usize,
pub approvals: Vec<InboxApprovalItem>,
pub clarifications: Vec<InboxClarificationItem>,
pub elicitations: Vec<InboxElicitationItem>,
}
/// Inbox operations available to plugins.
#[async_trait]
pub trait InboxApi: Send + Sync {
/// Snapshot of all currently pending approvals + clarifications.
async fn list_pending(&self) -> InboxSnapshot;
/// Approve a pending tool-call request. No-op if already resolved.
async fn approve(&self, request_id: i64);
/// Reject a pending tool-call request with a reason. No-op if already resolved.
async fn reject(&self, request_id: i64, reason: String);
/// Answer a pending clarification. Returns `true` if a pending entry was
/// found and resolved, `false` otherwise (idempotent).
async fn answer(&self, request_id: i64, answer: String) -> bool;
/// Resolve a pending MCP elicitation. `action` is `"accept"`/`"decline"`/
/// `"cancel"`; `content` carries the field values for `accept` (it may hold
/// a secret — do not log it). Returns `true` if a pending entry was found.
async fn resolve_elicitation(&self, request_id: i64, action: String, content: Option<Value>) -> bool;
}
+24
View File
@@ -0,0 +1,24 @@
use std::pin::Pin;
use std::sync::Arc;
use serde_json::Value;
/// Future returned by an [`InterfaceTool`] handler.
pub type ToolFuture = Pin<Box<dyn std::future::Future<Output = anyhow::Result<String>> + Send>>;
/// A single LLM-callable tool injected by a specific interface (Telegram, Web, Cron, …).
///
/// The handler closure captures interface-specific state (e.g. `Arc<Bot>` + `ChatId`).
pub struct InterfaceTool {
/// OpenAI-format tool definition sent to the LLM in the tools array.
pub definition: Value,
/// Async handler invoked when the LLM calls this tool.
pub handler: Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>,
}
impl InterfaceTool {
/// Returns the tool's name as declared in the definition.
pub fn name(&self) -> &str {
self.definition["function"]["name"].as_str().unwrap_or("")
}
}
+25
View File
@@ -0,0 +1,25 @@
/// Application name, sent as `X-Title` HTTP header to LLM/image/audio providers.
pub const APP_NAME: &str = "Skald";
pub mod approval;
pub mod bus;
pub mod system_bus;
pub mod chatbot;
pub mod chat_hub;
pub mod command;
pub mod events;
pub mod image_generate;
pub mod inbox;
pub mod interface_tool;
pub mod location;
pub mod memory;
pub mod message_meta;
pub mod plugin;
pub mod provider;
pub mod remote;
pub mod tool;
pub mod secrets;
pub mod transcribe;
pub mod tts;
pub mod config_property;
pub use config_property::{ConfigProperty, ConfigSet, PropertyType};
+69
View File
@@ -0,0 +1,69 @@
use std::collections::HashMap;
use std::sync::RwLock;
use chrono::{DateTime, Utc};
#[derive(Clone, Debug)]
pub struct GpsCoord {
pub latitude: f64,
pub longitude: f64,
}
#[derive(Clone, Debug)]
pub struct LocationEntry {
pub coord: GpsCoord,
pub accuracy: Option<f64>,
pub updated_at: DateTime<Utc>,
pub is_live: bool,
}
pub struct LocationManager {
locations: RwLock<HashMap<String, LocationEntry>>,
}
impl LocationManager {
pub fn new() -> Self {
Self { locations: RwLock::new(HashMap::new()) }
}
pub fn update(&self, source: &str, coord: GpsCoord, accuracy: Option<f64>, is_live: bool) {
let entry = LocationEntry { coord, accuracy, updated_at: Utc::now(), is_live };
self.locations.write().unwrap().insert(source.to_string(), entry);
}
pub fn get(&self, source: &str) -> Option<LocationEntry> {
self.locations.read().unwrap().get(source).cloned()
}
pub fn latest(&self) -> Option<(String, LocationEntry)> {
self.locations.read().unwrap()
.iter()
.max_by_key(|(_, e)| e.updated_at)
.map(|(k, v)| (k.clone(), v.clone()))
}
pub fn all(&self) -> Vec<(String, LocationEntry)> {
self.locations.read().unwrap()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
}
impl Default for LocationManager {
fn default() -> Self { Self::new() }
}
/// Write-only view of a location store.
///
/// Plugins store `Arc<dyn LocationUpdater>` so they can report GPS fixes
/// without depending on the concrete `LocationManager` struct.
pub trait LocationUpdater: Send + Sync {
fn update(&self, source: &str, coord: GpsCoord, accuracy: Option<f64>, is_live: bool);
}
impl LocationUpdater for LocationManager {
fn update(&self, source: &str, coord: GpsCoord, accuracy: Option<f64>, is_live: bool) {
LocationManager::update(self, source, coord, accuracy, is_live);
}
}
+29
View File
@@ -0,0 +1,29 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::tool::Tool;
/// Pluggable long-term memory backend.
///
/// Implementations are registered with `MemoryManager` in the main crate.
/// At most one backend is active at a time (singleton rule enforced by the manager).
#[async_trait]
pub trait Memory: Send + Sync {
/// Unique identifier for this backend (e.g. `"honcho"`).
fn id(&self) -> &str;
/// Returns `true` when the backend is reachable and ready.
fn is_available(&self) -> bool;
/// Retrieves context for the upcoming turn to inject into the system prompt.
/// Returns `None` on cold start, backend down, or nothing useful available.
async fn query_context(&self, session_id: i64, user_message: &str) -> Option<String>;
/// Optional LLM-callable tools exposed by this backend (e.g. `memory_query`).
/// Called per turn — added to the live tool list and dispatched before the
/// global tool registry.
fn tools(&self) -> Vec<Arc<dyn Tool>> {
vec![]
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Structured, reusable metadata attached to a `chat_history` row.
//!
//! Persisted as a single JSON column (`chat_history.metadata`) and intentionally
//! generic: today it carries user file **attachments**, but new keys can be added
//! later without a schema change. Two independent readers derive different views
//! from the same source:
//! - the **LLM context** builder appends [`attachments_block`] to the user turn,
//! - the **history UI** renders the structured attachments as chips.
//!
//! The raw `[SYSTEM INFO]` text block is therefore never persisted — it is
//! generated on the fly from this metadata.
use serde::{Deserialize, Serialize};
/// One file attached by the user to a message. `path` is relative to the project
/// root (e.g. `data/uploads/123/file.pdf`) so it is both servable under `/data/…`
/// and resolvable by the filesystem tools.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Attachment {
pub path: String,
pub name: String,
/// Best-effort MIME type (e.g. `application/pdf`); `None` if unknown.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mimetype: Option<String>,
/// Size in bytes, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filesize: Option<u64>,
}
/// Generic metadata bag for a chat message. Extra keys may be added over time;
/// `#[serde(default)]` keeps deserialization tolerant of older/newer shapes.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MessageMetadata {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
/// Present when this user turn was produced by a custom slash command.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<CommandRef>,
}
impl MessageMetadata {
/// True when there is nothing worth persisting.
pub fn is_empty(&self) -> bool {
self.attachments.is_empty() && self.command.is_none()
}
}
/// Identifies a user message produced by a custom slash command. The history row's
/// `content` holds the **expanded template** (replayed to the LLM verbatim); the UI
/// renders `display` — the original `/command …` the user typed — instead.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommandRef {
/// Canonical command name, without the leading `/` (e.g. `review`).
pub name: String,
/// The original text the user typed (e.g. `/review revisiona Cat.java`).
pub display: String,
}
/// Renders the human-readable block appended to a user turn so the LLM learns
/// which files were attached. Returns an empty string when there are none, so
/// callers can unconditionally concatenate it.
///
/// Shared by the web/mobile path and the Telegram plugin so every surface emits
/// an identical format.
pub fn attachments_block(attachments: &[Attachment]) -> String {
if attachments.is_empty() {
return String::new();
}
let noun = if attachments.len() == 1 { "file" } else { "files" };
let mut block = format!(
"\n\n[SYSTEM INFO]\n{} attached {}:",
attachments.len(),
noun
);
for a in attachments {
block.push_str(&format!("\n* {}", a.path));
}
block
}
+95
View File
@@ -0,0 +1,95 @@
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
use tokio::sync::RwLock;
use crate::approval::ApprovalApi;
use crate::bus::ChatEventBus;
use crate::command::CommandApi;
use crate::system_bus::SystemEventBus;
use crate::chat_hub::ChatHubApi;
use crate::image_generate::ImageGenerateRegistry;
use crate::inbox::InboxApi;
use crate::location::LocationUpdater;
use crate::memory::Memory;
use crate::provider::ApiProviderRegistry;
use crate::remote::RemoteAccess;
use crate::secrets::SecretsApi;
use crate::transcribe::{TranscribeProvider, TranscribeRegistry};
use crate::tts::{TtsProvider, TtsRegistry};
/// Closure that builds a fresh Axum router (e.g. for the mesh-facing server).
pub type RouterFactory = Arc<dyn Fn() -> axum::Router + Send + Sync>;
/// All deps a plugin may need — passed to [`Plugin::start`] and [`Plugin::reload`].
///
/// Fields are `Arc<dyn Trait>` sourced from `core-api`. Plugins use only the
/// fields relevant to them; unused fields are ignored.
/// `router_factory` and `remote_slot` are networking-specific — used only by
/// `RemotePlugin`.
#[derive(Clone)]
pub struct PluginContext {
pub chat_hub: Arc<dyn ChatHubApi>,
/// Custom file-based slash commands (`commands/<name>/`). Read-only from the
/// plugin side — lets the Telegram bot resolve `/command` expansions.
pub command: Arc<dyn CommandApi>,
pub approval: Arc<dyn ApprovalApi>,
/// Unified Inbox façade (approvals + clarifications). See plugin.md §12.2.
pub inbox: Arc<dyn InboxApi>,
/// Skald's shared SQLite pool — lets plugins create/use their own tables
/// (e.g. `relay_*`) in the main DB. See plugin.md §12.1.
pub db: Arc<sqlx::SqlitePool>,
pub secrets: Arc<dyn SecretsApi>,
pub transcribe: Arc<dyn TranscribeProvider>,
pub transcribe_registry: Arc<dyn TranscribeRegistry>,
pub image_generate_registry: Arc<dyn ImageGenerateRegistry>,
pub tts_registry: Arc<dyn TtsRegistry>,
pub tts_provider: Arc<dyn TtsProvider>,
pub api_provider_registry: Arc<dyn ApiProviderRegistry>,
pub location: Arc<dyn LocationUpdater>,
pub event_bus: Arc<ChatEventBus>,
pub system_bus: Arc<SystemEventBus>,
pub web_port: u16,
pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
pub router_factory: RouterFactory,
}
/// Plugin lifecycle contract.
///
/// Each plugin implements this trait. The `PluginManager` in the main crate
/// manages their lifecycle and passes a `PluginContext` on every start/reload.
#[async_trait]
pub trait Plugin: Send + Sync {
fn id(&self) -> &str;
fn name(&self) -> &str;
fn description(&self) -> &str;
fn is_running(&self) -> bool;
/// JSON Schema describing the plugin's config fields.
fn config_schema(&self) -> Value { serde_json::json!({}) }
/// Called whenever the enabled flag or config changes — including at startup.
/// The plugin is responsible for diffing state and restarting only what changed.
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()>;
async fn start(&self, ctx: PluginContext) -> Result<()>;
async fn stop(&self) -> Result<()>;
/// Runtime state surfaced to the UI and to agents (e.g. mesh IP).
fn runtime_status(&self) -> Option<Value> { None }
/// Optional Axum router contributed by the plugin. When `Some`, the main
/// `WebFrontend` nests it under `/api/plugin/<id>/` behind Skald's normal
/// auth (plugin.md §12.3). The router must close over the plugin's own state
/// (it receives no `State`). Default: no routes — existing plugins are
/// unaffected.
fn http_router(&self) -> Option<axum::Router> { None }
/// Returns a [`Memory`] backend if this plugin provides one.
fn memory(&self) -> Option<Arc<dyn Memory>> { None }
fn as_any(&self) -> &dyn std::any::Any;
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync>;
}
+251
View File
@@ -0,0 +1,251 @@
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use crate::chatbot::ChatbotClient;
use crate::image_generate::{ImageGenerate, ImageGenerateModelRecord};
use crate::tts::{TextToSpeech, TtsModelRecord, RemoteTtsModelInfo};
use crate::transcribe::{Transcribe, TranscribeModelRecord, RemoteTranscribeModelInfo};
// ── LlmStrength ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmStrength {
VeryLow,
Low,
Average,
High,
VeryHigh,
}
// ── Provider record types (DB ↔ manager) ──────────────────────────────────────
/// Full provider record (includes secrets — only expose over trusted local connections).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LlmProviderRecord {
pub id: i64,
pub name: String,
/// Provider type_id string as stored in DB (e.g. "open_ai", "anthropic").
#[serde(rename = "type")]
pub provider: String,
pub api_key: Option<String>,
/// Only used by ollama and lm_studio.
pub base_url: Option<String>,
pub description: Option<String>,
}
/// Full model record.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LlmModelRecord {
pub id: i64,
pub provider_id: i64,
pub model_id: String,
pub name: String,
pub strength: Option<LlmStrength>,
pub scope: Vec<String>,
pub is_default: bool,
pub priority: i32,
pub extra_params: Option<serde_json::Value>,
pub context_length: Option<i64>,
pub max_output_tokens: Option<i64>,
pub knowledge_cutoff: Option<String>,
pub capabilities: Vec<String>,
/// Selected reasoning value (interpreted per provider via `ReasoningMode`):
/// a JSON string for a `ValueSet` (e.g. `"high"`, `"enabled"`) or a JSON
/// number for a `Range` (e.g. `8000`). `None` = reasoning off / unset.
pub reasoning: Option<serde_json::Value>,
}
/// Remote model info returned by a provider's `list_llm_models()`.
#[derive(Debug, Clone, serde::Serialize)]
pub struct RemoteLlmModelInfo {
pub id: String,
pub name: String,
pub context_length: Option<u64>,
pub max_completion_tokens: Option<u64>,
pub knowledge_cutoff: Option<String>,
pub capabilities: Vec<String>,
pub vision: Option<bool>,
pub price_input_per_million: Option<f64>,
pub price_output_per_million: Option<f64>,
/// Reasoning control descriptor for this model, if it supports reasoning.
/// Filled by the API layer from `ApiProvider::reasoning_mode`; providers'
/// `list_llm_models` may leave it `None`.
#[serde(default)]
pub reasoning: Option<ReasoningMode>,
}
// ── ReasoningMode ─────────────────────────────────────────────────────────────
/// Describes how a model exposes its "reasoning"/"thinking" knob, so the UI can
/// render the right control and the caller knows the space of valid values.
/// Provider-owned (each `ApiProvider` returns it per model); the actual request
/// translation is done by `ApiProvider::reasoning_request`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ReasoningMode {
/// A fixed set of discrete string choices — e.g. `["low","medium","high"]`
/// (effort levels) or `["disabled","enabled"]` (on/off toggle).
ValueSet {
values: Vec<String>,
default: Option<String>,
},
/// A continuous numeric range — e.g. Anthropic thinking `budget_tokens`.
Range {
min: i64,
max: i64,
step: Option<i64>,
default: Option<i64>,
unit: Option<String>,
},
}
// ── ServiceType ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceType {
Llm,
Transcribe,
ImageGenerate,
Tts,
}
// ── UI metadata ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, serde::Serialize)]
pub struct ProviderUiMeta {
pub type_id: &'static str,
pub display_name: &'static str,
pub description: Option<&'static str>,
pub color: &'static str,
pub icon: &'static str,
pub fields: &'static [ProviderField],
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ProviderField {
pub key: &'static str,
pub label: &'static str,
pub required: bool,
pub secret: bool,
}
// ── BuiltLlmClient ────────────────────────────────────────────────────────────
pub struct BuiltLlmClient {
pub client: Arc<dyn ChatbotClient>,
pub prompt_cache: bool,
}
// ── ApiProvider trait ─────────────────────────────────────────────────────────
#[async_trait]
pub trait ApiProvider: Send + Sync {
/// Short stable identifier stored in the DB (e.g. "open_ai", "anthropic").
fn type_id(&self) -> &'static str;
fn display_name(&self) -> &'static str;
fn supported_types(&self) -> &'static [ServiceType];
async fn list_llm_models(
&self,
_record: &LlmProviderRecord,
) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
Ok(None)
}
/// Reasoning control descriptor for a given model, or `None` if the model
/// does not support reasoning. Drives the UI control (a `ValueSet` dropdown
/// or a numeric `Range`). Provider-owned so each provider decides which of
/// its models support reasoning and in what form.
fn reasoning_mode(
&self,
_model_id: &str,
_capabilities: &[String],
) -> Option<ReasoningMode> {
None
}
/// Translates a selected reasoning `value` (see `LlmModelRecord::reasoning`)
/// into the provider-specific JSON fragment to merge into the request body
/// (e.g. `{"reasoning":{"effort":"high"}}` or `{"thinking":{"type":"enabled"}}`).
/// `None` = nothing to inject.
fn reasoning_request(
&self,
_value: &serde_json::Value,
) -> Option<serde_json::Value> {
None
}
async fn llm_model_info(
&self,
_record: &LlmProviderRecord,
_model_id: &str,
) -> Result<Option<RemoteLlmModelInfo>> {
Ok(None)
}
async fn list_tts_models(
&self,
_record: &LlmProviderRecord,
) -> Result<Option<Vec<RemoteTtsModelInfo>>> {
Ok(None)
}
async fn list_transcribe_models(
&self,
_record: &LlmProviderRecord,
) -> Result<Option<Vec<RemoteTranscribeModelInfo>>> {
Ok(None)
}
fn build_llm(
&self,
_record: &LlmProviderRecord,
_model: &LlmModelRecord,
) -> Option<Result<BuiltLlmClient>> {
None
}
fn build_tts(
&self,
_record: &LlmProviderRecord,
_model: &TtsModelRecord,
) -> Option<Result<Arc<dyn TextToSpeech>>> {
None
}
fn build_transcriber(
&self,
_record: &LlmProviderRecord,
_model: &TranscribeModelRecord,
) -> Option<Result<Arc<dyn Transcribe>>> {
None
}
fn build_image_generator(
&self,
_record: &LlmProviderRecord,
_model: &ImageGenerateModelRecord,
) -> Option<Result<Arc<dyn ImageGenerate>>> {
None
}
fn ui_meta(&self) -> ProviderUiMeta;
}
// ── ApiProviderRegistry trait ─────────────────────────────────────────────────
/// Write-side of the provider registry: register and remove plugin-provided
/// `ApiProvider` implementations at runtime.
///
/// Implemented by `ProviderRegistry` in the main crate. Plugins that supply
/// their own API provider (e.g. `plugin-elevenlabs`) use this to register at
/// start and unregister at stop.
#[async_trait]
pub trait ApiProviderRegistry: Send + Sync {
fn register_plugin(&self, provider: Arc<dyn ApiProvider>);
fn unregister_plugin(&self, type_id: &str);
}
+22
View File
@@ -0,0 +1,22 @@
use std::net::Ipv4Addr;
use anyhow::Result;
use async_trait::async_trait;
/// Abstraction over a mesh/remote-connectivity provider.
/// The core (AppState, plugins) talks only to this trait —
/// no Tailscale-specific types leak outside the implementation.
#[async_trait]
pub trait RemoteAccess: Send + Sync {
/// Short provider name used in logs and status fields (e.g. `"tailscale"`).
fn provider_name(&self) -> &str;
/// IP address of this node on the mesh network.
async fn device_ip(&self) -> Result<Ipv4Addr>;
/// True once the provider has joined the mesh and obtained an IP.
fn is_connected(&self) -> bool;
/// Graceful shutdown — disconnect from the mesh and release resources.
async fn shutdown(&self);
}
+39
View File
@@ -0,0 +1,39 @@
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
/// Full CRUD interface to the secrets store.
///
/// Implemented by `SecretsStore` in the main crate. Both plugins (via
/// `PluginContext`) and agent tools (via `AppState`) receive an
/// `Arc<dyn SecretsApi>` so neither needs to depend on the main crate.
///
/// Security notes:
/// - Values should never appear in log output.
/// - Keys are safe to log/list.
/// - Storage is SQLite — same protection level as the rest of the DB.
#[async_trait]
pub trait SecretsApi: Send + Sync {
/// Returns the value for `key`, or `None` if not set.
async fn get(&self, key: &str) -> Option<String>;
/// Inserts or replaces the secret for `key`.
async fn set(&self, key: &str, value: &str) -> Result<()>;
/// Removes the secret for `key`. No-op if not present.
async fn delete(&self, key: &str) -> Result<()>;
/// Returns all stored keys (never values).
async fn list_keys(&self) -> Vec<String>;
}
/// Resolves a required secret, returning a descriptive error if absent.
pub async fn require(secrets: &Arc<dyn SecretsApi>, key: &str) -> Result<String> {
secrets.get(key).await.ok_or_else(|| {
anyhow::anyhow!(
"secret '{}' is not set — tell the agent: \"set the secret {} to <value>\"",
key, key,
)
})
}
+92
View File
@@ -0,0 +1,92 @@
//! In-process event bus for system-level lifecycle events.
//!
//! Distinct from [`crate::bus::ChatEventBus`] which carries chat-turn events.
//! This bus carries infrastructure events: provider registration, plugin state
//! changes, etc. Any component can subscribe and react without direct coupling.
//!
//! # Usage
//! ```rust,ignore
//! // Producer (e.g. a plugin):
//! bus.send(SystemEvent::ApiProviderRegistered { type_id: "elevenlabs".into() });
//!
//! // Consumer (spawn once at startup):
//! let mut rx = bus.subscribe();
//! tokio::spawn(async move {
//! loop {
//! match rx.recv().await {
//! Ok(SystemEvent::ApiProviderRegistered { type_id }) => { /* reload */ }
//! Ok(SystemEvent::ApiProviderUnregistered { type_id }) => { /* reload */ }
//! Err(RecvError::Lagged(n)) => warn!("system_bus lagged by {n}"),
//! Err(RecvError::Closed) => break,
//! }
//! }
//! });
//! ```
use tokio::sync::broadcast;
pub use tokio::sync::broadcast::error::RecvError;
const DEFAULT_CAPACITY: usize = 64;
// ── Events ────────────────────────────────────────────────────────────────────
/// All system-level events that flow through the [`SystemEventBus`].
#[derive(Debug, Clone)]
pub enum SystemEvent {
/// A plugin registered a new `ApiProvider` (e.g. ElevenLabs on plugin start).
ApiProviderRegistered { type_id: String },
/// A plugin unregistered an `ApiProvider` (e.g. on plugin stop/disable).
ApiProviderUnregistered { type_id: String },
/// A config key was changed via the API (only fires when the value actually changes).
ConfigKeyUpdated { key: String, old_value: Option<String>, new_value: String },
/// A scheduled job finished (success or failure). `origin_ref` is the opaque
/// string stored in `scheduled_jobs.origin_ref` (e.g. `"PROJECT_TASK:42"`).
JobCompleted {
job_id: i64,
origin_ref: Option<String>,
result: Option<String>,
error: Option<String>,
},
/// A session was forcibly cancelled (e.g. via the kill-task API).
/// Subscribers should cancel any in-flight LLM turn, pending approvals,
/// and pending clarifications for that session.
SessionCancelled {
session_id: i64,
},
}
// ── Bus ───────────────────────────────────────────────────────────────────────
pub struct SystemEventBus {
tx: broadcast::Sender<SystemEvent>,
}
impl SystemEventBus {
pub fn new() -> Self {
Self::with_capacity(DEFAULT_CAPACITY)
}
pub fn with_capacity(capacity: usize) -> Self {
let (tx, _) = broadcast::channel(capacity);
Self { tx }
}
/// Publish an event. No-op if there are no active subscribers.
pub fn send(&self, event: SystemEvent) {
let _ = self.tx.send(event);
}
/// Returns a new independent receiver. Each subscriber gets every future
/// event independently. If the subscriber falls behind by more than the
/// channel capacity it receives `RecvError::Lagged(n)`.
pub fn subscribe(&self) -> broadcast::Receiver<SystemEvent> {
self.tx.subscribe()
}
}
impl Default for SystemEventBus {
fn default() -> Self {
Self::new()
}
}
+355
View File
@@ -0,0 +1,355 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
use anyhow::Result;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
// ── ToolDescriptionLength ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolDescriptionLength {
Short,
Full,
}
// ── ToolCategory ──────────────────────────────────────────────────────────────
/// Logical grouping for a tool.
///
/// Used for access-control filtering and for display/audit purposes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCategory {
/// Read or write files on disk.
Filesystem,
/// Run shell commands or restart the process.
Shell,
/// Invoke sub-agents via call_agent.
Subagent,
/// Read-only discovery of system state.
Introspection,
/// Mutate system configuration.
Config,
}
// ── Tool trait ────────────────────────────────────────────────────────────────
/// A single LLM-callable tool.
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
/// Human-readable label for this tool invocation shown in UI / notifications.
fn describe(&self, _args: &Value, _length: ToolDescriptionLength) -> String {
self.name().to_string()
}
/// If this invocation targets a single file the user can open in the file
/// viewer, return its path (relative to the project root, or absolute).
/// Tools that target a directory (list/grep) or no file at all return
/// `None`. The frontend renders the returned path as a clickable link.
fn target_path(&self, _args: &Value) -> Option<String> {
None
}
/// JSON Schema for the `parameters` field in the OpenAI function definition.
fn parameters_schema(&self) -> Value;
/// Execute the tool synchronously and return a plain-text result (or error string).
/// Tools that require async I/O should override `execute_async` instead.
fn execute(&self, _args: Value) -> Result<String> {
Err(anyhow::anyhow!("tool '{}': sync execute not implemented — use execute_async", self.name()))
}
/// Execute the tool asynchronously. The default wraps `execute`; async tools
/// (e.g. image generation) override this directly to avoid `block_in_place`.
fn execute_async<'a>(&'a self, args: Value) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
Box::pin(async move { self.execute(args) })
}
/// Execute and produce a typed [`ToolResult`]. The default bridges
/// [`execute_async`](Self::execute_async) (plain string) into [`ToolResult::Text`],
/// so existing tools need no changes. Override to return [`ToolResult::Json`]
/// for tools with structured output (e.g. MCP tools exposing `structuredContent`).
fn execute_typed<'a>(&'a self, args: Value)
-> Pin<Box<dyn Future<Output = Result<ToolResult>> + Send + 'a>> {
Box::pin(async move { Ok(ToolResult::Text(self.execute_async(args).await?)) })
}
/// Start a single execution of this tool and return a live [`ToolExecution`]
/// handle. This is the entry point the session driver uses: it lets the tool
/// own its in-flight state and implement its own `stop()` (e.g. ComfyUI sends
/// an `/interrupt`; `execute_cmd` relies on `kill_on_drop`).
///
/// The default wraps `execute_typed` in a [`SimpleExecution`], whose `stop()`
/// drops the work future — already enough to make `/stop` responsive for any
/// I/O-bound tool. Tools needing remote/child teardown override this and
/// return their own `ToolExecution` with a bespoke `stop()`.
fn run<'a>(&'a self, args: Value) -> Box<dyn ToolExecution + 'a> {
Box::new(SimpleExecution::new(self.execute_typed(args)))
}
/// Logical category of this tool.
fn category(&self) -> ToolCategory;
/// If true, this tool is only included in the tool list for sub-agents (depth > 0).
fn sub_agents_only(&self) -> bool { false }
/// If true, this tool is only included in the tool list for the root agent (depth == 0).
fn root_agent_only(&self) -> bool { false }
/// If true, this tool is only available to interactive sessions (web, telegram, mobile, voice).
/// Non-interactive background sessions (cron, tic) will not receive this tool definition.
fn interactive_only(&self) -> bool { false }
/// Full OpenAI-format tool definition ready to be sent to the LLM.
fn openai_definition(&self) -> Value {
serde_json::json!({
"type": "function",
"function": {
"name": self.name(),
"description": self.description(),
"parameters": self.parameters_schema(),
}
})
}
}
// ── ToolExecutionState ────────────────────────────────────────────────────────
/// Lifecycle state of a single tool execution.
///
/// Richer than the persisted `chat_llm_tools.status` string: it distinguishes a
/// user `/stop` (`Cancelled`) and a policy/human denial (`Rejected`) from a real
/// tool error (`Failed`). The session driver owns the approval-phase states
/// (`Pending`, `AwaitingApproval`, `Rejected`); a [`ToolExecution`] itself only
/// ever reports `Running → Completed | Failed | Cancelled`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolExecutionState {
/// Intent recorded, not yet started (transient; not persisted on its own).
Pending,
/// Blocked waiting for a human approval / clarification answer.
AwaitingApproval,
/// Actively executing.
Running,
/// Finished successfully.
Completed,
/// Finished with a tool/runtime error.
Failed,
/// Stopped by the user via `/stop` — not an error.
Cancelled,
/// Denied by an approval policy or a human — not an error.
Rejected,
}
impl ToolExecutionState {
/// String persisted in `chat_llm_tools.status`. `AwaitingApproval` maps to the
/// legacy `pending` value so existing resume logic keeps working; the brand-new
/// `Pending` state is never persisted (the row is created on the first real
/// transition) and defaults to `running` defensively.
pub fn as_db_str(self) -> &'static str {
match self {
Self::Pending => "running",
Self::AwaitingApproval => "pending",
Self::Running => "running",
Self::Completed => "done",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
Self::Rejected => "rejected",
}
}
}
// ── ToolResult ────────────────────────────────────────────────────────────────
/// The typed result of a successful tool execution.
///
/// Most tools produce plain text ([`ToolResult::Text`], persisted as
/// `result_type = "string"`). Tools with structured output — notably MCP servers
/// returning `structuredContent` — produce [`ToolResult::Json`] (persisted as
/// `result_type = "json"`), so the host/frontend can render the typed payload
/// instead of a raw text blob. At the LLM wire both variants become the same
/// `{"role":"tool","content": <string>}` bytes (see [`ToolResult::to_wire`]); the
/// type tag only matters to the host.
#[derive(Debug, Clone)]
pub enum ToolResult {
/// Plain-text result. The default for every built-in tool.
Text(String),
/// Structured JSON result (e.g. MCP `structuredContent`).
Json(serde_json::Value),
}
impl ToolResult {
/// Tag persisted in `chat_llm_tools.result_type` and sent over the WS as
/// `ServerEvent::ToolDone.result_type`. Either `"string"` or `"json"`.
pub fn kind(&self) -> &'static str {
match self {
Self::Text(_) => "string",
Self::Json(_) => "json",
}
}
/// Wire content for the LLM tool message: text as-is, Json serialized to a
/// compact JSON string. Both OpenAI and Anthropic encode tool results as
/// text/JSON, so this is the canonical string form persisted in
/// `chat_llm_tools.result` and replayed by the message builder.
pub fn to_wire(&self) -> String {
match self {
Self::Text(s) => s.clone(),
Self::Json(v) => serde_json::to_string(v).unwrap_or_else(|_| "null".to_string()),
}
}
}
impl From<String> for ToolResult {
fn from(s: String) -> Self { Self::Text(s) }
}
impl From<&str> for ToolResult {
fn from(s: &str) -> Self { Self::Text(s.to_string()) }
}
// ── ExecutionOutcome ──────────────────────────────────────────────────────────
/// Terminal outcome produced by [`ToolExecution::wait`]. A running execution can
/// only end in one of these three ways; `Rejected`/`AwaitingApproval` are decided
/// by the approval gate *before* the work runs and never appear here.
#[derive(Debug, Clone)]
pub enum ExecutionOutcome {
Completed(ToolResult),
Failed(String),
Cancelled,
}
impl ExecutionOutcome {
pub fn state(&self) -> ToolExecutionState {
match self {
Self::Completed(_) => ToolExecutionState::Completed,
Self::Failed(_) => ToolExecutionState::Failed,
Self::Cancelled => ToolExecutionState::Cancelled,
}
}
}
/// A boxed, owned unit of asynchronous tool work producing a typed [`ToolResult`].
/// This is what [`Tool::execute_typed`] returns; [`SimpleExecution`] wraps one.
pub type ToolWork<'a> = Pin<Box<dyn Future<Output = Result<ToolResult>> + Send + 'a>>;
// ── ToolExecution ─────────────────────────────────────────────────────────────
/// A single, live execution of a [`Tool`]. Owns its in-memory state and decides
/// how it stops. Pure: it never touches the DB or the WebSocket — the session
/// driver mirrors its state transitions to persistence and transport.
///
/// `Send + Sync` is required because the driver shares `&self` across the two
/// concurrent branches of the cancellation race (`wait` vs `stop`).
pub trait ToolExecution: Send + Sync {
/// Current in-memory lifecycle state.
fn state(&self) -> ToolExecutionState;
/// Drive the work to its terminal outcome. Called exactly once by the driver.
fn wait<'a>(&'a self) -> Pin<Box<dyn Future<Output = ExecutionOutcome> + Send + 'a>>;
/// Tool-specific cancellation: signal the work to stop and tear down any
/// remote/child resources. The default relies on the driver dropping the
/// `wait` future; [`SimpleExecution`] overrides it to cancel its stop-token.
fn stop<'a>(&'a self) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async {})
}
}
// ── SimpleExecution ───────────────────────────────────────────────────────────
/// Default [`ToolExecution`] for any tool that is a single async unit of work.
///
/// Holds the work future plus a stop-token; `wait` races the two, so a `stop()`
/// (or the driver dropping `wait`) drops the work future — aborting the in-flight
/// I/O (a `reqwest` connection, a `kill_on_drop` child, …). Enough to make
/// `/stop` responsive for every I/O-bound tool with zero per-tool code.
pub struct SimpleExecution<'a> {
state: Mutex<ToolExecutionState>,
stop: CancellationToken,
work: tokio::sync::Mutex<Option<ToolWork<'a>>>,
}
impl<'a> SimpleExecution<'a> {
pub fn new(work: ToolWork<'a>) -> Self {
Self {
state: Mutex::new(ToolExecutionState::Running),
stop: CancellationToken::new(),
work: tokio::sync::Mutex::new(Some(work)),
}
}
}
impl<'a> ToolExecution for SimpleExecution<'a> {
fn state(&self) -> ToolExecutionState {
*self.state.lock().unwrap()
}
fn wait<'b>(&'b self) -> Pin<Box<dyn Future<Output = ExecutionOutcome> + Send + 'b>> {
Box::pin(async move {
let work = self.work.lock().await.take();
let Some(work) = work else {
// Already consumed (e.g. a second wait after cancellation).
return ExecutionOutcome::Cancelled;
};
let outcome = tokio::select! {
biased;
_ = self.stop.cancelled() => ExecutionOutcome::Cancelled,
r = work => match r {
Ok(s) => ExecutionOutcome::Completed(s),
Err(e) => ExecutionOutcome::Failed(e.to_string()),
},
};
*self.state.lock().unwrap() = outcome.state();
outcome
})
}
fn stop<'b>(&'b self) -> Pin<Box<dyn Future<Output = ()> + Send + 'b>> {
Box::pin(async move { self.stop.cancel(); })
}
}
// ── drive_execution ───────────────────────────────────────────────────────────
/// Run a [`ToolExecution`] to completion while honouring a cancellation token.
///
/// When `cancel` fires we call `exec.stop()` once (tool-specific teardown); the
/// execution then resolves `wait` to `Cancelled`. Both methods take `&self`, so
/// the two concurrent borrows are shared and the borrow checker is happy.
pub async fn drive_execution(
exec: &dyn ToolExecution,
cancel: &CancellationToken,
) -> ExecutionOutcome {
let work = exec.wait();
tokio::pin!(work);
let mut stopped = false;
loop {
tokio::select! {
biased;
outcome = &mut work => return outcome,
_ = cancel.cancelled(), if !stopped => {
exec.stop().await;
stopped = true;
}
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Truncates a label to `max` chars, appending `…` if cut.
pub fn truncate_label(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let cut = max.saturating_sub(1);
let mut end = cut;
while !s.is_char_boundary(end) { end -= 1; }
format!("{}", &s[..end])
}
+59
View File
@@ -0,0 +1,59 @@
use std::sync::Arc;
use async_trait::async_trait;
// ── Record types (DB ↔ manager) ───────────────────────────────────────────────
/// Full model record, mirroring one row in `transcribe_models`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TranscribeModelRecord {
pub id: i64,
pub provider_id: i64,
pub model_id: String,
/// Display alias (also used as the transcriber `id()`).
pub name: String,
/// BCP-47 language hint, e.g. `"it"`. `None` → auto-detect.
pub language: Option<String>,
/// Lower number = tried first by `get()`.
pub priority: i32,
}
/// Remote model info returned by a provider's `list_transcribe_models()`.
#[derive(Debug, Clone, serde::Serialize)]
pub struct RemoteTranscribeModelInfo {
pub id: String,
pub name: String,
pub description: Option<String>,
/// BCP-47 language codes supported by this model (empty = unknown).
pub languages: Vec<String>,
}
/// Implemented by any provider that can convert audio bytes to text.
/// The `format` hint (e.g. `"ogg"`, `"mp3"`) is advisory.
#[async_trait]
pub trait Transcribe: Send + Sync {
/// A stable, unique identifier for this provider (e.g. `"whisper_local"`).
fn id(&self) -> &str;
async fn transcribe(&self, audio: Vec<u8>, format: &str) -> anyhow::Result<String>;
}
/// Resolves the currently active [`Transcribe`] provider.
///
/// Implemented by `TranscribeManager` in the main crate. Plugins store
/// `Arc<dyn TranscribeProvider>` so they can resolve the active transcriber
/// per-call without holding a reference to `AppState`.
#[async_trait]
pub trait TranscribeProvider: Send + Sync {
async fn get(&self) -> Option<Arc<dyn Transcribe>>;
}
/// Write-side of the transcribe manager: register and remove ephemeral providers.
///
/// Implemented by `TranscribeManager`. Plugins that provide their own STT
/// (e.g. `WhisperLocalPlugin`) use this to register themselves at start and
/// unregister at stop.
#[async_trait]
pub trait TranscribeRegistry: Send + Sync {
async fn register(&self, provider: Arc<dyn Transcribe>);
async fn unregister(&self, id: &str);
}
+88
View File
@@ -0,0 +1,88 @@
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
// ── Record types (DB ↔ manager) ───────────────────────────────────────────────
/// Full model record, mirroring one row in `tts_models`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TtsModelRecord {
pub id: i64,
pub provider_id: i64,
pub model_id: String,
/// Voice/speaker identifier, if the provider requires it separately from the model.
pub voice_id: Option<String>,
/// Display alias (also used as the synthesiser `id()`).
pub name: String,
pub description: Option<String>,
/// Default voice instructions (tone, speed, style).
pub instructions: Option<String>,
/// Audio container/codec requested from the provider (`response_format`):
/// `mp3`, `opus`, `aac`, `flac`, `wav`, `pcm`. `None` ⇒ provider default (`mp3`).
/// Some models only accept a specific value (e.g. Gemini TTS requires `pcm`).
pub response_format: Option<String>,
/// Lower number = tried first by `get()`.
pub priority: i32,
}
/// Remote model info returned by a provider's `list_tts_models()`.
#[derive(Debug, Clone, serde::Serialize)]
pub struct RemoteTtsModelInfo {
pub id: String,
pub name: String,
pub description: Option<String>,
/// BCP-47 language codes supported by this model (empty = unknown).
pub languages: Vec<String>,
/// Cost multiplier relative to the provider's base rate (1.0 = standard).
pub cost_factor: Option<f64>,
/// Usage instructions: supported tags, markup, etc. Shown in UI and passed
/// to the LLM when generating text destined for this synthesiser.
pub instructions: Option<String>,
}
/// Implemented by any provider that can convert text to audio bytes.
/// Returns raw audio bytes (MP3 expected unless the provider states otherwise).
#[async_trait]
pub trait TextToSpeech: Send + Sync {
/// A stable, unique identifier for this provider (e.g. `"openai_tts_alloy"`).
fn id(&self) -> &str;
/// Human-readable display name.
fn name(&self) -> &str;
/// Human-readable description (voice style, language, ideal use cases).
fn description(&self) -> Option<&str> { None }
/// Default synthesis instructions: voice style, tone, speed, and any
/// provider-specific text markup syntax (e.g. emotion tags).
/// Surfaced to the LLM via `TtsModelInfo` so it knows how to format input text.
/// Individual call-time instructions passed to `synthesize` take precedence.
fn instructions(&self) -> Option<&str> { None }
/// Audio format (container/codec) of the bytes returned by `synthesize`,
/// e.g. `mp3`, `opus`, `wav`, `pcm`. Consumers that require a specific
/// container (e.g. Telegram voice messages need Ogg/Opus) use this to decide
/// whether and how to transcode. Default `"mp3"`.
fn output_format(&self) -> &str { "mp3" }
/// Synthesise `text` to audio bytes.
/// `instructions` overrides the provider's default instructions for this call only.
async fn synthesize(&self, text: &str, instructions: Option<&str>) -> Result<Vec<u8>>;
}
/// Resolves the currently active [`TextToSpeech`] provider.
///
/// Implemented by `TtsManager` in the main crate. Plugins store
/// `Arc<dyn TtsProvider>` to resolve the active synthesiser per-call
/// without holding a reference to `AppState`.
#[async_trait]
pub trait TtsProvider: Send + Sync {
async fn get(&self) -> Option<Arc<dyn TextToSpeech>>;
}
/// Write-side of the TTS manager: register and remove ephemeral providers.
///
/// Implemented by `TtsManager`. Plugins that supply their own TTS engine
/// (e.g. a local Kokoro or Piper plugin) use this to register at start
/// and unregister at stop.
#[async_trait]
pub trait TtsRegistry: Send + Sync {
async fn register(&self, provider: Arc<dyn TextToSpeech>);
async fn unregister(&self, id: &str);
}