.
diff --git a/README.md b/README.md
index 61d17872d..dfcdb8cc1 100644
--- a/README.md
+++ b/README.md
@@ -1,106 +1,91 @@
-AlphaHuman
+AlphaHuman Mk1
- Your most productive co-worker
+ Your most productive co-worker
+ A user-friendly (GUI-first) AI agent. AlphaHuman uses the
+ Neocortex Mk1 model to co-ordinate memories &
+ realtime-data, cheaper and faster than other models.
-
+
+
+
+
+ About ·
+ vs OpenClaw ·
+ Download ·
+ Getting Started ·
+ Architecture ·
+ Changelog

- "The Tet. What a brilliant machine" Morgan Freeman as he reminisces about the Tet in the movie Oblivion
+ "The Tet. What a brilliant machine" — Morgan Freeman in Oblivion
-## What is AlphaHuman?
-
AlphaHuman is a personal AI assistant that helps you manage high-volume communication without reading everything yourself. It connects to your messaging platforms and productivity tools, understands conversations in context, and produces clear, actionable outputs you can use immediately.
-AlphaHuman is not a chatbot, browser extension, or cloud-only service. It is a native application that runs on your device, connects to your tools, and works only when you ask it to. Think of it as a second brain that sits across your communication and productivity stack.
+AlphaHuman is **not** a chatbot, browser extension, or cloud-only service. It is a **native application** that runs on your device, connects to your tools, and works only when you ask it to. Think of it as a second brain that sits across your communication and productivity stack.
-## What It Does
+## AlphaHuman vs OpenClaw
-- **Summarize conversations** -- Understand what happened without reading everything
-- **Surface signals** -- Decisions, action items, risks, and sentiment shifts extracted automatically
-- **Generate responses** -- Context-aware reply suggestions informed by conversation history
-- **Create workflows** -- Turn unstructured commitments into trackable actions
-- **Export intelligence** -- Push summaries, action items, and structured data to Notion, Google Sheets, and connected tools
-- **Run automations** -- Via a sandboxed skills engine that extends the platform without app updates
+AlphaHuman is designed to be simpler to deploy, cheaper to run, and more intelligent in how it uses models and memory.
+
+| | OpenClaw | AlphaHuman |
+| ---------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| **Runtime** | Node.js (TypeScript) | Tauri (Rust + React), native binary |
+| **Inference** | Single-tier or manual routing | **Custom two-tier**: task-routed (summarize/vibe/memory → cheap; complex/tools → premium) |
+| **Memory** | Often external (Pinecone, Lucid, etc.) or markdown-only | **Custom hybrid**: SQLite FTS5 + vector similarity, optional encryption, no external vector DB |
+| **Tunneling** | Third-party (ngrok, Cloudflare, Tailscale) or none | **Custom tunneling** — secure app-to-backend path without vendor lock-in |
+| **Cost** | Typically one premium model for everything | **Lower** — Tier 1 for most ops; Tier 2 only when needed |
+| **Intelligence** | General-purpose agent loop | **Smarter** — vibe detection, interest-based escalation, constitution-driven behavior, session-aware memory |
+| **Deployment** | Server/Node process, high memory footprint | Native desktop/mobile app, Rust socket manager, smaller footprint |
+
+> OpenClaw is a strong open-source agent framework. We chose to build a custom stack so we could own inference routing, memory, and tunneling end-to-end and optimize for cost and clarity.
+
+---
## Download
-> **Early Beta** -- AlphaHuman is under active development. Expect rough edges.
+> **Early Beta** — AlphaHuman is under active development. Expect rough edges.
-### macOS
+| Platform | Variant | Download |
+| ----------- | --------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| **macOS** | Apple Silicon (M1/M2/M3/M4) | [`.dmg` (aarch64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_aarch64.dmg) |
+| **macOS** | Intel | [`.dmg` (x64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x64.dmg) |
+| **Windows** | x64 | [`.msi`](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x64_en-US.msi) |
+| **Linux** | Debian / Ubuntu | [`.deb` (amd64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_amd64.deb) |
+| **Linux** | Fedora / RHEL | [`.rpm` (x86_64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x86_64.rpm) |
+| **Linux** | Universal | [`.AppImage`](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_amd64.AppImage) |
+| **Android** | — | Coming soon |
+| **iOS** | — | Coming soon |
-| Chip | Download |
-| --------------------------- | --------------------------------------------------------------------------------------------------------------- |
-| Apple Silicon (M1/M2/M3/M4) | [`.dmg` (aarch64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_aarch64.dmg) |
-| Intel | [`.dmg` (x64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x64.dmg) |
-
-### Windows
-
-| Architecture | Download |
-| ------------ | ------------------------------------------------------------------------------------------------------- |
-| x64 | [`.msi`](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x64_en-US.msi) |
-
-### Linux
-
-| Format | Download |
-| --------------- | ------------------------------------------------------------------------------------------------------------- |
-| Debian / Ubuntu | [`.deb` (amd64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_amd64.deb) |
-| Fedora / RHEL | [`.rpm` (x86_64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x86_64.rpm) |
-| Universal | [`.AppImage`](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_amd64.AppImage) |
-
-### Mobile
-
-- **Android** and **iOS** -- Coming soon
-
-Browse all releases: [github.com/alphahumanxyz/alphahuman/releases](https://github.com/alphahumanxyz/alphahuman/releases)
-
-## Skills
-
-AlphaHuman uses a pluggable **skills** architecture. Each skill connects to an external service, syncs relevant data locally, and exposes tools that you (or the AI) can use. Skills run in a sandboxed environment with their own database, storage, and permissions.
-
-| Skill | Status | Description |
-| --------------- | ----------- | ------------------------------------------------------------- |
-| Telegram | Available | Chats, messages, contacts, search, admin tools, AI summaries |
-| Notion | Available | Pages, databases, blocks, users, comments, search, local sync |
-| Gmail | In Progress | Email management, labels, search, send/receive with OAuth2 |
-| Google Calendar | In Progress | Calendars, events, scheduling with OAuth2 |
-| Google Drive | In Progress | Files, Sheets, Docs with OAuth2 |
-| Slack | In Progress | Messages, channels, real-time events |
-| Web3 Wallets | Planned | EVM wallet management, balance checks, network monitoring |
-
-## Privacy-First
-
-- **Zero retention** -- Message content is processed to produce output, then discarded
-- **OS-level credential storage** -- Desktop platforms use native keychains (macOS Keychain, Windows Credential Manager)
-- **No training on your data** -- Your data is never used for model improvement
-- **Request-only processing** -- Nothing happens without an explicit user action; no background scanning
-- **Sandboxed skills** -- Each skill runs in isolation with memory and resource limits
+Browse all releases: [github.com/alphahumanai/alphahuman/releases](https://github.com/alphahumanai/alphahuman/releases)
## Getting Started
-1. **Download** the installer for your platform from the [releases page](https://github.com/alphahumanxyz/alphahuman/releases/latest)
+1. **Download** the installer for your platform from the [releases page](https://github.com/alphahumanai/alphahuman/releases/latest)
2. **Install** the app (drag to Applications on macOS, or use your package manager on Linux)
-3. **Connect a source** -- follow the in-app onboarding to link Telegram, Notion, Gmail, or other services
-4. **Run your first request** -- ask the AI to summarize what you missed, extract action items, or surface key decisions
+3. **Connect a source** — follow the in-app onboarding to link Telegram, Notion, Gmail, or other services
+4. **Run your first request** — ask the AI to summarize what you missed, extract action items, or surface key decisions
+
+---
## Links
-- [Architecture Overview](./ARCHITECTURE.md) -- How AlphaHuman is built
-- [Changelog](./CHANGELOG.md) -- Release history
-- [Website](https://alphahuman.xyz) -- Learn more
+- [Architecture Overview](docs/ARCHITECTURE.md) — How AlphaHuman is built
+- [Changelog](CHANGELOG.md) — Release history
+- [Website](https://alphahuman.xyz) — Learn more
---
- Made with love by a bunch of Web3 nerds
+ Made with love in India 🇮🇳
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..56044d378
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,53 @@
+# Security Policy
+
+## Supported Versions
+
+We provide security updates for the following versions of AlphaHuman:
+
+| Version | Supported |
+| ------- | ------------------ |
+| Latest | :white_check_mark: |
+| Previous minor | :white_check_mark: |
+| Older | :x: |
+
+We recommend always running the [latest release](https://github.com/alphahumanxyz/alphahuman/releases/latest). AlphaHuman is in early beta; older versions may not receive patches.
+
+## Reporting a Vulnerability
+
+We take security seriously. If you believe you have found a security vulnerability, please report it responsibly.
+
+### How to Report
+
+1. **Do not** open a public GitHub issue for security vulnerabilities.
+2. Email the maintainers with a clear description of the issue, steps to reproduce, and impact. You can reach us via the contact details listed in the [AlphaHuman organization](https://github.com/alphahumanxyz) or repository.
+3. Include as much detail as possible (platform, version, configuration) so we can reproduce and triage quickly.
+
+### What to Expect
+
+- We will acknowledge your report as soon as possible (typically within 5 business days).
+- We will keep you updated on our assessment and any fix or mitigation.
+- We will credit you in our security advisories and release notes (unless you prefer to remain anonymous).
+
+### Scope
+
+We are especially interested in:
+
+- Authentication or authorization bypass
+- Data exfiltration or exposure (credentials, messages, user data)
+- Remote code execution (frontend, Tauri/Rust backend, or skills runtime)
+- Issues in dependency chain (npm, Cargo) that affect our build or runtime
+- Platform-specific issues (macOS, Windows, Linux, Android, iOS) that compromise user data or device security
+
+Out-of-scope for this process: general bugs, feature requests, and issues in third-party services we integrate with (e.g., Telegram, Notion) unless they are specific to how AlphaHuman uses them.
+
+### Safe Harbor
+
+We support safe harbor for security researchers who report in good faith. We will not pursue legal action or involve law enforcement against you for discovering or reporting vulnerabilities in accordance with this policy.
+
+## Security Practices
+
+- **Credentials**: Desktop uses OS-level credential storage (e.g., macOS Keychain, Windows Credential Manager). We do not store secrets in plain text.
+- **Data**: Message content is processed on request and not retained for training or long-term storage.
+- **Skills**: Skills run in a sandboxed environment with defined boundaries; we review skill behavior and dependencies where possible.
+
+Thank you for helping keep AlphaHuman and its users safe.
diff --git a/ARCHITECTURE.md b/docs/ARCHITECTURE.md
similarity index 100%
rename from ARCHITECTURE.md
rename to docs/ARCHITECTURE.md
diff --git a/MVP.md b/docs/MVP.md
similarity index 100%
rename from MVP.md
rename to docs/MVP.md
diff --git a/teams-api-reference.md b/docs/teams-api-reference.md
similarity index 100%
rename from teams-api-reference.md
rename to docs/teams-api-reference.md
diff --git a/examples/skills/hello-python/skill.json b/examples/skills/hello-python/skill.json
deleted file mode 100644
index 30334809b..000000000
--- a/examples/skills/hello-python/skill.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "id": "hello-python",
- "name": "Hello Python",
- "description": "Example runtime skill demonstrating the Python subprocess protocol",
- "version": "0.1.0",
- "tier": "runtime",
- "author": "AlphaHuman",
- "runtime": {
- "command": "python3",
- "args": ["skill.py"]
- }
-}
diff --git a/examples/skills/hello-python/skill.py b/examples/skills/hello-python/skill.py
deleted file mode 100644
index 500c9a279..000000000
--- a/examples/skills/hello-python/skill.py
+++ /dev/null
@@ -1,187 +0,0 @@
-#!/usr/bin/env python3
-"""
-Hello Python — Example Runtime Skill
-
-Demonstrates the JSON-RPC 2.0 protocol for AlphaHuman runtime skills.
-The host communicates over stdin/stdout with newline-delimited JSON.
-
-Protocol:
- Host → Skill: JSON-RPC requests on stdin (one JSON object per line)
- Skill → Host: JSON-RPC responses on stdout (one JSON object per line)
- Skill logging: stderr (forwarded to host debug log)
-
-Lifecycle:
- 1. Host spawns this process
- 2. Host sends skill/load { manifest }
- 3. Host sends tools/list (skill replies with tool definitions)
- 4. Host sends skill/activate
- 5. Host sends tools/call as needed
- 6. Host sends skill/deactivate then skill/shutdown
-"""
-
-import json
-import sys
-
-
-def send(obj: dict) -> None:
- """Write a JSON-RPC message to stdout."""
- line = json.dumps(obj, separators=(",", ":"))
- sys.stdout.write(line + "\n")
- sys.stdout.flush()
-
-
-def send_result(req_id, result):
- """Send a successful JSON-RPC response."""
- send({"jsonrpc": "2.0", "id": req_id, "result": result})
-
-
-def send_error(req_id, code: int, message: str):
- """Send a JSON-RPC error response."""
- send({"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}})
-
-
-def log(message: str) -> None:
- """Log to stderr (host captures this for debugging)."""
- print(f"[hello-python] {message}", file=sys.stderr, flush=True)
-
-
-# ---------------------------------------------------------------------------
-# Tool definitions
-# ---------------------------------------------------------------------------
-
-TOOLS = [
- {
- "name": "hello_world",
- "description": "Returns a friendly greeting. Use this to verify the Python runtime skill is working.",
- "inputSchema": {
- "type": "object",
- "properties": {
- "name": {
- "type": "string",
- "description": "Name to greet (default: 'World')",
- }
- },
- },
- "tier": "state_only",
- "readOnly": True,
- }
-]
-
-
-def handle_tool_call(name: str, arguments: dict) -> dict:
- """Execute a tool and return an MCPToolResult."""
- if name == "hello_world":
- who = arguments.get("name", "World")
- return {
- "content": [{"type": "text", "text": f"Hello, {who}! Greetings from a Python runtime skill."}],
- }
-
- return {
- "content": [{"type": "text", "text": f"Unknown tool: {name}"}],
- "isError": True,
- }
-
-
-# ---------------------------------------------------------------------------
-# Request dispatcher
-# ---------------------------------------------------------------------------
-
-def handle_request(method: str, params, req_id):
- """Dispatch a JSON-RPC request to the appropriate handler."""
-
- if method == "skill/load":
- manifest = params.get("manifest", {}) if params else {}
- log(f"Loaded — manifest id: {manifest.get('id', '?')}")
- send_result(req_id, {"ok": True})
-
- elif method == "skill/activate":
- log("Activated")
- send_result(req_id, {"ok": True})
-
- elif method == "skill/deactivate":
- log("Deactivated")
- send_result(req_id, {"ok": True})
-
- elif method == "skill/unload":
- log("Unloaded")
- send_result(req_id, {"ok": True})
-
- elif method == "skill/shutdown":
- log("Shutting down")
- send_result(req_id, {"ok": True})
- sys.exit(0)
-
- elif method == "skill/sessionStart":
- session_id = params.get("sessionId", "?") if params else "?"
- log(f"Session started: {session_id}")
- send_result(req_id, {"ok": True})
-
- elif method == "skill/sessionEnd":
- session_id = params.get("sessionId", "?") if params else "?"
- log(f"Session ended: {session_id}")
- send_result(req_id, {"ok": True})
-
- elif method == "skill/beforeMessage":
- # Return None to leave message unchanged, or return transformed message
- send_result(req_id, {})
-
- elif method == "skill/afterResponse":
- # Return None to leave response unchanged
- send_result(req_id, {})
-
- elif method == "skill/tick":
- # Health check / periodic tick
- send_result(req_id, {"ok": True})
-
- elif method == "tools/list":
- send_result(req_id, {"tools": TOOLS})
-
- elif method == "tools/call":
- name = params.get("name", "") if params else ""
- arguments = params.get("arguments", {}) if params else {}
- result = handle_tool_call(name, arguments)
- send_result(req_id, result)
-
- else:
- send_error(req_id, -32601, f"Method not found: {method}")
-
-
-# ---------------------------------------------------------------------------
-# Main loop
-# ---------------------------------------------------------------------------
-
-def main():
- log("Starting hello-python skill")
-
- for line in sys.stdin:
- line = line.strip()
- if not line:
- continue
-
- try:
- msg = json.loads(line)
- except json.JSONDecodeError as e:
- log(f"Invalid JSON: {e}")
- continue
-
- # Must be JSON-RPC 2.0
- if msg.get("jsonrpc") != "2.0":
- log(f"Not a JSON-RPC 2.0 message: {line[:100]}")
- continue
-
- method = msg.get("method")
- params = msg.get("params")
- req_id = msg.get("id")
-
- if method and req_id is not None:
- # Request — needs a response
- handle_request(method, params, req_id)
- elif method:
- # Notification — no response needed
- log(f"Notification: {method}")
- else:
- log(f"Unrecognized message: {line[:100]}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/logo.png b/public/logo.png
similarity index 100%
rename from logo.png
rename to public/logo.png
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index d904c577e..6cd3c8413 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -8,25 +8,61 @@ version = "0.30.0"
dependencies = [
"aes-gcm",
"android_logger",
+ "anyhow",
"argon2",
+ "async-imap",
+ "async-trait",
+ "axum",
"base64 0.22.1",
+ "chacha20poly1305",
"chrono",
+ "chrono-tz",
+ "console",
"cron",
+ "dialoguer",
+ "directories",
"dirs 5.0.1",
"env_logger",
+ "fantoccini",
"futures",
"futures-util",
+ "glob",
+ "hex",
+ "hmac",
+ "hostname",
+ "http-body-util",
"keyring",
+ "landlock",
+ "lettre",
"log",
+ "mail-parser",
+ "matrix-sdk",
+ "nusb 0.2.2",
"once_cell",
+ "opentelemetry",
+ "opentelemetry-otlp",
+ "opentelemetry_sdk",
"parking_lot",
- "rand 0.8.5",
+ "pdf-extract",
+ "postgres",
+ "probe-rs",
+ "prometheus",
+ "prost 0.14.3",
+ "rand 0.9.2",
+ "regex",
"reqwest",
+ "ring",
+ "rppal",
"rquickjs",
"rusqlite",
+ "rustls",
+ "rustls-pki-types",
+ "schemars 1.2.0",
"serde",
+ "serde-big-array",
"serde_json",
"sha2",
+ "shellexpand",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
@@ -35,9 +71,41 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-os",
"tdlib-rs",
+ "tempfile",
+ "thiserror 2.0.18",
"tokio",
- "tokio-tungstenite",
+ "tokio-rustls",
+ "tokio-serial",
+ "tokio-stream",
+ "tokio-tungstenite 0.24.0",
+ "tokio-util",
+ "toml 1.0.3+spec-1.1.0",
+ "tower",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+ "urlencoding",
"uuid",
+ "wa-rs",
+ "wa-rs-binary",
+ "wa-rs-core",
+ "wa-rs-proto",
+ "wa-rs-tokio-transport",
+ "wa-rs-ureq-http",
+ "webpki-roots 1.0.6",
+]
+
+[[package]]
+name = "accessory"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28e416a3ab45838bac2ab2d81b1088d738d7b2d2c5272a54d39366565a29bd80"
+dependencies = [
+ "macroific",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
]
[[package]]
@@ -46,6 +114,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "adobe-cmap-parser"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3"
+dependencies = [
+ "pom",
+]
+
[[package]]
name = "aead"
version = "0.5.2"
@@ -205,6 +282,35 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+[[package]]
+name = "anymap2"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
+
+[[package]]
+name = "aquamarine"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2"
+dependencies = [
+ "include_dir",
+ "itertools 0.10.5",
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "ar_archive_writer"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
+dependencies = [
+ "object 0.37.3",
+]
+
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -214,6 +320,12 @@ dependencies = [
"derive_arbitrary",
]
+[[package]]
+name = "archery"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d"
+
[[package]]
name = "argon2"
version = "0.5.3"
@@ -226,18 +338,56 @@ dependencies = [
"password-hash",
]
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "as_variant"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a"
+
+[[package]]
+name = "assign"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002"
+
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
- "event-listener",
+ "event-listener 5.4.1",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
+[[package]]
+name = "async-channel"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
+dependencies = [
+ "concurrent-queue",
+ "event-listener 2.5.3",
+ "futures-core",
+]
+
[[package]]
name = "async-channel"
version = "2.5.0"
@@ -250,6 +400,18 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "async-compression"
+version = "0.4.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d67d43201f4d20c78bcda740c142ca52482d81da80681533d33bf3f0596c8e2"
+dependencies = [
+ "compression-codecs",
+ "compression-core",
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "async-executor"
version = "1.13.3"
@@ -264,6 +426,29 @@ dependencies = [
"slab",
]
+[[package]]
+name = "async-imap"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66"
+dependencies = [
+ "async-channel 2.5.0",
+ "async-compression",
+ "base64 0.22.1",
+ "bytes",
+ "chrono",
+ "futures",
+ "imap-proto",
+ "log",
+ "nom 7.1.3",
+ "pin-project",
+ "pin-utils",
+ "self_cell",
+ "stop-token",
+ "thiserror 1.0.69",
+ "tokio",
+]
+
[[package]]
name = "async-io"
version = "2.6.0"
@@ -277,7 +462,7 @@ dependencies = [
"futures-lite",
"parking",
"polling",
- "rustix",
+ "rustix 1.1.3",
"slab",
"windows-sys 0.61.2",
]
@@ -288,7 +473,7 @@ version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
- "event-listener",
+ "event-listener 5.4.1",
"event-listener-strategy",
"pin-project-lite",
]
@@ -299,16 +484,16 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
- "async-channel",
+ "async-channel 2.5.0",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
- "event-listener",
+ "event-listener 5.4.1",
"futures-lite",
- "rustix",
+ "rustix 1.1.3",
]
[[package]]
@@ -334,12 +519,34 @@ dependencies = [
"cfg-if",
"futures-core",
"futures-io",
- "rustix",
+ "rustix 1.1.3",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "async-task"
version = "4.7.1"
@@ -403,6 +610,104 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "aws-lc-rs"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
+dependencies = [
+ "aws-lc-sys",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.37.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+]
+
+[[package]]
+name = "axum"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
+dependencies = [
+ "axum-core",
+ "axum-macros",
+ "base64 0.22.1",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http 1.4.0",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sha1",
+ "sync_wrapper",
+ "tokio",
+ "tokio-tungstenite 0.28.0",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http 1.4.0",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-macros"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "backon"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
+dependencies = [
+ "fastrand",
+ "gloo-timers",
+ "tokio",
+]
+
[[package]]
name = "base64"
version = "0.21.7"
@@ -421,6 +726,46 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+[[package]]
+name = "bincode"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
+dependencies = [
+ "bincode_derive",
+ "serde",
+ "unty",
+]
+
+[[package]]
+name = "bincode_derive"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
+dependencies = [
+ "virtue",
+]
+
+[[package]]
+name = "bitfield"
+version = "0.19.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419"
+dependencies = [
+ "bitfield-macros",
+]
+
+[[package]]
+name = "bitfield-macros"
+version = "0.19.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -436,6 +781,24 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "bitmaps"
+version = "3.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
+
+[[package]]
+name = "bitvec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
[[package]]
name = "blake2"
version = "0.10.6"
@@ -445,6 +808,20 @@ dependencies = [
"digest",
]
+[[package]]
+name = "blake3"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq 0.4.2",
+ "cpufeatures",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -454,6 +831,15 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "block2"
version = "0.6.2"
@@ -469,7 +855,7 @@ version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
- "async-channel",
+ "async-channel 2.5.0",
"async-task",
"futures-io",
"futures-lite",
@@ -497,17 +883,46 @@ dependencies = [
"alloc-stdlib",
]
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
+[[package]]
+name = "bytecount"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
+
[[package]]
name = "bytemuck"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
[[package]]
name = "byteorder"
@@ -524,6 +939,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "bytesize"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
+
[[package]]
name = "bzip2"
version = "0.5.2"
@@ -610,6 +1031,15 @@ dependencies = [
"toml 0.9.11+spec-1.1.0",
]
+[[package]]
+name = "cbc"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "cc"
version = "1.2.54"
@@ -639,6 +1069,12 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "cff-parser"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d"
+
[[package]]
name = "cfg-expr"
version = "0.15.8"
@@ -661,6 +1097,30 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
[[package]]
name = "chrono"
version = "0.4.43"
@@ -675,6 +1135,26 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "chrono-tz"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
+dependencies = [
+ "chrono",
+ "phf 0.12.1",
+]
+
+[[package]]
+name = "chumsky"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
+dependencies = [
+ "hashbrown 0.14.5",
+ "stacker",
+]
+
[[package]]
name = "cipher"
version = "0.4.4"
@@ -683,6 +1163,26 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
+ "zeroize",
+]
+
+[[package]]
+name = "cmake"
+version = "0.1.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "cobs"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ef0193218d365c251b5b9297f9911a908a8ddd2ebd3a36cc5d0ef0f63aee9e"
+dependencies = [
+ "heapless",
+ "thiserror 2.0.18",
]
[[package]]
@@ -701,6 +1201,23 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "compression-codecs"
+version = "0.4.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
+dependencies = [
+ "compression-core",
+ "flate2",
+ "memchr",
+]
+
+[[package]]
+name = "compression-core"
+version = "0.4.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
+
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -710,6 +1227,25 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "console"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "once_cell",
+ "unicode-width 0.2.2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
[[package]]
name = "const-random"
version = "0.1.18"
@@ -730,12 +1266,27 @@ dependencies = [
"tiny-keccak",
]
+[[package]]
+name = "const_panic"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652"
+dependencies = [
+ "typewit",
+]
+
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -751,16 +1302,45 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "cookie"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb"
+dependencies = [
+ "time",
+ "version_check",
+]
+
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
+ "percent-encoding",
"time",
"version_check",
]
+[[package]]
+name = "cookie_store"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
+dependencies = [
+ "cookie 0.18.1",
+ "document-features",
+ "idna",
+ "indexmap 2.13.0",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "time",
+ "url",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -811,6 +1391,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core_maths"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
+dependencies = [
+ "libm",
+]
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -851,7 +1440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
dependencies = [
"chrono",
- "nom",
+ "nom 7.1.3",
"once_cell",
]
@@ -864,6 +1453,15 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -914,6 +1512,27 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "ctor"
version = "0.2.9"
@@ -933,14 +1552,66 @@ dependencies = [
"cipher",
]
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "serde",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
- "darling_core",
- "darling_macro",
+ "darling_core 0.21.3",
+ "darling_macro 0.21.3",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.114",
]
[[package]]
@@ -957,29 +1628,149 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core 0.20.11",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
- "darling_core",
+ "darling_core 0.21.3",
"quote",
"syn 2.0.114",
]
+[[package]]
+name = "dashmap"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
+[[package]]
+name = "date_header"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c03c416ed1a30fbb027ef484ba6ab6f80e1eada675e1a2b92fd673c045a1f1d"
+
+[[package]]
+name = "deadpool"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
+dependencies = [
+ "deadpool-runtime",
+ "lazy_static",
+ "num_cpus",
+ "tokio",
+]
+
+[[package]]
+name = "deadpool-runtime"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
+dependencies = [
+ "tokio",
+]
+
+[[package]]
+name = "deadpool-sync"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524bc3df0d57e98ecd022e21ba31166c2625e7d3e5bcc4510efaeeab4abcab04"
+dependencies = [
+ "deadpool-runtime",
+]
+
+[[package]]
+name = "decancer"
+version = "3.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"
+dependencies = [
+ "lazy_static",
+ "regex",
+]
+
[[package]]
name = "deflate64"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
+[[package]]
+name = "deku"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9711031e209dc1306d66985363b4397d4c7b911597580340b93c9729b55f6eb"
+dependencies = [
+ "bitvec",
+ "deku_derive",
+ "no_std_io2",
+ "rustversion",
+]
+
+[[package]]
+name = "deku_derive"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58cb0719583cbe4e81fb40434ace2f0d22ccc3e39a74bb3796c22b451b4f139d"
+dependencies = [
+ "darling 0.20.11",
+ "proc-macro-crate 3.4.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "delegate-display"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9926686c832494164c33a36bf65118f4bd6e704000b58c94681bf62e9ad67a74"
+dependencies = [
+ "impartial-ord",
+ "itoa",
+ "macroific",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
[[package]]
name = "deranged"
version = "0.5.5"
@@ -990,6 +1781,17 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "derivative"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -1014,6 +1816,61 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "derive_more"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
+dependencies = [
+ "derive_more-impl 1.0.0",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl 2.1.1",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.114",
+ "unicode-xid",
+]
+
+[[package]]
+name = "dialoguer"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
+dependencies = [
+ "console",
+ "fuzzy-matcher",
+ "shell-words",
+ "tempfile",
+ "zeroize",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -1025,6 +1882,15 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "directories"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
+dependencies = [
+ "dirs-sys 0.5.0",
+]
+
[[package]]
name = "dirs"
version = "4.0.0"
@@ -1146,6 +2012,35 @@ dependencies = [
"const-random",
]
+[[package]]
+name = "docsplay"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4"
+dependencies = [
+ "docsplay-macros",
+]
+
+[[package]]
+name = "docsplay-macros"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
[[package]]
name = "dpi"
version = "0.1.2"
@@ -1182,6 +2077,63 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+[[package]]
+name = "ecb"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "serde",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "rand_core 0.6.4",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "email-encoding"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
+dependencies = [
+ "base64 0.22.1",
+ "memchr",
+]
+
+[[package]]
+name = "email_address"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
+
[[package]]
name = "embed-resource"
version = "3.0.6"
@@ -1202,6 +2154,12 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1288,6 +2246,63 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "esp-idf-part"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5ebc2381d030e4e89183554c3fcd4ad44dc5ab34961ab09e09b4adbe4f94b61"
+dependencies = [
+ "bitflags 2.10.0",
+ "csv",
+ "deku",
+ "md-5",
+ "parse_int",
+ "regex",
+ "serde",
+ "serde_plain",
+ "strum",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "espflash"
+version = "4.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46f05d15cb2479a3cbbbe684b9f0831b2ae036d9faefd1eb08f21267275862f9"
+dependencies = [
+ "base64 0.22.1",
+ "bitflags 2.10.0",
+ "bytemuck",
+ "esp-idf-part",
+ "flate2",
+ "gimli",
+ "libc",
+ "log",
+ "md-5",
+ "miette",
+ "nix 0.30.1",
+ "object 0.38.1",
+ "serde",
+ "sha2",
+ "strum",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "euclid"
+version = "0.20.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "event-listener"
+version = "2.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
+
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -1305,10 +2320,42 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
- "event-listener",
+ "event-listener 5.4.1",
"pin-project-lite",
]
+[[package]]
+name = "eyeball"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d93bd0ebf93d61d6332d3c09a96e97975968a44e19a64c947bde06e6baff383f"
+dependencies = [
+ "futures-core",
+ "readlock",
+ "readlock-tokio",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "eyeball-im"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4790c03df183c2b46665c1a58118c04fd3e3976ec2fe16a0aa00e00c9eea7754"
+dependencies = [
+ "futures-core",
+ "imbl",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "fallible-iterator"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
+
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -1321,6 +2368,41 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+[[package]]
+name = "fancy_constructor"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28a27643a5d05f3a22f5afd6e0d0e6e354f92d37907006f97b84b9cb79082198"
+dependencies = [
+ "macroific",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "fantoccini"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d0086bcd59795408c87a04f94b5a8bd62cba2856cfe656c7e6439061d95b760"
+dependencies = [
+ "base64 0.22.1",
+ "cookie 0.18.1",
+ "futures-util",
+ "http 1.4.0",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "mime",
+ "serde",
+ "serde_json",
+ "time",
+ "tokio",
+ "url",
+ "webdriver",
+]
+
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1336,6 +2418,12 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1352,6 +2440,12 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
[[package]]
name = "flate2"
version = "1.1.8"
@@ -1360,6 +2454,7 @@ checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
dependencies = [
"crc32fast",
"miniz_oxide",
+ "zlib-rs",
]
[[package]]
@@ -1368,6 +2463,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1425,6 +2526,18 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
[[package]]
name = "futf"
version = "0.1.5"
@@ -1537,6 +2650,15 @@ dependencies = [
"slab",
]
+[[package]]
+name = "fuzzy-matcher"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
+dependencies = [
+ "thread_local",
+]
+
[[package]]
name = "fxhash"
version = "0.2.1"
@@ -1661,7 +2783,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
- "rustix",
+ "rustix 1.1.3",
"windows-link 0.2.1",
]
@@ -1713,6 +2835,17 @@ dependencies = [
"polyval",
]
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+dependencies = [
+ "fallible-iterator 0.3.0",
+ "indexmap 2.13.0",
+ "stable_deref_trait",
+]
+
[[package]]
name = "gio"
version = "0.18.4"
@@ -1798,6 +2931,31 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+[[package]]
+name = "gloo-timers"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gloo-utils"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
+dependencies = [
+ "js-sys",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -1809,6 +2967,18 @@ dependencies = [
"system-deps",
]
+[[package]]
+name = "growable-bloom-filter"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d174ccb4ba660d431329e7f0797870d0a4281e36353ec4b4a3c5eab6c2cfb6f1"
+dependencies = [
+ "serde",
+ "serde_bytes",
+ "serde_derive",
+ "xxhash-rust",
+]
+
[[package]]
name = "gtk"
version = "0.18.2"
@@ -1872,7 +3042,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
- "http",
+ "http 1.4.0",
"indexmap 2.13.0",
"slab",
"tokio",
@@ -1880,6 +3050,15 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "hash32"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
+dependencies = [
+ "byteorder",
+]
+
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1893,6 +3072,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
+ "allocator-api2",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
]
[[package]]
@@ -1903,16 +3092,61 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.2.0",
+]
+
+[[package]]
+name = "hashify"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
]
[[package]]
name = "hashlink"
-version = "0.9.1"
+version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
- "hashbrown 0.14.5",
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "headers"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "headers-core",
+ "http 1.4.0",
+ "httpdate",
+ "mime",
+ "sha1",
+]
+
+[[package]]
+name = "headers-core"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
+dependencies = [
+ "http 1.4.0",
+]
+
+[[package]]
+name = "heapless"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
+dependencies = [
+ "hash32",
+ "stable_deref_trait",
]
[[package]]
@@ -1927,6 +3161,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+
[[package]]
name = "hermit-abi"
version = "0.5.2"
@@ -1939,6 +3179,30 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "hidapi"
+version = "2.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "565dd4c730b8f8b2c0fb36df6be12e5470ae10895ddcc4e9dcfbfb495de202b0"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "nix 0.27.1",
+ "pkg-config",
+ "udev",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
[[package]]
name = "hmac"
version = "0.12.1"
@@ -1948,6 +3212,17 @@ dependencies = [
"digest",
]
+[[package]]
+name = "hostname"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "windows-link 0.2.1",
+]
+
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -1956,8 +3231,30 @@ checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
dependencies = [
"log",
"mac",
- "markup5ever",
- "match_token",
+ "markup5ever 0.14.1",
+ "match_token 0.1.0",
+]
+
+[[package]]
+name = "html5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
+dependencies = [
+ "log",
+ "markup5ever 0.35.0",
+ "match_token 0.35.0",
+]
+
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
]
[[package]]
@@ -1970,6 +3267,15 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "http-auth"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "http-body"
version = "1.0.1"
@@ -1977,7 +3283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.4.0",
]
[[package]]
@@ -1988,7 +3294,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
- "http",
+ "http 1.4.0",
"http-body",
"pin-project-lite",
]
@@ -1999,6 +3305,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
[[package]]
name = "hyper"
version = "1.8.1"
@@ -2010,9 +3322,10 @@ dependencies = [
"futures-channel",
"futures-core",
"h2",
- "http",
+ "http 1.4.0",
"http-body",
"httparse",
+ "httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -2027,15 +3340,17 @@ version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
- "http",
+ "http 1.4.0",
"hyper",
"hyper-util",
+ "log",
"rustls",
+ "rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
- "webpki-roots 1.0.5",
+ "webpki-roots 1.0.6",
]
[[package]]
@@ -2065,7 +3380,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
"hyper",
"ipnet",
@@ -2114,6 +3429,18 @@ dependencies = [
"png",
]
+[[package]]
+name = "icu_collections"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
+dependencies = [
+ "displaydoc",
+ "yoke 0.7.5",
+ "zerofrom",
+ "zerovec 0.10.4",
+]
+
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -2122,9 +3449,9 @@ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [
"displaydoc",
"potential_utf",
- "yoke",
+ "yoke 0.8.1",
"zerofrom",
- "zerovec",
+ "zerovec 0.11.5",
]
[[package]]
@@ -2134,10 +3461,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [
"displaydoc",
- "litemap",
- "tinystr",
- "writeable",
- "zerovec",
+ "litemap 0.8.1",
+ "tinystr 0.8.2",
+ "writeable 0.6.2",
+ "zerovec 0.11.5",
+]
+
+[[package]]
+name = "icu_locid"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
+dependencies = [
+ "displaydoc",
+ "litemap 0.7.5",
+ "tinystr 0.7.6",
+ "writeable 0.5.5",
]
[[package]]
@@ -2146,12 +3485,12 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
dependencies = [
- "icu_collections",
+ "icu_collections 2.1.1",
"icu_normalizer_data",
"icu_properties",
- "icu_provider",
+ "icu_provider 2.1.1",
"smallvec",
- "zerovec",
+ "zerovec 0.11.5",
]
[[package]]
@@ -2166,12 +3505,12 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
dependencies = [
- "icu_collections",
+ "icu_collections 2.1.1",
"icu_locale_core",
"icu_properties_data",
- "icu_provider",
+ "icu_provider 2.1.1",
"zerotrie",
- "zerovec",
+ "zerovec 0.11.5",
]
[[package]]
@@ -2180,6 +3519,23 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+[[package]]
+name = "icu_provider"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
+dependencies = [
+ "displaydoc",
+ "icu_locid",
+ "icu_provider_macros",
+ "stable_deref_trait",
+ "tinystr 0.7.6",
+ "writeable 0.5.5",
+ "yoke 0.7.5",
+ "zerofrom",
+ "zerovec 0.10.4",
+]
+
[[package]]
name = "icu_provider"
version = "2.1.1"
@@ -2188,13 +3544,46 @@ checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
dependencies = [
"displaydoc",
"icu_locale_core",
- "writeable",
- "yoke",
+ "writeable 0.6.2",
+ "yoke 0.8.1",
"zerofrom",
"zerotrie",
- "zerovec",
+ "zerovec 0.11.5",
]
+[[package]]
+name = "icu_provider_macros"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "icu_segmenter"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a717725612346ffc2d7b42c94b820db6908048f39434504cb130e8b46256b0de"
+dependencies = [
+ "core_maths",
+ "displaydoc",
+ "icu_collections 1.5.0",
+ "icu_locid",
+ "icu_provider 1.5.0",
+ "icu_segmenter_data",
+ "utf8_iter",
+ "zerovec 0.10.4",
+]
+
+[[package]]
+name = "icu_segmenter_data"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1e52775179941363cc594e49ce99284d13d6948928d8e72c755f55e98caa1eb"
+
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -2222,6 +3611,75 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "ihex"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "365a784774bb381e8c19edb91190a90d7f2625e057b55de2bc0f6b57bc779ff2"
+
+[[package]]
+name = "imap-proto"
+version = "0.16.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba1f9b30846c3d04371159ef3a0413ce7c1ae0a8c619cd255c60b3d902553f22"
+dependencies = [
+ "nom 7.1.3",
+]
+
+[[package]]
+name = "imbl"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fade8ae6828627ad1fa094a891eccfb25150b383047190a3648d66d06186501"
+dependencies = [
+ "archery",
+ "bitmaps",
+ "imbl-sized-chunks",
+ "rand_core 0.9.5",
+ "rand_xoshiro",
+ "serde",
+ "version_check",
+]
+
+[[package]]
+name = "imbl-sized-chunks"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d"
+dependencies = [
+ "bitmaps",
+]
+
+[[package]]
+name = "impartial-ord"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ab604ee7085efba6efc65e4ebca0e9533e3aff6cb501d7d77b211e3a781c6d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "include_dir"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
+dependencies = [
+ "include_dir_macros",
+]
+
+[[package]]
+name = "include_dir_macros"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2260,9 +3718,41 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
+ "block-padding",
"generic-array",
]
+[[package]]
+name = "io-kit-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
+dependencies = [
+ "core-foundation-sys",
+ "mach2 0.4.3",
+]
+
+[[package]]
+name = "io-kit-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06d3a048d09fbb6597dbf7c69f40d14df4a49487db1487191618c893fc3b1c26"
+dependencies = [
+ "core-foundation-sys",
+ "mach2 0.5.0",
+]
+
+[[package]]
+name = "io-lifetimes"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
+dependencies = [
+ "hermit-abi 0.3.9",
+ "libc",
+ "windows-sys 0.48.0",
+]
+
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2304,6 +3794,24 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itoa"
version = "1.0.17"
@@ -2333,6 +3841,15 @@ dependencies = [
"system-deps",
]
+[[package]]
+name = "jep106"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a1354c92c91fd5595fd4cc46694b6914749cc90ea437246549c26b6ff0ec6d1"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "jiff"
version = "0.2.18"
@@ -2399,6 +3916,24 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "js_int"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d937f95470b270ce8b8950207715d71aa8e153c0d44c6684d59397ed4949160a"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "js_option"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7dd3e281add16813cf673bf74a32249b0aa0d1c8117519a17b3ada5e8552b3c"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "json-patch"
version = "3.0.1"
@@ -2442,6 +3977,26 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "konst"
+version = "0.3.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4381b9b00c55f251f2ebe9473aef7c117e96828def1a7cb3bd3f0f903c6894e9"
+dependencies = [
+ "const_panic",
+ "konst_kernel",
+ "typewit",
+]
+
+[[package]]
+name = "konst_kernel"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4b1eb7788f3824c629b1116a7a9060d6e898c358ebff59070093d51103dcc3c"
+dependencies = [
+ "typewit",
+]
+
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -2449,17 +4004,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
dependencies = [
"cssparser",
- "html5ever",
+ "html5ever 0.29.1",
"indexmap 2.13.0",
"selectors",
]
+[[package]]
+name = "landlock"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088"
+dependencies = [
+ "enumflags2",
+ "libc",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "language-tags"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
+
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+[[package]]
+name = "lettre"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
+dependencies = [
+ "base64 0.22.1",
+ "chumsky",
+ "email-encoding",
+ "email_address",
+ "fastrand",
+ "httpdate",
+ "idna",
+ "mime",
+ "nom 8.0.0",
+ "percent-encoding",
+ "quoted_printable",
+ "rustls",
+ "socket2",
+ "tokio",
+ "url",
+ "webpki-roots 1.0.6",
+]
+
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -2500,6 +4096,12 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "libredox"
version = "0.1.12"
@@ -2512,27 +4114,55 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
-version = "0.28.0"
+version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
+[[package]]
+name = "libudev-sys"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+[[package]]
+name = "litemap"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"
+
[[package]]
name = "litemap"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2548,6 +4178,34 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+[[package]]
+name = "lopdf"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289"
+dependencies = [
+ "aes",
+ "bitflags 2.10.0",
+ "cbc",
+ "ecb",
+ "encoding_rs",
+ "flate2",
+ "getrandom 0.3.4",
+ "indexmap 2.13.0",
+ "itoa",
+ "log",
+ "md-5",
+ "nom 8.0.0",
+ "nom_locate",
+ "rand 0.9.2",
+ "rangemap",
+ "sha2",
+ "stringprep",
+ "thiserror 2.0.18",
+ "ttf-parser",
+ "weezl",
+]
+
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -2593,6 +4251,87 @@ dependencies = [
"time",
]
+[[package]]
+name = "mach2"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "mach2"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "macroific"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"
+dependencies = [
+ "macroific_attr_parse",
+ "macroific_core",
+ "macroific_macro",
+]
+
+[[package]]
+name = "macroific_attr_parse"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sealed",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "macroific_core"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sealed",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "macroific_macro"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"
+dependencies = [
+ "macroific_attr_parse",
+ "macroific_core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "mail-parser"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f82a3d6522697593ba4c683e0a6ee5a40fee93bc1a525e3cc6eeb3da11fd8897"
+dependencies = [
+ "hashify",
+]
+
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2607,6 +4346,17 @@ dependencies = [
"tendril",
]
+[[package]]
+name = "markup5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
[[package]]
name = "match_token"
version = "0.1.0"
@@ -2618,12 +4368,346 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "match_token"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
[[package]]
name = "matches"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "matrix-pickle"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c34e6db65145740459f2ca56623b40cd4e6000ffae2a7d91515fa82aa935dbf"
+dependencies = [
+ "matrix-pickle-derive",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "matrix-pickle-derive"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a962fc9981f823f6555416dcb2ae9ae67ca412d767ee21ecab5150113ee6285b"
+dependencies = [
+ "proc-macro-crate 3.4.0",
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "matrix-sdk"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b33f9bc45edd7f8e25161521fdd30654da5c55e6749be6afa1aa9d6cf838ace0"
+dependencies = [
+ "anymap2",
+ "aquamarine",
+ "as_variant",
+ "async-channel 2.5.0",
+ "async-stream",
+ "async-trait",
+ "backon",
+ "bytes",
+ "bytesize",
+ "cfg-if",
+ "event-listener 5.4.1",
+ "eyeball",
+ "eyeball-im",
+ "futures-core",
+ "futures-util",
+ "gloo-timers",
+ "http 1.4.0",
+ "imbl",
+ "indexmap 2.13.0",
+ "itertools 0.14.0",
+ "js_int",
+ "language-tags",
+ "matrix-sdk-base",
+ "matrix-sdk-common",
+ "matrix-sdk-indexeddb",
+ "matrix-sdk-sqlite",
+ "mime",
+ "mime2ext",
+ "oauth2",
+ "once_cell",
+ "percent-encoding",
+ "pin-project-lite",
+ "reqwest",
+ "ruma",
+ "serde",
+ "serde_html_form",
+ "serde_json",
+ "sha2",
+ "tempfile",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+ "tracing",
+ "url",
+ "urlencoding",
+ "vodozemac",
+ "zeroize",
+]
+
+[[package]]
+name = "matrix-sdk-base"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70f404a390ff98a73c426b1496b169be60ce6a93723a9a664e579d978a84c5e4"
+dependencies = [
+ "as_variant",
+ "async-trait",
+ "bitflags 2.10.0",
+ "decancer",
+ "eyeball",
+ "eyeball-im",
+ "futures-util",
+ "growable-bloom-filter",
+ "matrix-sdk-common",
+ "matrix-sdk-crypto",
+ "matrix-sdk-store-encryption",
+ "once_cell",
+ "regex",
+ "ruma",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "matrix-sdk-common"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54fae2bdfc3d760d21a84d6d2036b5db5c48d9a3dee3794119e3fb9c4cc4ccc5"
+dependencies = [
+ "eyeball-im",
+ "futures-core",
+ "futures-executor",
+ "futures-util",
+ "gloo-timers",
+ "imbl",
+ "ruma",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "matrix-sdk-crypto"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "304fc576810a9618bb831c4ad6403c758ec424f677668a49a196e3cde4b8f99f"
+dependencies = [
+ "aes",
+ "aquamarine",
+ "as_variant",
+ "async-trait",
+ "bs58",
+ "byteorder",
+ "cfg-if",
+ "ctr",
+ "eyeball",
+ "futures-core",
+ "futures-util",
+ "hkdf",
+ "hmac",
+ "itertools 0.14.0",
+ "js_option",
+ "matrix-sdk-common",
+ "pbkdf2",
+ "rand 0.8.5",
+ "rmp-serde",
+ "ruma",
+ "serde",
+ "serde_json",
+ "sha2",
+ "subtle",
+ "thiserror 2.0.18",
+ "time",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "ulid",
+ "url",
+ "vodozemac",
+ "zeroize",
+]
+
+[[package]]
+name = "matrix-sdk-indexeddb"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b6096084cc8d339c03e269ca25534d0f1e88d0097c35a215eb8c311797ec3e9"
+dependencies = [
+ "async-trait",
+ "base64 0.22.1",
+ "futures-util",
+ "getrandom 0.2.17",
+ "gloo-utils",
+ "hkdf",
+ "js-sys",
+ "matrix-sdk-base",
+ "matrix-sdk-crypto",
+ "matrix-sdk-store-encryption",
+ "matrix_indexed_db_futures",
+ "rmp-serde",
+ "ruma",
+ "serde",
+ "serde-wasm-bindgen",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "uuid",
+ "wasm-bindgen",
+ "web-sys",
+ "zeroize",
+]
+
+[[package]]
+name = "matrix-sdk-sqlite"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4325742fc06b7f75c80eec39e8fb32b06ea4b09b7aa1d432b67b01d08fbacc28"
+dependencies = [
+ "as_variant",
+ "async-trait",
+ "deadpool",
+ "deadpool-sync",
+ "itertools 0.14.0",
+ "matrix-sdk-base",
+ "matrix-sdk-crypto",
+ "matrix-sdk-store-encryption",
+ "num_cpus",
+ "rmp-serde",
+ "ruma",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "vodozemac",
+ "zeroize",
+]
+
+[[package]]
+name = "matrix-sdk-store-encryption"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "162a93e83114d5cef25c0ebaea72aa01b9f233df6ec4a2af45f175d01ec26323"
+dependencies = [
+ "base64 0.22.1",
+ "blake3",
+ "chacha20poly1305",
+ "getrandom 0.2.17",
+ "hmac",
+ "pbkdf2",
+ "rand 0.8.5",
+ "rmp-serde",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.18",
+ "zeroize",
+]
+
+[[package]]
+name = "matrix_indexed_db_futures"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "245ff6a224b4df7b0c90dda2dd5a6eb46112708d49e8bdd8b007fccb09fea8e4"
+dependencies = [
+ "accessory",
+ "cfg-if",
+ "delegate-display",
+ "derive_more 2.1.1",
+ "fancy_constructor",
+ "futures-core",
+ "js-sys",
+ "matrix_indexed_db_futures_macros_internal",
+ "sealed",
+ "serde",
+ "serde-wasm-bindgen",
+ "smallvec",
+ "thiserror 2.0.18",
+ "tokio",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm_evt_listener",
+ "web-sys",
+ "web-time",
+]
+
+[[package]]
+name = "matrix_indexed_db_futures_macros_internal"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b428aee5c0fe9e5babd29e99d289b7f64718c444989aac0442d1fd6d3e3f66d1"
+dependencies = [
+ "macroific",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
+name = "md5"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
+
[[package]]
name = "memchr"
version = "2.7.6"
@@ -2639,12 +4723,50 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "miette"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+dependencies = [
+ "cfg-if",
+ "miette-derive",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "miette-derive"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "mime2ext"
+version = "0.1.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -2668,10 +4790,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
+ "log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
+[[package]]
+name = "mio-serial"
+version = "5.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "029e1f407e261176a983a6599c084efd322d9301028055c87174beac71397ba3"
+dependencies = [
+ "log",
+ "mio",
+ "nix 0.29.0",
+ "serialport",
+ "winapi",
+]
+
+[[package]]
+name = "moka"
+version = "0.12.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e"
+dependencies = [
+ "async-lock",
+ "crossbeam-channel",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "equivalent",
+ "event-listener 5.4.1",
+ "futures-util",
+ "parking_lot",
+ "portable-atomic",
+ "smallvec",
+ "tagptr",
+ "uuid",
+]
+
[[package]]
name = "muda"
version = "0.17.1"
@@ -2693,6 +4849,12 @@ dependencies = [
"windows-sys 0.60.2",
]
+[[package]]
+name = "multimap"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
+
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -2702,10 +4864,10 @@ dependencies = [
"libc",
"log",
"openssl",
- "openssl-probe",
+ "openssl-probe 0.1.6",
"openssl-sys",
"schannel",
- "security-framework",
+ "security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
@@ -2746,6 +4908,40 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "nix"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
+dependencies = [
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "nix"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
+dependencies = [
+ "bitflags 2.10.0",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.10.0",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nix"
version = "0.30.1"
@@ -2758,6 +4954,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "no_std_io2"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -2774,6 +4979,26 @@ dependencies = [
"minimal-lexical",
]
+[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "nom_locate"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
+dependencies = [
+ "bytecount",
+ "memchr",
+ "nom 8.0.0",
+]
+
[[package]]
name = "notify-rust"
version = "4.12.0"
@@ -2788,6 +5013,15 @@ dependencies = [
"zbus",
]
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "num-conv"
version = "0.2.0"
@@ -2803,6 +5037,16 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi 0.5.2",
+ "libc",
+]
+
[[package]]
name = "num_enum"
version = "0.7.5"
@@ -2825,6 +5069,63 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "nusb"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f861541f15de120eae5982923d073bfc0c1a65466561988c82d6e197734c19e"
+dependencies = [
+ "atomic-waker",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "futures-core",
+ "io-kit-sys 0.4.1",
+ "libc",
+ "log",
+ "once_cell",
+ "rustix 0.38.44",
+ "slab",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "nusb"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5750d884c774a2862b0049b0318aea27cecc9e873485540af5ed8ab8841247da"
+dependencies = [
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "futures-core",
+ "io-kit-sys 0.5.0",
+ "linux-raw-sys 0.11.0",
+ "log",
+ "once_cell",
+ "rustix 1.1.3",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "oauth2"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "getrandom 0.2.17",
+ "http 1.4.0",
+ "rand 0.8.5",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "sha2",
+ "thiserror 1.0.69",
+ "url",
+]
+
[[package]]
name = "objc2"
version = "0.6.3"
@@ -3066,6 +5367,26 @@ dependencies = [
"objc2-security",
]
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "object"
+version = "0.38.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc"
+dependencies = [
+ "flate2",
+ "memchr",
+ "ruzstd",
+]
+
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -3128,6 +5449,12 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
[[package]]
name = "openssl-sys"
version = "0.9.111"
@@ -3140,6 +5467,76 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "opentelemetry"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "js-sys",
+ "pin-project-lite",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "opentelemetry-http"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "http 1.4.0",
+ "opentelemetry",
+ "reqwest",
+]
+
+[[package]]
+name = "opentelemetry-otlp"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf"
+dependencies = [
+ "http 1.4.0",
+ "opentelemetry",
+ "opentelemetry-http",
+ "opentelemetry-proto",
+ "opentelemetry_sdk",
+ "prost 0.14.3",
+ "reqwest",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "opentelemetry-proto"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
+dependencies = [
+ "opentelemetry",
+ "opentelemetry_sdk",
+ "prost 0.14.3",
+ "tonic",
+ "tonic-prost",
+]
+
+[[package]]
+name = "opentelemetry_sdk"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
+dependencies = [
+ "futures-channel",
+ "futures-executor",
+ "futures-util",
+ "opentelemetry",
+ "percent-encoding",
+ "rand 0.9.2",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3174,7 +5571,7 @@ checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
dependencies = [
"android_system_properties",
"log",
- "nix",
+ "nix 0.30.1",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
@@ -3236,6 +5633,15 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "parse_int"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c464266693329dd5a8715098c7f86e6c5fd5d985018b8318f53d9c6c2b21a31"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "password-hash"
version = "0.5.0"
@@ -3263,12 +5669,40 @@ dependencies = [
"hmac",
]
+[[package]]
+name = "pdf-extract"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e28ba1758a3d3f361459645780e09570b573fc3c82637449e9963174c813a98"
+dependencies = [
+ "adobe-cmap-parser",
+ "cff-parser",
+ "encoding_rs",
+ "euclid",
+ "log",
+ "lopdf",
+ "postscript",
+ "type1-encoding-parser",
+ "unicode-normalization",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "petgraph"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+dependencies = [
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap 2.13.0",
+]
+
[[package]]
name = "phf"
version = "0.8.0"
@@ -3299,6 +5733,25 @@ dependencies = [
"phf_shared 0.11.3",
]
+[[package]]
+name = "phf"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
+dependencies = [
+ "phf_shared 0.12.1",
+]
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_shared 0.13.1",
+ "serde",
+]
+
[[package]]
name = "phf_codegen"
version = "0.8.0"
@@ -3319,6 +5772,16 @@ dependencies = [
"phf_shared 0.11.3",
]
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator 0.13.1",
+ "phf_shared 0.13.1",
+]
+
[[package]]
name = "phf_generator"
version = "0.8.0"
@@ -3349,6 +5812,16 @@ dependencies = [
"rand 0.8.5",
]
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared 0.13.1",
+]
+
[[package]]
name = "phf_macros"
version = "0.10.0"
@@ -3403,6 +5876,44 @@ dependencies = [
"siphasher 1.0.1",
]
+[[package]]
+name = "phf_shared"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
+dependencies = [
+ "siphasher 1.0.1",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher 1.0.1",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -3426,6 +5937,16 @@ dependencies = [
"futures-io",
]
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -3466,12 +5987,23 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
- "hermit-abi",
+ "hermit-abi 0.5.2",
"pin-project-lite",
- "rustix",
+ "rustix 1.1.3",
"windows-sys 0.61.2",
]
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
[[package]]
name = "polyval"
version = "0.6.2"
@@ -3484,6 +6016,12 @@ dependencies = [
"universal-hash",
]
+[[package]]
+name = "pom"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6"
+
[[package]]
name = "portable-atomic"
version = "1.13.0"
@@ -3499,13 +6037,63 @@ dependencies = [
"portable-atomic",
]
+[[package]]
+name = "postgres"
+version = "0.19.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c48ece1c6cda0db61b058c1721378da76855140e9214339fa1317decacb176"
+dependencies = [
+ "bytes",
+ "fallible-iterator 0.2.0",
+ "futures-util",
+ "log",
+ "tokio",
+ "tokio-postgres",
+]
+
+[[package]]
+name = "postgres-protocol"
+version = "0.6.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491"
+dependencies = [
+ "base64 0.22.1",
+ "byteorder",
+ "bytes",
+ "fallible-iterator 0.2.0",
+ "hmac",
+ "md-5",
+ "memchr",
+ "rand 0.9.2",
+ "sha2",
+ "stringprep",
+]
+
+[[package]]
+name = "postgres-types"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20"
+dependencies = [
+ "bytes",
+ "chrono",
+ "fallible-iterator 0.2.0",
+ "postgres-protocol",
+]
+
+[[package]]
+name = "postscript"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306"
+
[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
dependencies = [
- "zerovec",
+ "zerovec 0.11.5",
]
[[package]]
@@ -3529,6 +6117,56 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+[[package]]
+name = "probe-rs"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ee27329ac37fa02b194c62a4e3c1aa053739884ea7bcf861249866d3bf7de00"
+dependencies = [
+ "anyhow",
+ "async-io",
+ "bincode",
+ "bitfield",
+ "bitvec",
+ "cobs",
+ "docsplay",
+ "dunce",
+ "espflash",
+ "flate2",
+ "futures-lite",
+ "hidapi",
+ "ihex",
+ "itertools 0.14.0",
+ "jep106",
+ "nusb 0.1.14",
+ "object 0.37.3",
+ "parking_lot",
+ "probe-rs-target",
+ "rmp-serde",
+ "scroll",
+ "serde",
+ "serde_yaml",
+ "serialport",
+ "thiserror 2.0.18",
+ "tracing",
+ "uf2-decode",
+ "zerocopy",
+]
+
+[[package]]
+name = "probe-rs-target"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2239aca5dc62c68ca6d8ff0051fe617cb8363b803380fbc60567e67c82b474df"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap 2.13.0",
+ "jep106",
+ "serde",
+ "serde_with",
+ "url",
+]
+
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
@@ -3582,6 +6220,27 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "proc-macro-error-attr2"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "proc-macro-error2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
+dependencies = [
+ "proc-macro-error-attr2",
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "proc-macro-hack"
version = "0.5.20+deprecated"
@@ -3597,6 +6256,140 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "prometheus"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a"
+dependencies = [
+ "cfg-if",
+ "fnv",
+ "lazy_static",
+ "memchr",
+ "parking_lot",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive 0.13.5",
+]
+
+[[package]]
+name = "prost"
+version = "0.14.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
+dependencies = [
+ "bytes",
+ "prost-derive 0.14.3",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.14.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
+dependencies = [
+ "heck 0.5.0",
+ "itertools 0.14.0",
+ "log",
+ "multimap",
+ "petgraph",
+ "prost 0.14.3",
+ "prost-types",
+ "regex",
+ "tempfile",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools 0.14.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.14.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
+dependencies = [
+ "anyhow",
+ "itertools 0.14.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.14.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
+dependencies = [
+ "prost 0.14.3",
+]
+
+[[package]]
+name = "protobuf"
+version = "3.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4"
+dependencies = [
+ "once_cell",
+ "protobuf-support",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "protobuf-support"
+version = "3.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6"
+dependencies = [
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "psm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
+dependencies = [
+ "ar_archive_writer",
+ "cc",
+]
+
+[[package]]
+name = "pulldown-cmark"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
+dependencies = [
+ "bitflags 2.10.0",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -3679,12 +6472,24 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "quoted_printable"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
+
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
[[package]]
name = "rand"
version = "0.7.3"
@@ -3777,6 +6582,12 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "rand_core"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
+
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -3795,12 +6606,42 @@ dependencies = [
"rand_core 0.5.1",
]
+[[package]]
+name = "rand_xoshiro"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
+dependencies = [
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rangemap"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
+
[[package]]
name = "raw-window-handle"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+[[package]]
+name = "readlock"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6da6f291b23556edd9edaf655a0be2ad8ef8002ff5f1bca62b264f3f58b53f34"
+
+[[package]]
+name = "readlock-tokio"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7e264f9ec4f3d112e8e2f214e8e7cb5cf3b83278f3570b7e00bfe13d3bd8ff"
+dependencies = [
+ "tokio",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -3903,7 +6744,7 @@ dependencies = [
"futures-core",
"futures-util",
"h2",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"hyper",
@@ -3913,6 +6754,7 @@ dependencies = [
"js-sys",
"log",
"mime",
+ "mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -3935,7 +6777,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
- "webpki-roots 1.0.5",
+ "webpki-roots 1.0.6",
]
[[package]]
@@ -3952,6 +6794,34 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rmp"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "rmp-serde"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
+dependencies = [
+ "rmp",
+ "serde",
+]
+
+[[package]]
+name = "rppal"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1ce3b019009cff02cb6b0e96e7cc2e5c5b90187dc1a490f8ef1521d0596b026"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "rquickjs"
version = "0.11.0"
@@ -4001,13 +6871,189 @@ dependencies = [
]
[[package]]
-name = "rusqlite"
-version = "0.31.0"
+name = "ruma"
+version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
+checksum = "a9f620a2116d0d3082f9256e61dcdf67f2ec266d3f6bb9d2f9c8a20ec5a1fabb"
+dependencies = [
+ "assign",
+ "js_int",
+ "js_option",
+ "ruma-client-api",
+ "ruma-common",
+ "ruma-events",
+ "ruma-federation-api",
+ "ruma-html",
+ "web-time",
+]
+
+[[package]]
+name = "ruma-client-api"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc977d1a91ea15dcf896cbd7005ed4a253784468833638998109ffceaee53e7"
+dependencies = [
+ "as_variant",
+ "assign",
+ "bytes",
+ "date_header",
+ "http 1.4.0",
+ "js_int",
+ "js_option",
+ "maplit",
+ "ruma-common",
+ "ruma-events",
+ "serde",
+ "serde_html_form",
+ "serde_json",
+ "thiserror 2.0.18",
+ "url",
+ "web-time",
+]
+
+[[package]]
+name = "ruma-common"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a01993f22d291320b7c9267675e7395775e95269ff526e2c8c3ed5e13175b"
+dependencies = [
+ "as_variant",
+ "base64 0.22.1",
+ "bytes",
+ "form_urlencoded",
+ "getrandom 0.2.17",
+ "http 1.4.0",
+ "indexmap 2.13.0",
+ "js-sys",
+ "js_int",
+ "konst",
+ "percent-encoding",
+ "rand 0.8.5",
+ "regex",
+ "ruma-identifiers-validation",
+ "ruma-macros",
+ "serde",
+ "serde_html_form",
+ "serde_json",
+ "thiserror 2.0.18",
+ "time",
+ "tracing",
+ "url",
+ "uuid",
+ "web-time",
+ "wildmatch",
+ "zeroize",
+]
+
+[[package]]
+name = "ruma-events"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dbdeccb62cb4ffe3282325de8ba28cbc0fdce7c78a3f11b7241fbfdb9cb9907"
+dependencies = [
+ "as_variant",
+ "indexmap 2.13.0",
+ "js_int",
+ "js_option",
+ "percent-encoding",
+ "pulldown-cmark",
+ "regex",
+ "ruma-common",
+ "ruma-identifiers-validation",
+ "ruma-macros",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tracing",
+ "url",
+ "web-time",
+ "wildmatch",
+ "zeroize",
+]
+
+[[package]]
+name = "ruma-federation-api"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcb45c15badbf4299c6113a6b90df3e7cb64edbe756bbd8e0224144b56b38305"
+dependencies = [
+ "headers",
+ "http 1.4.0",
+ "http-auth",
+ "js_int",
+ "mime",
+ "ruma-common",
+ "ruma-events",
+ "ruma-signatures",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
+[[package]]
+name = "ruma-html"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a6dcd6e9823e177d15460d3cd3a413f38a2beea381f26aca1001c05cd6954ff"
+dependencies = [
+ "as_variant",
+ "html5ever 0.35.0",
+ "tracing",
+ "wildmatch",
+]
+
+[[package]]
+name = "ruma-identifiers-validation"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9c6b5643060beec0fc9d7acfb41d2c5d91e1591db440ff62361d178e77c35fe"
+dependencies = [
+ "js_int",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ruma-macros"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a0753312ad577ac462de1742bf2e326b6ba9856ff6f13343aeb17d423fd5426"
+dependencies = [
+ "as_variant",
+ "cfg-if",
+ "proc-macro-crate 3.4.0",
+ "proc-macro2",
+ "quote",
+ "ruma-identifiers-validation",
+ "serde",
+ "syn 2.0.114",
+ "toml 0.9.11+spec-1.1.0",
+]
+
+[[package]]
+name = "ruma-signatures"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "146ace2cd59b60ec80d3e801a84e7e6a91e3e01d18a9f5d896ea7ca16a6b8e08"
+dependencies = [
+ "base64 0.22.1",
+ "ed25519-dalek",
+ "pkcs8",
+ "rand 0.8.5",
+ "ruma-common",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "rusqlite"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags 2.10.0",
- "fallible-iterator",
+ "fallible-iterator 0.3.0",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
@@ -4039,6 +7085,19 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.10.0",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "rustix"
version = "1.1.3"
@@ -4048,7 +7107,7 @@ dependencies = [
"bitflags 2.10.0",
"errno",
"libc",
- "linux-raw-sys",
+ "linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
]
@@ -4058,6 +7117,8 @@ version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
+ "aws-lc-rs",
+ "log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -4066,6 +7127,18 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
+dependencies = [
+ "openssl-probe 0.2.1",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework 3.6.0",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -4082,6 +7155,7 @@ version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
+ "aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -4093,6 +7167,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+[[package]]
+name = "ruzstd"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01"
+dependencies = [
+ "twox-hash",
+]
+
[[package]]
name = "ryu"
version = "1.0.22"
@@ -4125,7 +7208,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"indexmap 1.9.3",
- "schemars_derive",
+ "schemars_derive 0.8.22",
"serde",
"serde_json",
"url",
@@ -4152,6 +7235,7 @@ checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2"
dependencies = [
"dyn-clone",
"ref-cast",
+ "schemars_derive 1.2.0",
"serde",
"serde_json",
]
@@ -4168,12 +7252,41 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "schemars_derive"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn 2.0.114",
+]
+
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "scroll"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add"
+
+[[package]]
+name = "sealed"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -4188,10 +7301,23 @@ dependencies = [
]
[[package]]
-name = "security-framework-sys"
-version = "2.15.0"
+name = "security-framework"
+version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
+checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
+dependencies = [
+ "bitflags 2.10.0",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a"
dependencies = [
"core-foundation-sys",
"libc",
@@ -4205,7 +7331,7 @@ checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416"
dependencies = [
"bitflags 1.3.2",
"cssparser",
- "derive_more",
+ "derive_more 0.99.20",
"fxhash",
"log",
"phf 0.8.0",
@@ -4215,6 +7341,12 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "self_cell"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
+
[[package]]
name = "semver"
version = "1.0.27"
@@ -4235,6 +7367,15 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde-big-array"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde-untagged"
version = "0.1.9"
@@ -4247,6 +7388,27 @@ dependencies = [
"typeid",
]
+[[package]]
+name = "serde-wasm-bindgen"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
+dependencies = [
+ "js-sys",
+ "serde",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "serde_bytes"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -4278,6 +7440,19 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "serde_html_form"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
+dependencies = [
+ "form_urlencoded",
+ "indexmap 2.13.0",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -4291,6 +7466,26 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_plain"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -4357,12 +7552,25 @@ version = "3.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
dependencies = [
- "darling",
+ "darling 0.21.3",
"proc-macro2",
"quote",
"syn 2.0.114",
]
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap 2.13.0",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -4385,6 +7593,24 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "serialport"
+version = "4.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acaf3f973e8616d7ceac415f53fc60e190b2a686fbcf8d27d0256c741c5007b"
+dependencies = [
+ "bitflags 2.10.0",
+ "cfg-if",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "io-kit-sys 0.4.1",
+ "mach2 0.4.3",
+ "nix 0.26.4",
+ "scopeguard",
+ "unescaper",
+ "winapi",
+]
+
[[package]]
name = "servo_arc"
version = "0.2.0"
@@ -4417,6 +7643,30 @@ dependencies = [
"digest",
]
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
+[[package]]
+name = "shellexpand"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
+dependencies = [
+ "dirs 6.0.0",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -4433,12 +7683,27 @@ dependencies = [
"libc",
]
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -4462,6 +7727,9 @@ name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+dependencies = [
+ "serde",
+]
[[package]]
name = "socket2"
@@ -4521,12 +7789,47 @@ dependencies = [
"system-deps",
]
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "stacker"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "psm",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "stop-token"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af91f480ee899ab2d9f8435bfdfc14d08a5754bd9d3fef1f1a1c23336aad6c8b"
+dependencies = [
+ "async-channel 1.9.0",
+ "cfg-if",
+ "futures-core",
+ "pin-project-lite",
+]
+
[[package]]
name = "string_cache"
version = "0.8.9"
@@ -4552,12 +7855,44 @@ dependencies = [
"quote",
]
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "strum"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4660,6 +7995,12 @@ dependencies = [
"version-compare",
]
+[[package]]
+name = "tagptr"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
[[package]]
name = "tao"
version = "0.34.5"
@@ -4711,6 +8052,12 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4725,7 +8072,7 @@ checksum = "8a3868da5508446a7cd08956d523ac3edf0a8bc20bf7e4038f9a95c2800d2033"
dependencies = [
"anyhow",
"bytes",
- "cookie",
+ "cookie 0.18.1",
"dirs 6.0.0",
"dunce",
"embed_plist",
@@ -4733,7 +8080,7 @@ dependencies = [
"glob",
"gtk",
"heck 0.5.0",
- "http",
+ "http 1.4.0",
"jni",
"libc",
"log",
@@ -4948,10 +8295,10 @@ version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892"
dependencies = [
- "cookie",
+ "cookie 0.18.1",
"dpi",
"gtk",
- "http",
+ "http 1.4.0",
"jni",
"objc2",
"objc2-ui-kit",
@@ -4974,7 +8321,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065"
dependencies = [
"gtk",
- "http",
+ "http 1.4.0",
"jni",
"log",
"objc2",
@@ -5006,8 +8353,8 @@ dependencies = [
"ctor",
"dunce",
"glob",
- "html5ever",
- "http",
+ "html5ever 0.29.1",
+ "http 1.4.0",
"infer",
"json-patch",
"kuchikiki",
@@ -5098,7 +8445,7 @@ dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
- "rustix",
+ "rustix 1.1.3",
"windows-sys 0.61.2",
]
@@ -5154,10 +8501,19 @@ dependencies = [
]
[[package]]
-name = "time"
-version = "0.3.46"
+name = "thread_local"
+version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
@@ -5176,9 +8532,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
-version = "0.2.26"
+version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
@@ -5193,6 +8549,15 @@ dependencies = [
"crunchy",
]
+[[package]]
+name = "tinystr"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
+dependencies = [
+ "displaydoc",
+]
+
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -5200,7 +8565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
"displaydoc",
- "zerovec",
+ "zerovec 0.11.5",
]
[[package]]
@@ -5256,6 +8621,32 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-postgres"
+version = "0.7.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42"
+dependencies = [
+ "async-trait",
+ "byteorder",
+ "bytes",
+ "fallible-iterator 0.2.0",
+ "futures-channel",
+ "futures-util",
+ "log",
+ "parking_lot",
+ "percent-encoding",
+ "phf 0.13.1",
+ "pin-project-lite",
+ "postgres-protocol",
+ "postgres-types",
+ "rand 0.9.2",
+ "socket2",
+ "tokio",
+ "tokio-util",
+ "whoami",
+]
+
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -5266,6 +8657,32 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-serial"
+version = "5.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8"
+dependencies = [
+ "cfg-if",
+ "futures",
+ "log",
+ "mio-serial",
+ "serialport",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
@@ -5278,10 +8695,22 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
- "tungstenite",
+ "tungstenite 0.24.0",
"webpki-roots 0.26.11",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite 0.28.0",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -5291,10 +8720,32 @@ dependencies = [
"bytes",
"futures-core",
"futures-sink",
+ "futures-util",
"pin-project-lite",
"tokio",
]
+[[package]]
+name = "tokio-websockets"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b6aa6c8b5a31e06fd3760eb5c1b8d9072e30731f0467ee3795617fe768e7449"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "http 1.4.0",
+ "httparse",
+ "rand 0.9.2",
+ "ring",
+ "rustls-pki-types",
+ "simdutf8",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+]
+
[[package]]
name = "toml"
version = "0.8.2"
@@ -5322,6 +8773,21 @@ dependencies = [
"winnow 0.7.14",
]
+[[package]]
+name = "toml"
+version = "1.0.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
+dependencies = [
+ "indexmap 2.13.0",
+ "serde_core",
+ "serde_spanned 1.0.4",
+ "toml_datetime 1.0.0+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.14",
+]
+
[[package]]
name = "toml_datetime"
version = "0.6.3"
@@ -5340,6 +8806,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "toml_datetime"
+version = "1.0.0+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "toml_edit"
version = "0.19.15"
@@ -5378,9 +8853,9 @@ dependencies = [
[[package]]
name = "toml_parser"
-version = "1.0.6+spec-1.1.0"
+version = "1.0.9+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
+checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
dependencies = [
"winnow 0.7.14",
]
@@ -5391,6 +8866,38 @@ version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
+[[package]]
+name = "tonic"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
+dependencies = [
+ "async-trait",
+ "base64 0.22.1",
+ "bytes",
+ "http 1.4.0",
+ "http-body",
+ "http-body-util",
+ "percent-encoding",
+ "pin-project",
+ "sync_wrapper",
+ "tokio-stream",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tonic-prost"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309"
+dependencies = [
+ "bytes",
+ "prost 0.14.3",
+ "tonic",
+]
+
[[package]]
name = "tower"
version = "0.5.3"
@@ -5412,13 +8919,18 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
+ "async-compression",
"bitflags 2.10.0",
"bytes",
+ "futures-core",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
+ "http-body-util",
"iri-string",
"pin-project-lite",
+ "tokio",
+ "tokio-util",
"tower",
"tower-layer",
"tower-service",
@@ -5465,6 +8977,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
]
[[package]]
@@ -5495,6 +9037,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+
[[package]]
name = "tungstenite"
version = "0.24.0"
@@ -5504,7 +9052,7 @@ dependencies = [
"byteorder",
"bytes",
"data-encoding",
- "http",
+ "http 1.4.0",
"httparse",
"log",
"rand 0.8.5",
@@ -5515,6 +9063,58 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "tungstenite"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http 1.4.0",
+ "httparse",
+ "log",
+ "rand 0.9.2",
+ "sha1",
+ "thiserror 2.0.18",
+ "utf-8",
+]
+
+[[package]]
+name = "twox-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
+
+[[package]]
+name = "type1-encoding-parser"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b"
+dependencies = [
+ "pom",
+]
+
+[[package]]
+name = "typed-builder"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda"
+dependencies = [
+ "typed-builder-macro",
+]
+
+[[package]]
+name = "typed-builder-macro"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5527,6 +9127,33 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+[[package]]
+name = "typewit"
+version = "1.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71"
+dependencies = [
+ "typewit_proc_macros",
+]
+
+[[package]]
+name = "typewit_proc_macros"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e36a83ea2b3c704935a01b4642946aadd445cea40b10935e3f8bd8052b8193d6"
+
+[[package]]
+name = "udev"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50051c6e22be28ee6f217d50014f3bc29e81c20dc66ff7ca0d5c5226e1dcc5a1"
+dependencies = [
+ "io-lifetimes",
+ "libc",
+ "libudev-sys",
+ "pkg-config",
+]
+
[[package]]
name = "uds_windows"
version = "1.1.0"
@@ -5538,6 +9165,31 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "uf2-decode"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca77d41ab27e3fa45df42043f96c79b80c6d8632eed906b54681d8d47ab00623"
+
+[[package]]
+name = "ulid"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
+dependencies = [
+ "rand 0.9.2",
+ "web-time",
+]
+
+[[package]]
+name = "unescaper"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e"
+dependencies = [
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -5579,18 +9231,63 @@ dependencies = [
"unic-common",
]
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -5601,12 +9298,55 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+[[package]]
+name = "unty"
+version = "0.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
+
+[[package]]
+name = "ureq"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc"
+dependencies = [
+ "base64 0.22.1",
+ "cookie_store",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "ureq-proto",
+ "utf-8",
+ "webpki-roots 1.0.6",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
+dependencies = [
+ "base64 0.22.1",
+ "http 1.4.0",
+ "httparse",
+ "log",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -5620,6 +9360,12 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -5662,6 +9408,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -5680,6 +9432,42 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "virtue"
+version = "0.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
+
+[[package]]
+name = "vodozemac"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c022a277687e4e8685d72b95a7ca3ccfec907daa946678e715f8badaa650883d"
+dependencies = [
+ "aes",
+ "arrayvec",
+ "base64 0.22.1",
+ "base64ct",
+ "cbc",
+ "chacha20poly1305",
+ "curve25519-dalek",
+ "ed25519-dalek",
+ "getrandom 0.2.17",
+ "hkdf",
+ "hmac",
+ "matrix-pickle",
+ "prost 0.13.5",
+ "rand 0.8.5",
+ "serde",
+ "serde_bytes",
+ "serde_json",
+ "sha2",
+ "subtle",
+ "thiserror 2.0.18",
+ "x25519-dalek",
+ "zeroize",
+]
+
[[package]]
name = "vswhom"
version = "0.1.0"
@@ -5700,6 +9488,223 @@ dependencies = [
"libc",
]
+[[package]]
+name = "wa-rs"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fecb468bdfe1e7d4c06a1bd12908c66edaca59024862cb64757ad11c3b948b1"
+dependencies = [
+ "anyhow",
+ "async-channel 2.5.0",
+ "async-trait",
+ "base64 0.22.1",
+ "bytes",
+ "chrono",
+ "dashmap",
+ "env_logger",
+ "hex",
+ "log",
+ "moka",
+ "prost 0.14.3",
+ "rand 0.9.2",
+ "rand_core 0.10.0",
+ "scopeguard",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "wa-rs-binary",
+ "wa-rs-core",
+ "wa-rs-proto",
+]
+
+[[package]]
+name = "wa-rs-appstate"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3845137b3aead2d99de7c6744784bf2f5a908be9dc97a3dbd7585dc40296925c"
+dependencies = [
+ "anyhow",
+ "bytemuck",
+ "hex",
+ "hkdf",
+ "log",
+ "prost 0.14.3",
+ "serde",
+ "serde-big-array",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.18",
+ "wa-rs-binary",
+ "wa-rs-libsignal",
+ "wa-rs-proto",
+]
+
+[[package]]
+name = "wa-rs-binary"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b30a6e11aebb39c07392675256ead5e2570c31382bd4835d6ddc877284b6be"
+dependencies = [
+ "flate2",
+ "phf 0.13.1",
+ "phf_codegen 0.13.1",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "wa-rs-core"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed13bb2aff2de43fc4dd821955f03ea48a1d31eda3c80efe6f905898e304d11f"
+dependencies = [
+ "aes",
+ "aes-gcm",
+ "anyhow",
+ "async-channel 2.5.0",
+ "async-trait",
+ "base64 0.22.1",
+ "bytes",
+ "chrono",
+ "ctr",
+ "flate2",
+ "hex",
+ "hkdf",
+ "hmac",
+ "log",
+ "md5",
+ "once_cell",
+ "pbkdf2",
+ "prost 0.14.3",
+ "protobuf",
+ "rand 0.9.2",
+ "rand_core 0.10.0",
+ "serde",
+ "serde-big-array",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.18",
+ "typed-builder",
+ "wa-rs-appstate",
+ "wa-rs-binary",
+ "wa-rs-derive",
+ "wa-rs-libsignal",
+ "wa-rs-noise",
+ "wa-rs-proto",
+]
+
+[[package]]
+name = "wa-rs-derive"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75c03f610c9bc960e653d5d6d2a4cced9013bedbe5e6e8948787bbd418e4137c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "wa-rs-libsignal"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3471be8ff079ae4959fcddf2e7341281e5c6756bdc6a66454ea1a8e474d14576"
+dependencies = [
+ "aes",
+ "aes-gcm",
+ "arrayref",
+ "async-trait",
+ "cbc",
+ "chrono",
+ "ctr",
+ "curve25519-dalek",
+ "derive_more 2.1.1",
+ "displaydoc",
+ "ghash",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itertools 0.14.0",
+ "log",
+ "prost 0.14.3",
+ "rand 0.9.2",
+ "serde",
+ "sha1",
+ "sha2",
+ "subtle",
+ "thiserror 2.0.18",
+ "uuid",
+ "wa-rs-proto",
+ "x25519-dalek",
+]
+
+[[package]]
+name = "wa-rs-noise"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3efb3891c1e22ce54646dc581e34e79377dc402ed8afb11a7671c5ef629b3ae"
+dependencies = [
+ "aes-gcm",
+ "anyhow",
+ "bytes",
+ "hkdf",
+ "log",
+ "prost 0.14.3",
+ "rand 0.9.2",
+ "rand_core 0.10.0",
+ "sha2",
+ "thiserror 2.0.18",
+ "wa-rs-binary",
+ "wa-rs-libsignal",
+ "wa-rs-proto",
+]
+
+[[package]]
+name = "wa-rs-proto"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ada50ee03752f0e66ada8cf415ed5f90d572d34039b058ce23d8b13493e510"
+dependencies = [
+ "prost 0.14.3",
+ "prost-build",
+ "serde",
+]
+
+[[package]]
+name = "wa-rs-tokio-transport"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfc638c168949dc99cbb756a776869898d4ae654b36b90d5f7ce2d32bf92a404"
+dependencies = [
+ "anyhow",
+ "async-channel 2.5.0",
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http 1.4.0",
+ "log",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tokio-websockets",
+ "wa-rs-core",
+ "webpki-roots 1.0.6",
+]
+
+[[package]]
+name = "wa-rs-ureq-http"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88d0c7fff8a7bd93d0c17af8d797a3934144fa269fe47a615635f3bf04238806"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "tokio",
+ "ureq",
+ "wa-rs-core",
+]
+
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -5731,6 +9736,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+[[package]]
+name = "wasi"
+version = "0.14.7+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
+dependencies = [
+ "wasip2",
+]
+
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
@@ -5740,6 +9754,15 @@ dependencies = [
"wit-bindgen",
]
+[[package]]
+name = "wasite"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
+dependencies = [
+ "wasi 0.14.7+wasi-0.2.4",
+]
+
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
@@ -5812,6 +9835,24 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wasm_evt_listener"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc92d6378b411ed94839112a36d9dbc77143451d85b05dfb0cce93a78dab1963"
+dependencies = [
+ "accessory",
+ "derivative",
+ "derive_more 1.0.0",
+ "fancy_constructor",
+ "futures-core",
+ "js-sys",
+ "smallvec",
+ "tokio",
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "web-sys"
version = "0.3.85"
@@ -5829,9 +9870,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
+ "serde",
"wasm-bindgen",
]
+[[package]]
+name = "web_atoms"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
+dependencies = [
+ "phf 0.11.3",
+ "phf_codegen 0.11.3",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "webdriver"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91d53921e1bef27512fa358179c9a22428d55778d2c2ae3c5c37a52b82ce6e92"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "cookie 0.16.2",
+ "http 0.2.12",
+ "icu_segmenter",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror 1.0.69",
+ "time",
+ "url",
+]
+
[[package]]
name = "webkit2gtk"
version = "2.0.1"
@@ -5882,14 +9956,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
- "webpki-roots 1.0.5",
+ "webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
-version = "1.0.5"
+version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
+checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
@@ -5930,6 +10004,29 @@ dependencies = [
"windows-core 0.61.2",
]
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "whoami"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fae98cf96deed1b7572272dfc777713c249ae40aa1cf8862e091e8b745f5361"
+dependencies = [
+ "libredox",
+ "wasite",
+ "web-sys",
+]
+
+[[package]]
+name = "wildmatch"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68"
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -6484,6 +10581,12 @@ version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+[[package]]
+name = "writeable"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
+
[[package]]
name = "writeable"
version = "0.6.2"
@@ -6498,15 +10601,15 @@ checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2"
dependencies = [
"base64 0.22.1",
"block2",
- "cookie",
+ "cookie 0.18.1",
"crossbeam-channel",
"dirs 6.0.0",
"dpi",
"dunce",
"gdkx11",
"gtk",
- "html5ever",
- "http",
+ "html5ever 0.29.1",
+ "http 1.4.0",
"javascriptcore-rs",
"jni",
"kuchikiki",
@@ -6535,6 +10638,15 @@ dependencies = [
"x11-dl",
]
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
[[package]]
name = "x11"
version = "2.21.0"
@@ -6556,6 +10668,24 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "x25519-dalek"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
+dependencies = [
+ "curve25519-dalek",
+ "rand_core 0.6.4",
+ "serde",
+ "zeroize",
+]
+
+[[package]]
+name = "xxhash-rust"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
+
[[package]]
name = "xz2"
version = "0.1.7"
@@ -6565,6 +10695,18 @@ dependencies = [
"lzma-sys",
]
+[[package]]
+name = "yoke"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
+dependencies = [
+ "serde",
+ "stable_deref_trait",
+ "yoke-derive 0.7.5",
+ "zerofrom",
+]
+
[[package]]
name = "yoke"
version = "0.8.1"
@@ -6572,10 +10714,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
dependencies = [
"stable_deref_trait",
- "yoke-derive",
+ "yoke-derive 0.8.1",
"zerofrom",
]
+[[package]]
+name = "yoke-derive"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+ "synstructure",
+]
+
[[package]]
name = "yoke-derive"
version = "0.8.1"
@@ -6604,13 +10758,13 @@ dependencies = [
"async-trait",
"blocking",
"enumflags2",
- "event-listener",
+ "event-listener 5.4.1",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
- "rustix",
+ "rustix 1.1.3",
"serde",
"serde_repr",
"tracing",
@@ -6717,19 +10871,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
dependencies = [
"displaydoc",
- "yoke",
+ "yoke 0.8.1",
"zerofrom",
]
+[[package]]
+name = "zerovec"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
+dependencies = [
+ "yoke 0.7.5",
+ "zerofrom",
+ "zerovec-derive 0.10.3",
+]
+
[[package]]
name = "zerovec"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
- "yoke",
+ "yoke 0.8.1",
"zerofrom",
- "zerovec-derive",
+ "zerovec-derive 0.11.2",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
]
[[package]]
@@ -6752,7 +10928,7 @@ dependencies = [
"aes",
"arbitrary",
"bzip2",
- "constant_time_eq",
+ "constant_time_eq 0.3.1",
"crc32fast",
"crossbeam-utils",
"deflate64",
@@ -6773,6 +10949,12 @@ dependencies = [
"zstd",
]
+[[package]]
+name = "zlib-rs"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
+
[[package]]
name = "zmij"
version = "1.0.17"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 523e72ecd..18b9c6e3a 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -16,6 +16,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
[dependencies]
# Tauri core and plugins
@@ -31,7 +33,7 @@ serde_json = "1"
# HTTP client: rustls for cross-platform TLS (Android), native-tls for desktop skill HTTP
# (some APIs/CDNs use TLS configs that rustls can't negotiate — native-tls uses OS TLS stack)
-reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2"] }
+reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] }
# Async runtime
tokio = { version = "1", features = ["full", "sync"] }
@@ -50,11 +52,20 @@ env_logger = "0.11"
base64 = "0.22"
aes-gcm = "0.10"
argon2 = "0.5"
-rand = "0.8"
+rand = "0.9"
dirs = "5"
sha2 = "0.10"
+hmac = "0.12"
uuid = { version = "1", features = ["v4"] }
+# Alphahuman agent runtime dependencies
+anyhow = "1.0"
+async-trait = "0.1"
+chacha20poly1305 = "0.10"
+hex = "0.4"
+tokio-util = { version = "0.7", features = ["rt"] }
+landlock = { version = "0.4", optional = true }
+
# V8 JavaScript runtime moved to desktop-only (not available on Android/iOS)
# WebSocket client for TDLib connections
@@ -64,7 +75,7 @@ tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures = "0.3"
# Embedded SQLite for skill databases
-rusqlite = { version = "0.31", features = ["bundled"] }
+rusqlite = { version = "0.37", features = ["bundled"] }
# Date/time utilities
chrono = { version = "0.4", features = ["serde"] }
@@ -76,6 +87,62 @@ cron = "0.12"
futures-util = "0.3"
# Android uses a simpler approach - no persistent Socket.io (use web socket from frontend)
+
+# Alphahuman config + observability
+directories = "6"
+toml = "1.0"
+shellexpand = "3.1"
+schemars = "1.2"
+tracing = { version = "0.1", default-features = false }
+tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter"] }
+prometheus = { version = "0.14", default-features = false }
+urlencoding = "2.1"
+thiserror = "2.0"
+ring = "0.17"
+prost = { version = "0.14", default-features = false }
+postgres = { version = "0.19", features = ["with-chrono-0_4"] }
+chrono-tz = "0.10"
+dialoguer = { version = "0.12", features = ["fuzzy-select"] }
+console = "0.16"
+glob = "0.3"
+regex = "1.10"
+hostname = "0.4.2"
+rustls = { version = "0.23", features = ["ring"] }
+rustls-pki-types = "1.14.0"
+tokio-rustls = "0.26.4"
+webpki-roots = "1.0.6"
+lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
+mail-parser = "0.11.2"
+async-imap = { version = "0.11", features = ["runtime-tokio"], default-features = false }
+axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] }
+tower = { version = "0.5", default-features = false }
+tower-http = { version = "0.6", default-features = false, features = ["limit", "timeout"] }
+http-body-util = "0.1"
+opentelemetry = { version = "0.31", default-features = false, features = ["trace", "metrics"] }
+opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics"] }
+opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] }
+tokio-stream = { version = "0.1.18", features = ["full"] }
+url = "2"
+
+# Optional integrations
+matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] }
+fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
+serde-big-array = { version = "0.5", optional = true }
+nusb = { version = "0.2", default-features = false, optional = true }
+tokio-serial = { version = "5", default-features = false, optional = true }
+probe-rs = { version = "0.30", optional = true }
+pdf-extract = { version = "0.10", optional = true }
+wa-rs = { version = "0.2", optional = true, default-features = false }
+wa-rs-core = { version = "0.2", optional = true, default-features = false }
+wa-rs-binary = { version = "0.2", optional = true, default-features = false }
+wa-rs-proto = { version = "0.2", optional = true, default-features = false }
+wa-rs-ureq-http = { version = "0.2", optional = true }
+wa-rs-tokio-transport = { version = "0.2", optional = true, default-features = false }
+
+
+[target.'cfg(target_os = "linux")'.dependencies]
+rppal = { version = "0.22", optional = true }
+
[target.'cfg(target_os = "android")'.dependencies]
# Android logging (routes Rust `log` crate to logcat)
android_logger = "0.14"
@@ -98,6 +165,22 @@ tdlib-rs = { version = "1.2", features = ["download-tdlib"] }
# TDLib build configuration (download-tdlib for all desktop platforms)
tdlib-rs = { version = "1.2", features = ["download-tdlib"] }
+[dev-dependencies]
+tempfile = "3"
+
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]
+sandbox-landlock = ["dep:landlock"]
+sandbox-bubblewrap = []
+
+# Alphahuman feature flags
+hardware = ["dep:nusb", "dep:tokio-serial"]
+channel-matrix = ["dep:matrix-sdk"]
+peripheral-rpi = ["dep:rppal"]
+browser-native = ["dep:fantoccini"]
+fantoccini = ["browser-native"]
+landlock = ["sandbox-landlock"]
+probe = ["dep:probe-rs"]
+rag-pdf = ["dep:pdf-extract"]
+whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-proto", "dep:wa-rs-ureq-http", "dep:wa-rs-tokio-transport", "serde-big-array"]
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
index 8e58a114c..9e52ed211 100644
--- a/src-tauri/build.rs
+++ b/src-tauri/build.rs
@@ -1,10 +1,51 @@
use std::env;
+use std::path::PathBuf;
fn main() {
+ maybe_override_tauri_config_for_tests();
setup_tdlib();
tauri_build::build();
}
+fn maybe_override_tauri_config_for_tests() {
+ let profile = env::var("PROFILE").unwrap_or_default();
+ let skip_resources = env::var("TAURI_SKIP_RESOURCES").is_ok() || profile == "test";
+ if !skip_resources {
+ return;
+ }
+
+ let manifest_dir =
+ PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
+ let config_path = manifest_dir.join("tauri.conf.json");
+ let Ok(raw) = std::fs::read_to_string(&config_path) else {
+ println!("cargo:warning=Failed to read tauri.conf.json; keeping default config");
+ return;
+ };
+
+ let mut value: serde_json::Value = match serde_json::from_str(&raw) {
+ Ok(value) => value,
+ Err(err) => {
+ println!("cargo:warning=Failed to parse tauri.conf.json: {err}");
+ return;
+ }
+ };
+
+ if let Some(bundle) = value.get_mut("bundle").and_then(|b| b.as_object_mut()) {
+ bundle.insert("resources".to_string(), serde_json::Value::Array(Vec::new()));
+ }
+
+ let out_dir = env::var("OUT_DIR").unwrap_or_else(|_| ".".into());
+ let override_path = PathBuf::from(out_dir).join("tauri.conf.test.json");
+ if std::fs::write(&override_path, serde_json::to_string_pretty(&value).unwrap_or(raw)).is_ok()
+ {
+ env::set_var("TAURI_CONFIG", &override_path);
+ println!(
+ "cargo:warning=TAURI resources disabled for test build (using {})",
+ override_path.display()
+ );
+ }
+}
+
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn setup_tdlib() {
// download-tdlib: downloads prebuilt TDLib and configures linker flags
diff --git a/src-tauri/src/ai/encryption.rs b/src-tauri/src/ai/encryption.rs
index 5c8f552f4..e89af3a91 100644
--- a/src-tauri/src/ai/encryption.rs
+++ b/src-tauri/src/ai/encryption.rs
@@ -9,7 +9,7 @@ use aes_gcm::{
Aes256Gcm, Nonce,
};
use argon2::{self, Algorithm, Argon2, Params, Version};
-use rand::RngCore;
+use aes_gcm::aead::rand_core::RngCore;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
diff --git a/src-tauri/src/alphahuman/agent/agent.rs b/src-tauri/src/alphahuman/agent/agent.rs
new file mode 100644
index 000000000..db6145b98
--- /dev/null
+++ b/src-tauri/src/alphahuman/agent/agent.rs
@@ -0,0 +1,753 @@
+use super::dispatcher::{
+ NativeToolDispatcher, ParsedToolCall, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher,
+};
+use super::memory_loader::{DefaultMemoryLoader, MemoryLoader};
+use super::prompt::{PromptContext, SystemPromptBuilder};
+use crate::alphahuman::config::Config;
+use crate::alphahuman::memory::{self, Memory, MemoryCategory};
+use crate::alphahuman::observability::{self, Observer, ObserverEvent};
+use crate::alphahuman::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
+use crate::alphahuman::runtime;
+use crate::alphahuman::security::SecurityPolicy;
+use crate::alphahuman::tools::{self, Tool, ToolSpec};
+use crate::alphahuman::util::truncate_with_ellipsis;
+use anyhow::Result;
+use std::io::Write as IoWrite;
+use std::sync::Arc;
+use std::time::Instant;
+
+pub struct Agent {
+ provider: Box,
+ tools: Vec>,
+ tool_specs: Vec,
+ memory: Arc,
+ observer: Arc,
+ prompt_builder: SystemPromptBuilder,
+ tool_dispatcher: Box,
+ memory_loader: Box,
+ config: crate::alphahuman::config::AgentConfig,
+ model_name: String,
+ temperature: f64,
+ workspace_dir: std::path::PathBuf,
+ identity_config: crate::alphahuman::config::IdentityConfig,
+ skills: Vec,
+ auto_save: bool,
+ history: Vec,
+ classification_config: crate::alphahuman::config::QueryClassificationConfig,
+ available_hints: Vec,
+}
+
+pub struct AgentBuilder {
+ provider: Option>,
+ tools: Option>>,
+ memory: Option>,
+ observer: Option>,
+ prompt_builder: Option,
+ tool_dispatcher: Option>,
+ memory_loader: Option>,
+ config: Option,
+ model_name: Option,
+ temperature: Option,
+ workspace_dir: Option,
+ identity_config: Option,
+ skills: Option>,
+ auto_save: Option,
+ classification_config: Option,
+ available_hints: Option>,
+}
+
+impl AgentBuilder {
+ pub fn new() -> Self {
+ Self {
+ provider: None,
+ tools: None,
+ memory: None,
+ observer: None,
+ prompt_builder: None,
+ tool_dispatcher: None,
+ memory_loader: None,
+ config: None,
+ model_name: None,
+ temperature: None,
+ workspace_dir: None,
+ identity_config: None,
+ skills: None,
+ auto_save: None,
+ classification_config: None,
+ available_hints: None,
+ }
+ }
+
+ pub fn provider(mut self, provider: Box) -> Self {
+ self.provider = Some(provider);
+ self
+ }
+
+ pub fn tools(mut self, tools: Vec>) -> Self {
+ self.tools = Some(tools);
+ self
+ }
+
+ pub fn memory(mut self, memory: Arc) -> Self {
+ self.memory = Some(memory);
+ self
+ }
+
+ pub fn observer(mut self, observer: Arc) -> Self {
+ self.observer = Some(observer);
+ self
+ }
+
+ pub fn prompt_builder(mut self, prompt_builder: SystemPromptBuilder) -> Self {
+ self.prompt_builder = Some(prompt_builder);
+ self
+ }
+
+ pub fn tool_dispatcher(mut self, tool_dispatcher: Box) -> Self {
+ self.tool_dispatcher = Some(tool_dispatcher);
+ self
+ }
+
+ pub fn memory_loader(mut self, memory_loader: Box) -> Self {
+ self.memory_loader = Some(memory_loader);
+ self
+ }
+
+ pub fn config(mut self, config: crate::alphahuman::config::AgentConfig) -> Self {
+ self.config = Some(config);
+ self
+ }
+
+ pub fn model_name(mut self, model_name: String) -> Self {
+ self.model_name = Some(model_name);
+ self
+ }
+
+ pub fn temperature(mut self, temperature: f64) -> Self {
+ self.temperature = Some(temperature);
+ self
+ }
+
+ pub fn workspace_dir(mut self, workspace_dir: std::path::PathBuf) -> Self {
+ self.workspace_dir = Some(workspace_dir);
+ self
+ }
+
+ pub fn identity_config(mut self, identity_config: crate::alphahuman::config::IdentityConfig) -> Self {
+ self.identity_config = Some(identity_config);
+ self
+ }
+
+ pub fn skills(mut self, skills: Vec) -> Self {
+ self.skills = Some(skills);
+ self
+ }
+
+ pub fn auto_save(mut self, auto_save: bool) -> Self {
+ self.auto_save = Some(auto_save);
+ self
+ }
+
+ pub fn classification_config(
+ mut self,
+ classification_config: crate::alphahuman::config::QueryClassificationConfig,
+ ) -> Self {
+ self.classification_config = Some(classification_config);
+ self
+ }
+
+ pub fn available_hints(mut self, available_hints: Vec) -> Self {
+ self.available_hints = Some(available_hints);
+ self
+ }
+
+ pub fn build(self) -> Result {
+ let tools = self
+ .tools
+ .ok_or_else(|| anyhow::anyhow!("tools are required"))?;
+ let tool_specs = tools.iter().map(|tool| tool.spec()).collect();
+
+ Ok(Agent {
+ provider: self
+ .provider
+ .ok_or_else(|| anyhow::anyhow!("provider is required"))?,
+ tools,
+ tool_specs,
+ memory: self
+ .memory
+ .ok_or_else(|| anyhow::anyhow!("memory is required"))?,
+ observer: self
+ .observer
+ .ok_or_else(|| anyhow::anyhow!("observer is required"))?,
+ prompt_builder: self
+ .prompt_builder
+ .unwrap_or_else(SystemPromptBuilder::with_defaults),
+ tool_dispatcher: self
+ .tool_dispatcher
+ .ok_or_else(|| anyhow::anyhow!("tool_dispatcher is required"))?,
+ memory_loader: self
+ .memory_loader
+ .unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())),
+ config: self.config.unwrap_or_default(),
+ model_name: self
+ .model_name
+ .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()),
+ temperature: self.temperature.unwrap_or(0.7),
+ workspace_dir: self
+ .workspace_dir
+ .unwrap_or_else(|| std::path::PathBuf::from(".")),
+ identity_config: self.identity_config.unwrap_or_default(),
+ skills: self.skills.unwrap_or_default(),
+ auto_save: self.auto_save.unwrap_or(false),
+ history: Vec::new(),
+ classification_config: self.classification_config.unwrap_or_default(),
+ available_hints: self.available_hints.unwrap_or_default(),
+ })
+ }
+}
+
+impl Agent {
+ pub fn builder() -> AgentBuilder {
+ AgentBuilder::new()
+ }
+
+ pub fn history(&self) -> &[ConversationMessage] {
+ &self.history
+ }
+
+ pub fn clear_history(&mut self) {
+ self.history.clear();
+ }
+
+ pub fn from_config(config: &Config) -> Result {
+ let observer: Arc =
+ Arc::from(observability::create_observer(&config.observability));
+ let runtime: Arc =
+ Arc::from(runtime::create_runtime(&config.runtime)?);
+ let security = Arc::new(SecurityPolicy::from_config(
+ &config.autonomy,
+ &config.workspace_dir,
+ ));
+
+ let memory: Arc = Arc::from(memory::create_memory_with_storage_and_routes(
+ &config.memory,
+ &config.embedding_routes,
+ Some(&config.storage.provider.config),
+ &config.workspace_dir,
+ config.api_key.as_deref(),
+ )?);
+
+ let composio_key = if config.composio.enabled {
+ config.composio.api_key.as_deref()
+ } else {
+ None
+ };
+ let composio_entity_id = if config.composio.enabled {
+ Some(config.composio.entity_id.as_str())
+ } else {
+ None
+ };
+
+ let tools = tools::all_tools_with_runtime(
+ Arc::new(config.clone()),
+ &security,
+ runtime,
+ memory.clone(),
+ composio_key,
+ composio_entity_id,
+ &config.browser,
+ &config.http_request,
+ &config.workspace_dir,
+ &config.agents,
+ config.api_key.as_deref(),
+ config,
+ );
+
+ let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
+
+ let model_name = config
+ .default_model
+ .as_deref()
+ .unwrap_or("anthropic/claude-sonnet-4-20250514")
+ .to_string();
+
+ let provider: Box = providers::create_routed_provider(
+ provider_name,
+ config.api_key.as_deref(),
+ config.api_url.as_deref(),
+ &config.reliability,
+ &config.model_routes,
+ &model_name,
+ )?;
+
+ let dispatcher_choice = config.agent.tool_dispatcher.as_str();
+ let tool_dispatcher: Box = match dispatcher_choice {
+ "native" => Box::new(NativeToolDispatcher),
+ "xml" => Box::new(XmlToolDispatcher),
+ _ if provider.supports_native_tools() => Box::new(NativeToolDispatcher),
+ _ => Box::new(XmlToolDispatcher),
+ };
+
+ let available_hints: Vec =
+ config.model_routes.iter().map(|r| r.hint.clone()).collect();
+
+ Agent::builder()
+ .provider(provider)
+ .tools(tools)
+ .memory(memory)
+ .observer(observer)
+ .tool_dispatcher(tool_dispatcher)
+ .memory_loader(Box::new(DefaultMemoryLoader::new(
+ 5,
+ config.memory.min_relevance_score,
+ )))
+ .prompt_builder(SystemPromptBuilder::with_defaults())
+ .config(config.agent.clone())
+ .model_name(model_name)
+ .temperature(config.default_temperature)
+ .workspace_dir(config.workspace_dir.clone())
+ .classification_config(config.query_classification.clone())
+ .available_hints(available_hints)
+ .identity_config(config.identity.clone())
+ .skills(crate::alphahuman::skills::load_skills(&config.workspace_dir))
+ .auto_save(config.memory.auto_save)
+ .build()
+ }
+
+ fn trim_history(&mut self) {
+ let max = self.config.max_history_messages;
+ if self.history.len() <= max {
+ return;
+ }
+
+ let mut system_messages = Vec::new();
+ let mut other_messages = Vec::new();
+
+ for msg in self.history.drain(..) {
+ match &msg {
+ ConversationMessage::Chat(chat) if chat.role == "system" => {
+ system_messages.push(msg);
+ }
+ _ => other_messages.push(msg),
+ }
+ }
+
+ if other_messages.len() > max {
+ let drop_count = other_messages.len() - max;
+ other_messages.drain(0..drop_count);
+ }
+
+ self.history = system_messages;
+ self.history.extend(other_messages);
+ }
+
+ fn build_system_prompt(&self) -> Result {
+ let instructions = self.tool_dispatcher.prompt_instructions(&self.tools);
+ let ctx = PromptContext {
+ workspace_dir: &self.workspace_dir,
+ model_name: &self.model_name,
+ tools: &self.tools,
+ skills: &self.skills,
+ identity_config: Some(&self.identity_config),
+ dispatcher_instructions: &instructions,
+ };
+ self.prompt_builder.build(&ctx)
+ }
+
+ async fn execute_tool_call(&self, call: &ParsedToolCall) -> ToolExecutionResult {
+ let start = Instant::now();
+
+ let result = if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) {
+ match tool.execute(call.arguments.clone()).await {
+ Ok(r) => {
+ self.observer.record_event(&ObserverEvent::ToolCall {
+ tool: call.name.clone(),
+ duration: start.elapsed(),
+ success: r.success,
+ });
+ if r.success {
+ r.output
+ } else {
+ format!("Error: {}", r.error.unwrap_or(r.output))
+ }
+ }
+ Err(e) => {
+ self.observer.record_event(&ObserverEvent::ToolCall {
+ tool: call.name.clone(),
+ duration: start.elapsed(),
+ success: false,
+ });
+ format!("Error executing {}: {e}", call.name)
+ }
+ }
+ } else {
+ format!("Unknown tool: {}", call.name)
+ };
+
+ ToolExecutionResult {
+ name: call.name.clone(),
+ output: result,
+ success: true,
+ tool_call_id: call.tool_call_id.clone(),
+ }
+ }
+
+ async fn execute_tools(&self, calls: &[ParsedToolCall]) -> Vec {
+ if !self.config.parallel_tools {
+ let mut results = Vec::with_capacity(calls.len());
+ for call in calls {
+ results.push(self.execute_tool_call(call).await);
+ }
+ return results;
+ }
+
+ let mut results = Vec::with_capacity(calls.len());
+ for call in calls {
+ results.push(self.execute_tool_call(call).await);
+ }
+ results
+ }
+
+ fn classify_model(&self, user_message: &str) -> String {
+ if let Some(hint) = super::classifier::classify(&self.classification_config, user_message) {
+ if self.available_hints.contains(&hint) {
+ tracing::info!(hint = hint.as_str(), "Auto-classified query");
+ return format!("hint:{hint}");
+ }
+ }
+ self.model_name.clone()
+ }
+
+ pub async fn turn(&mut self, user_message: &str) -> Result {
+ if self.history.is_empty() {
+ let system_prompt = self.build_system_prompt()?;
+ self.history
+ .push(ConversationMessage::Chat(ChatMessage::system(
+ system_prompt,
+ )));
+ }
+
+ if self.auto_save {
+ let _ = self
+ .memory
+ .store("user_msg", user_message, MemoryCategory::Conversation, None)
+ .await;
+ }
+
+ let context = self
+ .memory_loader
+ .load_context(self.memory.as_ref(), user_message)
+ .await
+ .unwrap_or_default();
+
+ let enriched = if context.is_empty() {
+ user_message.to_string()
+ } else {
+ format!("{context}{user_message}")
+ };
+
+ self.history
+ .push(ConversationMessage::Chat(ChatMessage::user(enriched)));
+
+ let effective_model = self.classify_model(user_message);
+
+ for _ in 0..self.config.max_tool_iterations {
+ let messages = self.tool_dispatcher.to_provider_messages(&self.history);
+ let response = match self
+ .provider
+ .chat(
+ ChatRequest {
+ messages: &messages,
+ tools: if self.tool_dispatcher.should_send_tool_specs() {
+ Some(&self.tool_specs)
+ } else {
+ None
+ },
+ },
+ &effective_model,
+ self.temperature,
+ )
+ .await
+ {
+ Ok(resp) => resp,
+ Err(err) => return Err(err),
+ };
+
+ let (text, calls) = self.tool_dispatcher.parse_response(&response);
+ if calls.is_empty() {
+ let final_text = if text.is_empty() {
+ response.text.unwrap_or_default()
+ } else {
+ text
+ };
+
+ self.history
+ .push(ConversationMessage::Chat(ChatMessage::assistant(
+ final_text.clone(),
+ )));
+ self.trim_history();
+
+ if self.auto_save {
+ let summary = truncate_with_ellipsis(&final_text, 100);
+ let _ = self
+ .memory
+ .store("assistant_resp", &summary, MemoryCategory::Daily, None)
+ .await;
+ }
+
+ return Ok(final_text);
+ }
+
+ if !text.is_empty() {
+ self.history
+ .push(ConversationMessage::Chat(ChatMessage::assistant(
+ text.clone(),
+ )));
+ print!("{text}");
+ let _ = std::io::stdout().flush();
+ }
+
+ self.history.push(ConversationMessage::AssistantToolCalls {
+ text: response.text.clone(),
+ tool_calls: response.tool_calls.clone(),
+ });
+
+ let results = self.execute_tools(&calls).await;
+ let formatted = self.tool_dispatcher.format_results(&results);
+ self.history.push(formatted);
+ self.trim_history();
+ }
+
+ anyhow::bail!(
+ "Agent exceeded maximum tool iterations ({})",
+ self.config.max_tool_iterations
+ )
+ }
+
+ pub async fn run_single(&mut self, message: &str) -> Result {
+ self.turn(message).await
+ }
+
+ pub async fn run_interactive(&mut self) -> Result<()> {
+ println!("🦀 Alphahuman Interactive Mode");
+ println!("Type /quit to exit.\n");
+
+ let (tx, mut rx) = tokio::sync::mpsc::channel(32);
+ let cli = crate::alphahuman::channels::CliChannel::new();
+
+ let listen_handle = tokio::spawn(async move {
+ let _ = crate::alphahuman::channels::Channel::listen(&cli, tx).await;
+ });
+
+ while let Some(msg) = rx.recv().await {
+ let response = match self.turn(&msg.content).await {
+ Ok(resp) => resp,
+ Err(e) => {
+ eprintln!("\nError: {e}\n");
+ continue;
+ }
+ };
+ println!("\n{response}\n");
+ }
+
+ listen_handle.abort();
+ Ok(())
+ }
+}
+
+pub async fn run(
+ config: Config,
+ message: Option,
+ provider_override: Option,
+ model_override: Option,
+ temperature: f64,
+) -> Result<()> {
+ let start = Instant::now();
+
+ let mut effective_config = config;
+ if let Some(p) = provider_override {
+ effective_config.default_provider = Some(p);
+ }
+ if let Some(m) = model_override {
+ effective_config.default_model = Some(m);
+ }
+ effective_config.default_temperature = temperature;
+
+ let mut agent = Agent::from_config(&effective_config)?;
+
+ let provider_name = effective_config
+ .default_provider
+ .as_deref()
+ .unwrap_or("openrouter")
+ .to_string();
+ let model_name = effective_config
+ .default_model
+ .as_deref()
+ .unwrap_or("anthropic/claude-sonnet-4-20250514")
+ .to_string();
+
+ agent.observer.record_event(&ObserverEvent::AgentStart {
+ provider: provider_name.clone(),
+ model: model_name.clone(),
+ });
+
+ if let Some(msg) = message {
+ let response = agent.run_single(&msg).await?;
+ println!("{response}");
+ } else {
+ agent.run_interactive().await?;
+ }
+
+ agent.observer.record_event(&ObserverEvent::AgentEnd {
+ provider: provider_name,
+ model: model_name,
+ duration: start.elapsed(),
+ tokens_used: None,
+ cost_usd: None,
+ });
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use async_trait::async_trait;
+ use parking_lot::Mutex;
+
+ struct MockProvider {
+ responses: Mutex>,
+ }
+
+ #[async_trait]
+ impl Provider for MockProvider {
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> Result {
+ Ok("ok".into())
+ }
+
+ async fn chat(
+ &self,
+ _request: ChatRequest<'_>,
+ _model: &str,
+ _temperature: f64,
+ ) -> Result {
+ let mut guard = self.responses.lock();
+ if guard.is_empty() {
+ return Ok(crate::alphahuman::providers::ChatResponse {
+ text: Some("done".into()),
+ tool_calls: vec![],
+ });
+ }
+ Ok(guard.remove(0))
+ }
+ }
+
+ struct MockTool;
+
+ #[async_trait]
+ impl Tool for MockTool {
+ fn name(&self) -> &str {
+ "echo"
+ }
+
+ fn description(&self) -> &str {
+ "echo"
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({"type": "object"})
+ }
+
+ async fn execute(&self, _args: serde_json::Value) -> Result {
+ Ok(crate::alphahuman::tools::ToolResult {
+ success: true,
+ output: "tool-out".into(),
+ error: None,
+ })
+ }
+ }
+
+ #[tokio::test]
+ async fn turn_without_tools_returns_text() {
+ let provider = Box::new(MockProvider {
+ responses: Mutex::new(vec![crate::alphahuman::providers::ChatResponse {
+ text: Some("hello".into()),
+ tool_calls: vec![],
+ }]),
+ });
+
+ let memory_cfg = crate::alphahuman::config::MemoryConfig {
+ backend: "none".into(),
+ ..crate::alphahuman::config::MemoryConfig::default()
+ };
+ let mem: Arc = Arc::from(
+ crate::alphahuman::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(),
+ );
+
+ let observer: Arc = Arc::from(crate::alphahuman::observability::NoopObserver {});
+ let mut agent = Agent::builder()
+ .provider(provider)
+ .tools(vec![Box::new(MockTool)])
+ .memory(mem)
+ .observer(observer)
+ .tool_dispatcher(Box::new(XmlToolDispatcher))
+ .workspace_dir(std::path::PathBuf::from("/tmp"))
+ .build()
+ .unwrap();
+
+ let response = agent.turn("hi").await.unwrap();
+ assert_eq!(response, "hello");
+ }
+
+ #[tokio::test]
+ async fn turn_with_native_dispatcher_handles_tool_results_variant() {
+ let provider = Box::new(MockProvider {
+ responses: Mutex::new(vec![
+ crate::alphahuman::providers::ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![crate::alphahuman::providers::ToolCall {
+ id: "tc1".into(),
+ name: "echo".into(),
+ arguments: "{}".into(),
+ }],
+ },
+ crate::alphahuman::providers::ChatResponse {
+ text: Some("done".into()),
+ tool_calls: vec![],
+ },
+ ]),
+ });
+
+ let memory_cfg = crate::alphahuman::config::MemoryConfig {
+ backend: "none".into(),
+ ..crate::alphahuman::config::MemoryConfig::default()
+ };
+ let mem: Arc = Arc::from(
+ crate::alphahuman::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(),
+ );
+
+ let observer: Arc = Arc::from(crate::alphahuman::observability::NoopObserver {});
+ let mut agent = Agent::builder()
+ .provider(provider)
+ .tools(vec![Box::new(MockTool)])
+ .memory(mem)
+ .observer(observer)
+ .tool_dispatcher(Box::new(NativeToolDispatcher))
+ .workspace_dir(std::path::PathBuf::from("/tmp"))
+ .build()
+ .unwrap();
+
+ let response = agent.turn("hi").await.unwrap();
+ assert_eq!(response, "done");
+ assert!(agent
+ .history()
+ .iter()
+ .any(|msg| matches!(msg, ConversationMessage::ToolResults(_))));
+ }
+}
diff --git a/src-tauri/src/alphahuman/agent/classifier.rs b/src-tauri/src/alphahuman/agent/classifier.rs
new file mode 100644
index 000000000..e2cf6e8ca
--- /dev/null
+++ b/src-tauri/src/alphahuman/agent/classifier.rs
@@ -0,0 +1,172 @@
+use crate::alphahuman::config::schema::QueryClassificationConfig;
+
+/// Classify a user message against the configured rules and return the
+/// matching hint string, if any.
+///
+/// Returns `None` when classification is disabled, no rules are configured,
+/// or no rule matches the message.
+pub fn classify(config: &QueryClassificationConfig, message: &str) -> Option {
+ if !config.enabled || config.rules.is_empty() {
+ return None;
+ }
+
+ let lower = message.to_lowercase();
+ let len = message.len();
+
+ let mut rules: Vec<_> = config.rules.iter().collect();
+ rules.sort_by(|a, b| b.priority.cmp(&a.priority));
+
+ for rule in rules {
+ // Length constraints
+ if let Some(min) = rule.min_length {
+ if len < min {
+ continue;
+ }
+ }
+ if let Some(max) = rule.max_length {
+ if len > max {
+ continue;
+ }
+ }
+
+ // Check keywords (case-insensitive) and patterns (case-sensitive)
+ let keyword_hit = rule
+ .keywords
+ .iter()
+ .any(|kw: &String| lower.contains(&kw.to_lowercase()));
+ let pattern_hit = rule
+ .patterns
+ .iter()
+ .any(|pat: &String| message.contains(pat.as_str()));
+
+ if keyword_hit || pattern_hit {
+ return Some(rule.hint.clone());
+ }
+ }
+
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::alphahuman::config::schema::{ClassificationRule, QueryClassificationConfig};
+
+ fn make_config(enabled: bool, rules: Vec) -> QueryClassificationConfig {
+ QueryClassificationConfig { enabled, rules }
+ }
+
+ #[test]
+ fn disabled_returns_none() {
+ let config = make_config(
+ false,
+ vec![ClassificationRule {
+ hint: "fast".into(),
+ keywords: vec!["hello".into()],
+ ..Default::default()
+ }],
+ );
+ assert_eq!(classify(&config, "hello"), None);
+ }
+
+ #[test]
+ fn empty_rules_returns_none() {
+ let config = make_config(true, vec![]);
+ assert_eq!(classify(&config, "hello"), None);
+ }
+
+ #[test]
+ fn keyword_match_case_insensitive() {
+ let config = make_config(
+ true,
+ vec![ClassificationRule {
+ hint: "fast".into(),
+ keywords: vec!["hello".into()],
+ ..Default::default()
+ }],
+ );
+ assert_eq!(classify(&config, "HELLO world"), Some("fast".into()));
+ }
+
+ #[test]
+ fn pattern_match_case_sensitive() {
+ let config = make_config(
+ true,
+ vec![ClassificationRule {
+ hint: "code".into(),
+ patterns: vec!["fn ".into()],
+ ..Default::default()
+ }],
+ );
+ assert_eq!(classify(&config, "fn main()"), Some("code".into()));
+ assert_eq!(classify(&config, "FN MAIN()"), None);
+ }
+
+ #[test]
+ fn length_constraints() {
+ let config = make_config(
+ true,
+ vec![ClassificationRule {
+ hint: "fast".into(),
+ keywords: vec!["hi".into()],
+ max_length: Some(10),
+ ..Default::default()
+ }],
+ );
+ assert_eq!(classify(&config, "hi"), Some("fast".into()));
+ assert_eq!(
+ classify(&config, "hi there, how are you doing today?"),
+ None
+ );
+
+ let config2 = make_config(
+ true,
+ vec![ClassificationRule {
+ hint: "reasoning".into(),
+ keywords: vec!["explain".into()],
+ min_length: Some(20),
+ ..Default::default()
+ }],
+ );
+ assert_eq!(classify(&config2, "explain"), None);
+ assert_eq!(
+ classify(&config2, "explain how this works in detail"),
+ Some("reasoning".into())
+ );
+ }
+
+ #[test]
+ fn priority_ordering() {
+ let config = make_config(
+ true,
+ vec![
+ ClassificationRule {
+ hint: "fast".into(),
+ keywords: vec!["code".into()],
+ priority: 1,
+ ..Default::default()
+ },
+ ClassificationRule {
+ hint: "code".into(),
+ keywords: vec!["code".into()],
+ priority: 10,
+ ..Default::default()
+ },
+ ],
+ );
+ assert_eq!(classify(&config, "write some code"), Some("code".into()));
+ }
+
+ #[test]
+ fn no_match_returns_none() {
+ let config = make_config(
+ true,
+ vec![ClassificationRule {
+ hint: "fast".into(),
+ keywords: vec!["hello".into()],
+ ..Default::default()
+ }],
+ );
+ assert_eq!(classify(&config, "something completely different"), None);
+ }
+}
diff --git a/src-tauri/src/alphahuman/agent/dispatcher.rs b/src-tauri/src/alphahuman/agent/dispatcher.rs
new file mode 100644
index 000000000..f3024fa85
--- /dev/null
+++ b/src-tauri/src/alphahuman/agent/dispatcher.rs
@@ -0,0 +1,318 @@
+use crate::alphahuman::providers::{ChatMessage, ChatResponse, ConversationMessage, ToolResultMessage};
+use crate::alphahuman::tools::{Tool, ToolSpec};
+use serde_json::Value;
+use std::fmt::Write;
+
+#[derive(Debug, Clone)]
+pub struct ParsedToolCall {
+ pub name: String,
+ pub arguments: Value,
+ pub tool_call_id: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct ToolExecutionResult {
+ pub name: String,
+ pub output: String,
+ pub success: bool,
+ pub tool_call_id: Option,
+}
+
+pub trait ToolDispatcher: Send + Sync {
+ fn parse_response(&self, response: &ChatResponse) -> (String, Vec);
+ fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage;
+ fn prompt_instructions(&self, tools: &[Box]) -> String;
+ fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec;
+ fn should_send_tool_specs(&self) -> bool;
+}
+
+#[derive(Default)]
+pub struct XmlToolDispatcher;
+
+impl XmlToolDispatcher {
+ fn parse_xml_tool_calls(response: &str) -> (String, Vec) {
+ let mut text_parts = Vec::new();
+ let mut calls = Vec::new();
+ let mut remaining = response;
+
+ while let Some(start) = remaining.find("") {
+ let before = &remaining[..start];
+ if !before.trim().is_empty() {
+ text_parts.push(before.trim().to_string());
+ }
+
+ if let Some(end) = remaining[start..].find(" ") {
+ let inner = &remaining[start + 11..start + end];
+ match serde_json::from_str::(inner.trim()) {
+ Ok(parsed) => {
+ let name = parsed
+ .get("name")
+ .and_then(Value::as_str)
+ .unwrap_or("")
+ .to_string();
+ if name.is_empty() {
+ remaining = &remaining[start + end + 12..];
+ continue;
+ }
+ let arguments = parsed
+ .get("arguments")
+ .cloned()
+ .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
+ calls.push(ParsedToolCall {
+ name,
+ arguments,
+ tool_call_id: None,
+ });
+ }
+ Err(e) => {
+ tracing::warn!("Malformed JSON: {e}");
+ }
+ }
+ remaining = &remaining[start + end + 12..];
+ } else {
+ break;
+ }
+ }
+
+ if !remaining.trim().is_empty() {
+ text_parts.push(remaining.trim().to_string());
+ }
+
+ (text_parts.join("\n"), calls)
+ }
+
+ pub fn tool_specs(tools: &[Box]) -> Vec {
+ tools.iter().map(|tool| tool.spec()).collect()
+ }
+}
+
+impl ToolDispatcher for XmlToolDispatcher {
+ fn parse_response(&self, response: &ChatResponse) -> (String, Vec) {
+ let text = response.text_or_empty();
+ Self::parse_xml_tool_calls(text)
+ }
+
+ fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage {
+ let mut content = String::new();
+ for result in results {
+ let status = if result.success { "ok" } else { "error" };
+ let _ = writeln!(
+ content,
+ "\n{}\n ",
+ result.name, status, result.output
+ );
+ }
+ ConversationMessage::Chat(ChatMessage::user(format!("[Tool results]\n{content}")))
+ }
+
+ fn prompt_instructions(&self, tools: &[Box]) -> String {
+ let mut instructions = String::new();
+ instructions.push_str("## Tool Use Protocol\n\n");
+ instructions
+ .push_str("To use a tool, wrap a JSON object in tags:\n\n");
+ instructions.push_str(
+ "```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n \n```\n\n",
+ );
+ instructions.push_str("### Available Tools\n\n");
+
+ for tool in tools {
+ let _ = writeln!(
+ instructions,
+ "- **{}**: {}\n Parameters: `{}`",
+ tool.name(),
+ tool.description(),
+ tool.parameters_schema()
+ );
+ }
+
+ instructions
+ }
+
+ fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec {
+ history
+ .iter()
+ .flat_map(|msg| match msg {
+ ConversationMessage::Chat(chat) => vec![chat.clone()],
+ ConversationMessage::AssistantToolCalls { text, .. } => {
+ vec![ChatMessage::assistant(text.clone().unwrap_or_default())]
+ }
+ ConversationMessage::ToolResults(results) => {
+ let mut content = String::new();
+ for result in results {
+ let _ = writeln!(
+ content,
+ "\n{}\n ",
+ result.tool_call_id, result.content
+ );
+ }
+ vec![ChatMessage::user(format!("[Tool results]\n{content}"))]
+ }
+ })
+ .collect()
+ }
+
+ fn should_send_tool_specs(&self) -> bool {
+ false
+ }
+}
+
+pub struct NativeToolDispatcher;
+
+impl ToolDispatcher for NativeToolDispatcher {
+ fn parse_response(&self, response: &ChatResponse) -> (String, Vec) {
+ let text = response.text.clone().unwrap_or_default();
+ let calls = response
+ .tool_calls
+ .iter()
+ .map(|tc| ParsedToolCall {
+ name: tc.name.clone(),
+ arguments: serde_json::from_str(&tc.arguments).unwrap_or_else(|e| {
+ tracing::warn!(
+ tool = %tc.name,
+ error = %e,
+ "Failed to parse native tool call arguments as JSON; defaulting to empty object"
+ );
+ Value::Object(serde_json::Map::new())
+ }),
+ tool_call_id: Some(tc.id.clone()),
+ })
+ .collect();
+ (text, calls)
+ }
+
+ fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage {
+ let messages = results
+ .iter()
+ .map(|result| ToolResultMessage {
+ tool_call_id: result
+ .tool_call_id
+ .clone()
+ .unwrap_or_else(|| "unknown".to_string()),
+ content: result.output.clone(),
+ })
+ .collect();
+ ConversationMessage::ToolResults(messages)
+ }
+
+ fn prompt_instructions(&self, _tools: &[Box]) -> String {
+ String::new()
+ }
+
+ fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec {
+ history
+ .iter()
+ .flat_map(|msg| match msg {
+ ConversationMessage::Chat(chat) => vec![chat.clone()],
+ ConversationMessage::AssistantToolCalls { text, tool_calls } => {
+ let payload = serde_json::json!({
+ "content": text,
+ "tool_calls": tool_calls,
+ });
+ vec![ChatMessage::assistant(payload.to_string())]
+ }
+ ConversationMessage::ToolResults(results) => results
+ .iter()
+ .map(|result| {
+ ChatMessage::tool(
+ serde_json::json!({
+ "tool_call_id": result.tool_call_id,
+ "content": result.content,
+ })
+ .to_string(),
+ )
+ })
+ .collect(),
+ })
+ .collect()
+ }
+
+ fn should_send_tool_specs(&self) -> bool {
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn xml_dispatcher_parses_tool_calls() {
+ let response = ChatResponse {
+ text: Some(
+ "Checking\n{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}} "
+ .into(),
+ ),
+ tool_calls: vec![],
+ };
+ let dispatcher = XmlToolDispatcher;
+ let (_, calls) = dispatcher.parse_response(&response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ }
+
+ #[test]
+ fn native_dispatcher_roundtrip() {
+ let response = ChatResponse {
+ text: Some("ok".into()),
+ tool_calls: vec![crate::alphahuman::providers::ToolCall {
+ id: "tc1".into(),
+ name: "file_read".into(),
+ arguments: "{\"path\":\"a.txt\"}".into(),
+ }],
+ };
+ let dispatcher = NativeToolDispatcher;
+ let (_, calls) = dispatcher.parse_response(&response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].tool_call_id.as_deref(), Some("tc1"));
+
+ let msg = dispatcher.format_results(&[ToolExecutionResult {
+ name: "file_read".into(),
+ output: "hello".into(),
+ success: true,
+ tool_call_id: Some("tc1".into()),
+ }]);
+ match msg {
+ ConversationMessage::ToolResults(results) => {
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].tool_call_id, "tc1");
+ }
+ _ => panic!("expected tool results"),
+ }
+ }
+
+ #[test]
+ fn xml_format_results_contains_tool_result_tags() {
+ let dispatcher = XmlToolDispatcher;
+ let msg = dispatcher.format_results(&[ToolExecutionResult {
+ name: "shell".into(),
+ output: "ok".into(),
+ success: true,
+ tool_call_id: None,
+ }]);
+ let rendered = match msg {
+ ConversationMessage::Chat(chat) => chat.content,
+ _ => String::new(),
+ };
+ assert!(rendered.contains(" {
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].tool_call_id, "tc-1");
+ }
+ _ => panic!("expected ToolResults variant"),
+ }
+ }
+}
diff --git a/src-tauri/src/alphahuman/agent/loop_.rs b/src-tauri/src/alphahuman/agent/loop_.rs
new file mode 100644
index 000000000..97b6d2f97
--- /dev/null
+++ b/src-tauri/src/alphahuman/agent/loop_.rs
@@ -0,0 +1,2921 @@
+use crate::alphahuman::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse};
+use crate::alphahuman::config::Config;
+use crate::alphahuman::memory::{self, Memory, MemoryCategory};
+use crate::alphahuman::multimodal;
+use crate::alphahuman::observability::{self, Observer, ObserverEvent};
+use crate::alphahuman::providers::{
+ self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall,
+};
+use crate::alphahuman::runtime;
+use crate::alphahuman::security::SecurityPolicy;
+use crate::alphahuman::tools::{self, Tool};
+use crate::alphahuman::util::truncate_with_ellipsis;
+use anyhow::Result;
+use regex::{Regex, RegexSet};
+use std::fmt::Write;
+use std::io::Write as _;
+use std::sync::{Arc, LazyLock};
+use std::time::Instant;
+use uuid::Uuid;
+
+/// Minimum characters per chunk when relaying LLM text to a streaming draft.
+const STREAM_CHUNK_MIN_CHARS: usize = 80;
+
+/// Default maximum agentic tool-use iterations per user message to prevent runaway loops.
+/// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero.
+const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
+
+static SENSITIVE_KEY_PATTERNS: LazyLock = LazyLock::new(|| {
+ RegexSet::new([
+ r"(?i)token",
+ r"(?i)api[_-]?key",
+ r"(?i)password",
+ r"(?i)secret",
+ r"(?i)user[_-]?key",
+ r"(?i)bearer",
+ r"(?i)credential",
+ ])
+ .unwrap()
+});
+
+static SENSITIVE_KV_REGEX: LazyLock = LazyLock::new(|| {
+ Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#).unwrap()
+});
+
+/// Scrub credentials from tool output to prevent accidental exfiltration.
+/// Replaces known credential patterns with a redacted placeholder while preserving
+/// a small prefix for context.
+fn scrub_credentials(input: &str) -> String {
+ SENSITIVE_KV_REGEX
+ .replace_all(input, |caps: ®ex::Captures| {
+ let full_match = &caps[0];
+ let key = &caps[1];
+ let val = caps
+ .get(2)
+ .or(caps.get(3))
+ .or(caps.get(4))
+ .map(|m| m.as_str())
+ .unwrap_or("");
+
+ // Preserve first 4 chars for context, then redact
+ let prefix = if val.len() > 4 { &val[..4] } else { "" };
+
+ if full_match.contains(':') {
+ if full_match.contains('"') {
+ format!("\"{}\": \"{}*[REDACTED]\"", key, prefix)
+ } else {
+ format!("{}: {}*[REDACTED]", key, prefix)
+ }
+ } else if full_match.contains('=') {
+ if full_match.contains('"') {
+ format!("{}=\"{}*[REDACTED]\"", key, prefix)
+ } else {
+ format!("{}={}*[REDACTED]", key, prefix)
+ }
+ } else {
+ format!("{}: {}*[REDACTED]", key, prefix)
+ }
+ })
+ .to_string()
+}
+
+/// Default trigger for auto-compaction when non-system message count exceeds this threshold.
+/// Prefer passing the config-driven value via `run_tool_call_loop`; this constant is only
+/// used when callers omit the parameter.
+const DEFAULT_MAX_HISTORY_MESSAGES: usize = 50;
+
+/// Keep this many most-recent non-system messages after compaction.
+const COMPACTION_KEEP_RECENT_MESSAGES: usize = 20;
+
+/// Safety cap for compaction source transcript passed to the summarizer.
+const COMPACTION_MAX_SOURCE_CHARS: usize = 12_000;
+
+/// Max characters retained in stored compaction summary.
+const COMPACTION_MAX_SUMMARY_CHARS: usize = 2_000;
+
+/// Convert a tool registry to OpenAI function-calling format for native tool support.
+fn tools_to_openai_format(tools_registry: &[Box]) -> Vec {
+ tools_registry
+ .iter()
+ .map(|tool| {
+ serde_json::json!({
+ "type": "function",
+ "function": {
+ "name": tool.name(),
+ "description": tool.description(),
+ "parameters": tool.parameters_schema()
+ }
+ })
+ })
+ .collect()
+}
+
+fn autosave_memory_key(prefix: &str) -> String {
+ format!("{prefix}_{}", Uuid::new_v4())
+}
+
+/// Trim conversation history to prevent unbounded growth.
+/// Preserves the system prompt (first message if role=system) and the most recent messages.
+fn trim_history(history: &mut Vec, max_history: usize) {
+ // Nothing to trim if within limit
+ let has_system = history.first().map_or(false, |m| m.role == "system");
+ let non_system_count = if has_system {
+ history.len() - 1
+ } else {
+ history.len()
+ };
+
+ if non_system_count <= max_history {
+ return;
+ }
+
+ let start = if has_system { 1 } else { 0 };
+ let to_remove = non_system_count - max_history;
+ history.drain(start..start + to_remove);
+}
+
+fn build_compaction_transcript(messages: &[ChatMessage]) -> String {
+ let mut transcript = String::new();
+ for msg in messages {
+ let role = msg.role.to_uppercase();
+ let _ = writeln!(transcript, "{role}: {}", msg.content.trim());
+ }
+
+ if transcript.chars().count() > COMPACTION_MAX_SOURCE_CHARS {
+ truncate_with_ellipsis(&transcript, COMPACTION_MAX_SOURCE_CHARS)
+ } else {
+ transcript
+ }
+}
+
+fn apply_compaction_summary(
+ history: &mut Vec,
+ start: usize,
+ compact_end: usize,
+ summary: &str,
+) {
+ let summary_msg = ChatMessage::assistant(format!("[Compaction summary]\n{}", summary.trim()));
+ history.splice(start..compact_end, std::iter::once(summary_msg));
+}
+
+async fn auto_compact_history(
+ history: &mut Vec,
+ provider: &dyn Provider,
+ model: &str,
+ max_history: usize,
+) -> Result {
+ let has_system = history.first().map_or(false, |m| m.role == "system");
+ let non_system_count = if has_system {
+ history.len().saturating_sub(1)
+ } else {
+ history.len()
+ };
+
+ if non_system_count <= max_history {
+ return Ok(false);
+ }
+
+ let start = if has_system { 1 } else { 0 };
+ let keep_recent = COMPACTION_KEEP_RECENT_MESSAGES.min(non_system_count);
+ let compact_count = non_system_count.saturating_sub(keep_recent);
+ if compact_count == 0 {
+ return Ok(false);
+ }
+
+ let compact_end = start + compact_count;
+ let to_compact: Vec = history[start..compact_end].to_vec();
+ let transcript = build_compaction_transcript(&to_compact);
+
+ let summarizer_system = "You are a conversation compaction engine. Summarize older chat history into concise context for future turns. Preserve: user preferences, commitments, decisions, unresolved tasks, key facts. Omit: filler, repeated chit-chat, verbose tool logs. Output plain text bullet points only.";
+
+ let summarizer_user = format!(
+ "Summarize the following conversation history for context preservation. Keep it short (max 12 bullet points).\n\n{}",
+ transcript
+ );
+
+ let summary_raw = provider
+ .chat_with_system(Some(summarizer_system), &summarizer_user, model, 0.2)
+ .await
+ .unwrap_or_else(|_| {
+ // Fallback to deterministic local truncation when summarization fails.
+ truncate_with_ellipsis(&transcript, COMPACTION_MAX_SUMMARY_CHARS)
+ });
+
+ let summary = truncate_with_ellipsis(&summary_raw, COMPACTION_MAX_SUMMARY_CHARS);
+ apply_compaction_summary(history, start, compact_end, &summary);
+
+ Ok(true)
+}
+
+/// Build context preamble by searching memory for relevant entries.
+/// Entries with a hybrid score below `min_relevance_score` are dropped to
+/// prevent unrelated memories from bleeding into the conversation.
+async fn build_context(mem: &dyn Memory, user_msg: &str, min_relevance_score: f64) -> String {
+ let mut context = String::new();
+
+ // Pull relevant memories for this message
+ if let Ok(entries) = mem.recall(user_msg, 5, None).await {
+ let relevant: Vec<_> = entries
+ .iter()
+ .filter(|e| match e.score {
+ Some(score) => score >= min_relevance_score,
+ None => true,
+ })
+ .collect();
+
+ if !relevant.is_empty() {
+ context.push_str("[Memory context]\n");
+ for entry in &relevant {
+ let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
+ }
+ context.push('\n');
+ }
+ }
+
+ context
+}
+
+/// Build hardware datasheet context from RAG when peripherals are enabled.
+/// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks.
+fn build_hardware_context(
+ rag: &crate::alphahuman::rag::HardwareRag,
+ user_msg: &str,
+ boards: &[String],
+ chunk_limit: usize,
+) -> String {
+ if rag.is_empty() || boards.is_empty() {
+ return String::new();
+ }
+
+ let mut context = String::new();
+
+ // Pin aliases: when user says "red led", inject "red_led: 13" for matching boards
+ let pin_ctx = rag.pin_alias_context(user_msg, boards);
+ if !pin_ctx.is_empty() {
+ context.push_str(&pin_ctx);
+ }
+
+ let chunks = rag.retrieve(user_msg, boards, chunk_limit);
+ if chunks.is_empty() && pin_ctx.is_empty() {
+ return String::new();
+ }
+
+ if !chunks.is_empty() {
+ context.push_str("[Hardware documentation]\n");
+ }
+ for chunk in chunks {
+ let board_tag = chunk.board.as_deref().unwrap_or("generic");
+ let _ = writeln!(
+ context,
+ "--- {} ({}) ---\n{}\n",
+ chunk.source, board_tag, chunk.content
+ );
+ }
+ context.push('\n');
+ context
+}
+
+/// Find a tool by name in the registry.
+fn find_tool<'a>(tools: &'a [Box], name: &str) -> Option<&'a dyn Tool> {
+ tools.iter().find(|t| t.name() == name).map(|t| t.as_ref())
+}
+
+fn parse_arguments_value(raw: Option<&serde_json::Value>) -> serde_json::Value {
+ match raw {
+ Some(serde_json::Value::String(s)) => serde_json::from_str::(s)
+ .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
+ Some(value) => value.clone(),
+ None => serde_json::Value::Object(serde_json::Map::new()),
+ }
+}
+
+fn parse_tool_call_value(value: &serde_json::Value) -> Option {
+ if let Some(function) = value.get("function") {
+ let name = function
+ .get("name")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .trim()
+ .to_string();
+ if !name.is_empty() {
+ let arguments = parse_arguments_value(function.get("arguments"));
+ return Some(ParsedToolCall { name, arguments });
+ }
+ }
+
+ let name = value
+ .get("name")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .trim()
+ .to_string();
+
+ if name.is_empty() {
+ return None;
+ }
+
+ let arguments = parse_arguments_value(value.get("arguments"));
+ Some(ParsedToolCall { name, arguments })
+}
+
+fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec {
+ let mut calls = Vec::new();
+
+ if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
+ for call in tool_calls {
+ if let Some(parsed) = parse_tool_call_value(call) {
+ calls.push(parsed);
+ }
+ }
+
+ if !calls.is_empty() {
+ return calls;
+ }
+ }
+
+ if let Some(array) = value.as_array() {
+ for item in array {
+ if let Some(parsed) = parse_tool_call_value(item) {
+ calls.push(parsed);
+ }
+ }
+ return calls;
+ }
+
+ if let Some(parsed) = parse_tool_call_value(value) {
+ calls.push(parsed);
+ }
+
+ calls
+}
+
+const TOOL_CALL_OPEN_TAGS: [&str; 4] = ["", "", "", ""];
+
+fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
+ tags.iter()
+ .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
+ .min_by_key(|(idx, _)| *idx)
+}
+
+fn matching_tool_call_close_tag(open_tag: &str) -> Option<&'static str> {
+ match open_tag {
+ "" => Some(" "),
+ "" => Some(" "),
+ "" => Some(" "),
+ "" => Some(" "),
+ _ => None,
+ }
+}
+
+fn extract_first_json_value_with_end(input: &str) -> Option<(serde_json::Value, usize)> {
+ let trimmed = input.trim_start();
+ let trim_offset = input.len().saturating_sub(trimmed.len());
+
+ for (byte_idx, ch) in trimmed.char_indices() {
+ if ch != '{' && ch != '[' {
+ continue;
+ }
+
+ let slice = &trimmed[byte_idx..];
+ let mut stream = serde_json::Deserializer::from_str(slice).into_iter::();
+ if let Some(Ok(value)) = stream.next() {
+ let consumed = stream.byte_offset();
+ if consumed > 0 {
+ return Some((value, trim_offset + byte_idx + consumed));
+ }
+ }
+ }
+
+ None
+}
+
+fn strip_leading_close_tags(mut input: &str) -> &str {
+ loop {
+ let trimmed = input.trim_start();
+ if !trimmed.starts_with("") {
+ return trimmed;
+ }
+
+ let Some(close_end) = trimmed.find('>') else {
+ return "";
+ };
+ input = &trimmed[close_end + 1..];
+ }
+}
+
+/// Extract JSON values from a string.
+///
+/// # Security Warning
+///
+/// This function extracts ANY JSON objects/arrays from the input. It MUST only
+/// be used on content that is already trusted to be from the LLM, such as
+/// content inside `` tags where the LLM has explicitly indicated intent
+/// to make a tool call. Do NOT use this on raw user input or content that
+/// could contain prompt injection payloads.
+fn extract_json_values(input: &str) -> Vec {
+ let mut values = Vec::new();
+ let trimmed = input.trim();
+ if trimmed.is_empty() {
+ return values;
+ }
+
+ if let Ok(value) = serde_json::from_str::(trimmed) {
+ values.push(value);
+ return values;
+ }
+
+ let char_positions: Vec<(usize, char)> = trimmed.char_indices().collect();
+ let mut idx = 0;
+ while idx < char_positions.len() {
+ let (byte_idx, ch) = char_positions[idx];
+ if ch == '{' || ch == '[' {
+ let slice = &trimmed[byte_idx..];
+ let mut stream =
+ serde_json::Deserializer::from_str(slice).into_iter::();
+ if let Some(Ok(value)) = stream.next() {
+ let consumed = stream.byte_offset();
+ if consumed > 0 {
+ values.push(value);
+ let next_byte = byte_idx + consumed;
+ while idx < char_positions.len() && char_positions[idx].0 < next_byte {
+ idx += 1;
+ }
+ continue;
+ }
+ }
+ }
+ idx += 1;
+ }
+
+ values
+}
+
+/// Find the end position of a JSON object by tracking balanced braces.
+fn find_json_end(input: &str) -> Option {
+ let trimmed = input.trim_start();
+ let offset = input.len() - trimmed.len();
+
+ if !trimmed.starts_with('{') {
+ return None;
+ }
+
+ let mut depth = 0;
+ let mut in_string = false;
+ let mut escape_next = false;
+
+ for (i, ch) in trimmed.char_indices() {
+ if escape_next {
+ escape_next = false;
+ continue;
+ }
+
+ match ch {
+ '\\' if in_string => escape_next = true,
+ '"' => in_string = !in_string,
+ '{' if !in_string => depth += 1,
+ '}' if !in_string => {
+ depth -= 1;
+ if depth == 0 {
+ return Some(offset + i + ch.len_utf8());
+ }
+ }
+ _ => {}
+ }
+ }
+
+ None
+}
+
+/// Parse GLM-style tool calls from response text.
+/// GLM uses proprietary formats like:
+/// - `browser_open/url>https://example.com`
+/// - `shell/command>ls -la`
+/// - `http_request/url>https://api.example.com`
+fn map_glm_tool_alias(tool_name: &str) -> &str {
+ match tool_name {
+ "browser_open" | "browser" | "web_search" | "shell" | "bash" => "shell",
+ "http_request" | "http" => "http_request",
+ _ => tool_name,
+ }
+}
+
+fn build_curl_command(url: &str) -> Option {
+ if !(url.starts_with("http://") || url.starts_with("https://")) {
+ return None;
+ }
+
+ if url.chars().any(char::is_whitespace) {
+ return None;
+ }
+
+ let escaped = url.replace('\'', r#"'\\''"#);
+ Some(format!("curl -s '{}'", escaped))
+}
+
+fn parse_glm_style_tool_calls(text: &str) -> Vec<(String, serde_json::Value, Option)> {
+ let mut calls = Vec::new();
+
+ for line in text.lines() {
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+
+ // Format: tool_name/param>value or tool_name/{json}
+ if let Some(pos) = line.find('/') {
+ let tool_part = &line[..pos];
+ let rest = &line[pos + 1..];
+
+ if tool_part.chars().all(|c| c.is_alphanumeric() || c == '_') {
+ let tool_name = map_glm_tool_alias(tool_part);
+
+ if let Some(gt_pos) = rest.find('>') {
+ let param_name = rest[..gt_pos].trim();
+ let value = rest[gt_pos + 1..].trim();
+
+ let arguments = match tool_name {
+ "shell" => {
+ if param_name == "url" {
+ let Some(command) = build_curl_command(value) else {
+ continue;
+ };
+ serde_json::json!({"command": command})
+ } else if value.starts_with("http://") || value.starts_with("https://")
+ {
+ if let Some(command) = build_curl_command(value) {
+ serde_json::json!({"command": command})
+ } else {
+ serde_json::json!({"command": value})
+ }
+ } else {
+ serde_json::json!({"command": value})
+ }
+ }
+ "http_request" => {
+ serde_json::json!({"url": value, "method": "GET"})
+ }
+ _ => serde_json::json!({param_name: value}),
+ };
+
+ calls.push((tool_name.to_string(), arguments, Some(line.to_string())));
+ continue;
+ }
+
+ if rest.starts_with('{') {
+ if let Ok(json_args) = serde_json::from_str::(rest) {
+ calls.push((tool_name.to_string(), json_args, Some(line.to_string())));
+ }
+ }
+ }
+ }
+
+ // Plain URL
+ if let Some(command) = build_curl_command(line) {
+ calls.push((
+ "shell".to_string(),
+ serde_json::json!({"command": command}),
+ Some(line.to_string()),
+ ));
+ }
+ }
+
+ calls
+}
+
+/// Parse tool calls from an LLM response that uses XML-style function calling.
+///
+/// Expected format (common with system-prompt-guided tool use):
+/// ```text
+///
+/// {"name": "shell", "arguments": {"command": "ls"}}
+///
+/// ```
+///
+/// Also accepts common tag variants (``, ``) for model
+/// compatibility.
+///
+/// Also supports JSON with `tool_calls` array from OpenAI-format responses.
+fn parse_tool_calls(response: &str) -> (String, Vec) {
+ let mut text_parts = Vec::new();
+ let mut calls = Vec::new();
+ let mut remaining = response;
+
+ // First, try to parse as OpenAI-style JSON response with tool_calls array
+ // This handles providers like Minimax that return tool_calls in native JSON format
+ if let Ok(json_value) = serde_json::from_str::(response.trim()) {
+ calls = parse_tool_calls_from_json_value(&json_value);
+ if !calls.is_empty() {
+ // If we found tool_calls, extract any content field as text
+ if let Some(content) = json_value.get("content").and_then(|v| v.as_str()) {
+ if !content.trim().is_empty() {
+ text_parts.push(content.trim().to_string());
+ }
+ }
+ return (text_parts.join("\n"), calls);
+ }
+ }
+
+ // Fall back to XML-style tool-call tag parsing.
+ while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
+ // Everything before the tag is text
+ let before = &remaining[..start];
+ if !before.trim().is_empty() {
+ text_parts.push(before.trim().to_string());
+ }
+
+ let Some(close_tag) = matching_tool_call_close_tag(open_tag) else {
+ break;
+ };
+
+ let after_open = &remaining[start + open_tag.len()..];
+ if let Some(close_idx) = after_open.find(close_tag) {
+ let inner = &after_open[..close_idx];
+ let mut parsed_any = false;
+ let json_values = extract_json_values(inner);
+ for value in json_values {
+ let parsed_calls = parse_tool_calls_from_json_value(&value);
+ if !parsed_calls.is_empty() {
+ parsed_any = true;
+ calls.extend(parsed_calls);
+ }
+ }
+
+ if !parsed_any {
+ tracing::warn!("Malformed JSON: expected tool-call object in tag body");
+ }
+
+ remaining = &after_open[close_idx + close_tag.len()..];
+ } else {
+ if let Some(json_end) = find_json_end(after_open) {
+ if let Ok(value) =
+ serde_json::from_str::(&after_open[..json_end])
+ {
+ let parsed_calls = parse_tool_calls_from_json_value(&value);
+ if !parsed_calls.is_empty() {
+ calls.extend(parsed_calls);
+ remaining = strip_leading_close_tags(&after_open[json_end..]);
+ continue;
+ }
+ }
+ }
+
+ if let Some((value, consumed_end)) = extract_first_json_value_with_end(after_open) {
+ let parsed_calls = parse_tool_calls_from_json_value(&value);
+ if !parsed_calls.is_empty() {
+ calls.extend(parsed_calls);
+ remaining = strip_leading_close_tags(&after_open[consumed_end..]);
+ continue;
+ }
+ }
+
+ remaining = &remaining[start..];
+ break;
+ }
+ }
+
+ // If XML tags found nothing, try markdown code blocks with tool_call language.
+ // Models behind OpenRouter sometimes output ```tool_call ... ``` or hybrid
+ // ```tool_call ... instead of structured API calls or XML tags.
+ if calls.is_empty() {
+ static MD_TOOL_CALL_RE: LazyLock = LazyLock::new(|| {
+ Regex::new(
+ r"(?s)```(?:tool[_-]?call|invoke)\s*\n(.*?)(?:```|| | )",
+ )
+ .unwrap()
+ });
+ let mut md_text_parts: Vec = Vec::new();
+ let mut last_end = 0;
+
+ for cap in MD_TOOL_CALL_RE.captures_iter(response) {
+ let full_match = cap.get(0).unwrap();
+ let before = &response[last_end..full_match.start()];
+ if !before.trim().is_empty() {
+ md_text_parts.push(before.trim().to_string());
+ }
+ let inner = &cap[1];
+ let json_values = extract_json_values(inner);
+ for value in json_values {
+ let parsed_calls = parse_tool_calls_from_json_value(&value);
+ calls.extend(parsed_calls);
+ }
+ last_end = full_match.end();
+ }
+
+ if !calls.is_empty() {
+ let after = &response[last_end..];
+ if !after.trim().is_empty() {
+ md_text_parts.push(after.trim().to_string());
+ }
+ text_parts = md_text_parts;
+ remaining = "";
+ }
+ }
+
+ // GLM-style tool calls (browser_open/url>https://..., shell/command>ls, etc.)
+ if calls.is_empty() {
+ let glm_calls = parse_glm_style_tool_calls(remaining);
+ if !glm_calls.is_empty() {
+ let mut cleaned_text = remaining.to_string();
+ for (name, args, raw) in &glm_calls {
+ calls.push(ParsedToolCall {
+ name: name.clone(),
+ arguments: args.clone(),
+ });
+ if let Some(r) = raw {
+ cleaned_text = cleaned_text.replace(r, "");
+ }
+ }
+ if !cleaned_text.trim().is_empty() {
+ text_parts.push(cleaned_text.trim().to_string());
+ }
+ remaining = "";
+ }
+ }
+
+ // SECURITY: We do NOT fall back to extracting arbitrary JSON from the response
+ // here. That would enable prompt injection attacks where malicious content
+ // (e.g., in emails, files, or web pages) could include JSON that mimics a
+ // tool call. Tool calls MUST be explicitly wrapped in either:
+ // 1. OpenAI-style JSON with a "tool_calls" array
+ // 2. Alphahuman tool-call tags (, , )
+ // 3. Markdown code blocks with tool_call/toolcall/tool-call language
+ // 4. Explicit GLM line-based call formats (e.g. `shell/command>...`)
+ // This ensures only the LLM's intentional tool calls are executed.
+
+ // Remaining text after last tool call
+ if !remaining.trim().is_empty() {
+ text_parts.push(remaining.trim().to_string());
+ }
+
+ (text_parts.join("\n"), calls)
+}
+
+fn parse_structured_tool_calls(tool_calls: &[ToolCall]) -> Vec {
+ tool_calls
+ .iter()
+ .map(|call| ParsedToolCall {
+ name: call.name.clone(),
+ arguments: serde_json::from_str::(&call.arguments)
+ .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
+ })
+ .collect()
+}
+
+/// Build assistant history entry in JSON format for native tool-call APIs.
+/// `convert_messages` in the OpenRouter provider parses this JSON to reconstruct
+/// the proper `NativeMessage` with structured `tool_calls`.
+fn build_native_assistant_history(text: &str, tool_calls: &[ToolCall]) -> String {
+ let calls_json: Vec = tool_calls
+ .iter()
+ .map(|tc| {
+ serde_json::json!({
+ "id": tc.id,
+ "name": tc.name,
+ "arguments": tc.arguments,
+ })
+ })
+ .collect();
+
+ let content = if text.trim().is_empty() {
+ serde_json::Value::Null
+ } else {
+ serde_json::Value::String(text.trim().to_string())
+ };
+
+ serde_json::json!({
+ "content": content,
+ "tool_calls": calls_json,
+ })
+ .to_string()
+}
+
+fn build_assistant_history_with_tool_calls(text: &str, tool_calls: &[ToolCall]) -> String {
+ let mut parts = Vec::new();
+
+ if !text.trim().is_empty() {
+ parts.push(text.trim().to_string());
+ }
+
+ for call in tool_calls {
+ let arguments = serde_json::from_str::(&call.arguments)
+ .unwrap_or_else(|_| serde_json::Value::String(call.arguments.clone()));
+ let payload = serde_json::json!({
+ "id": call.id,
+ "name": call.name,
+ "arguments": arguments,
+ });
+ parts.push(format!("\n{payload}\n "));
+ }
+
+ parts.join("\n")
+}
+
+#[derive(Debug)]
+struct ParsedToolCall {
+ name: String,
+ arguments: serde_json::Value,
+}
+
+/// Execute a single turn of the agent loop: send messages, parse tool calls,
+/// execute tools, and loop until the LLM produces a final text response.
+/// When `silent` is true, suppresses stdout (for channel use).
+#[allow(clippy::too_many_arguments)]
+pub(crate) async fn agent_turn(
+ provider: &dyn Provider,
+ history: &mut Vec,
+ tools_registry: &[Box],
+ observer: &dyn Observer,
+ provider_name: &str,
+ model: &str,
+ temperature: f64,
+ silent: bool,
+ multimodal_config: &crate::alphahuman::config::MultimodalConfig,
+ max_tool_iterations: usize,
+) -> Result {
+ run_tool_call_loop(
+ provider,
+ history,
+ tools_registry,
+ observer,
+ provider_name,
+ model,
+ temperature,
+ silent,
+ None,
+ "channel",
+ multimodal_config,
+ max_tool_iterations,
+ None,
+ )
+ .await
+}
+
+/// Execute a single turn of the agent loop: send messages, parse tool calls,
+/// execute tools, and loop until the LLM produces a final text response.
+#[allow(clippy::too_many_arguments)]
+pub(crate) async fn run_tool_call_loop(
+ provider: &dyn Provider,
+ history: &mut Vec,
+ tools_registry: &[Box],
+ observer: &dyn Observer,
+ provider_name: &str,
+ model: &str,
+ temperature: f64,
+ silent: bool,
+ approval: Option<&ApprovalManager>,
+ channel_name: &str,
+ multimodal_config: &crate::alphahuman::config::MultimodalConfig,
+ max_tool_iterations: usize,
+ on_delta: Option>,
+) -> Result {
+ let max_iterations = if max_tool_iterations == 0 {
+ DEFAULT_MAX_TOOL_ITERATIONS
+ } else {
+ max_tool_iterations
+ };
+
+ let tool_specs: Vec =
+ tools_registry.iter().map(|tool| tool.spec()).collect();
+ let use_native_tools = provider.supports_native_tools() && !tool_specs.is_empty();
+
+ for _iteration in 0..max_iterations {
+ let image_marker_count = multimodal::count_image_markers(history);
+ if image_marker_count > 0 && !provider.supports_vision() {
+ return Err(ProviderCapabilityError {
+ provider: provider_name.to_string(),
+ capability: "vision".to_string(),
+ message: format!(
+ "received {image_marker_count} image marker(s), but this provider does not support vision input"
+ ),
+ }
+ .into());
+ }
+
+ let prepared_messages =
+ multimodal::prepare_messages_for_provider(history, multimodal_config).await?;
+
+ observer.record_event(&ObserverEvent::LlmRequest {
+ provider: provider_name.to_string(),
+ model: model.to_string(),
+ messages_count: history.len(),
+ });
+
+ let llm_started_at = Instant::now();
+
+ // Unified path via Provider::chat so provider-specific native tool logic
+ // (OpenAI/Anthropic/OpenRouter/compatible adapters) is honored.
+ let request_tools = if use_native_tools {
+ Some(tool_specs.as_slice())
+ } else {
+ None
+ };
+
+ let (response_text, parsed_text, tool_calls, assistant_history_content, native_tool_calls) =
+ match provider
+ .chat(
+ ChatRequest {
+ messages: &prepared_messages.messages,
+ tools: request_tools,
+ },
+ model,
+ temperature,
+ )
+ .await
+ {
+ Ok(resp) => {
+ observer.record_event(&ObserverEvent::LlmResponse {
+ provider: provider_name.to_string(),
+ model: model.to_string(),
+ duration: llm_started_at.elapsed(),
+ success: true,
+ error_message: None,
+ });
+
+ let response_text = resp.text_or_empty().to_string();
+ let mut calls = parse_structured_tool_calls(&resp.tool_calls);
+ let mut parsed_text = String::new();
+
+ if calls.is_empty() {
+ let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
+ if !fallback_text.is_empty() {
+ parsed_text = fallback_text;
+ }
+ calls = fallback_calls;
+ }
+
+ // Preserve native tool call IDs in assistant history so role=tool
+ // follow-up messages can reference the exact call id.
+ let assistant_history_content = if resp.tool_calls.is_empty() {
+ response_text.clone()
+ } else {
+ build_native_assistant_history(&response_text, &resp.tool_calls)
+ };
+
+ let native_calls = resp.tool_calls;
+ (
+ response_text,
+ parsed_text,
+ calls,
+ assistant_history_content,
+ native_calls,
+ )
+ }
+ Err(e) => {
+ observer.record_event(&ObserverEvent::LlmResponse {
+ provider: provider_name.to_string(),
+ model: model.to_string(),
+ duration: llm_started_at.elapsed(),
+ success: false,
+ error_message: Some(crate::alphahuman::providers::sanitize_api_error(&e.to_string())),
+ });
+ return Err(e);
+ }
+ };
+
+ let display_text = if parsed_text.is_empty() {
+ response_text.clone()
+ } else {
+ parsed_text
+ };
+
+ if tool_calls.is_empty() {
+ // No tool calls — this is the final response.
+ // If a streaming sender is provided, relay the text in small chunks
+ // so the channel can progressively update the draft message.
+ if let Some(ref tx) = on_delta {
+ // Split on whitespace boundaries, accumulating chunks of at least
+ // STREAM_CHUNK_MIN_CHARS characters for progressive draft updates.
+ let mut chunk = String::new();
+ for word in display_text.split_inclusive(char::is_whitespace) {
+ chunk.push_str(word);
+ if chunk.len() >= STREAM_CHUNK_MIN_CHARS
+ && tx.send(std::mem::take(&mut chunk)).await.is_err()
+ {
+ break; // receiver dropped
+ }
+ }
+ if !chunk.is_empty() {
+ let _ = tx.send(chunk).await;
+ }
+ }
+ history.push(ChatMessage::assistant(response_text.clone()));
+ return Ok(display_text);
+ }
+
+ // Print any text the LLM produced alongside tool calls (unless silent)
+ if !silent && !display_text.is_empty() {
+ print!("{display_text}");
+ let _ = std::io::stdout().flush();
+ }
+
+ // Execute each tool call and build results.
+ // `individual_results` tracks per-call output so that native-mode history
+ // can emit one `role: tool` message per tool call with the correct ID.
+ let mut tool_results = String::new();
+ let mut individual_results: Vec = Vec::new();
+ for call in &tool_calls {
+ // ── Approval hook ────────────────────────────────
+ if let Some(mgr) = approval {
+ if mgr.needs_approval(&call.name) {
+ let request = ApprovalRequest {
+ tool_name: call.name.clone(),
+ arguments: call.arguments.clone(),
+ };
+
+ // Only prompt interactively when approvals are supported; auto-approve on other channels.
+ let decision = if channel_name == "cli" {
+ mgr.prompt_cli(&request)
+ } else {
+ ApprovalResponse::Yes
+ };
+
+ mgr.record_decision(&call.name, &call.arguments, decision, channel_name);
+
+ if decision == ApprovalResponse::No {
+ let denied = "Denied by user.".to_string();
+ individual_results.push(denied.clone());
+ let _ = writeln!(
+ tool_results,
+ "\n{denied}\n ",
+ call.name
+ );
+ continue;
+ }
+ }
+ }
+
+ observer.record_event(&ObserverEvent::ToolCallStart {
+ tool: call.name.clone(),
+ });
+ let start = Instant::now();
+ let result = if let Some(tool) = find_tool(tools_registry, &call.name) {
+ match tool.execute(call.arguments.clone()).await {
+ Ok(r) => {
+ observer.record_event(&ObserverEvent::ToolCall {
+ tool: call.name.clone(),
+ duration: start.elapsed(),
+ success: r.success,
+ });
+ if r.success {
+ scrub_credentials(&r.output)
+ } else {
+ format!("Error: {}", r.error.unwrap_or_else(|| r.output))
+ }
+ }
+ Err(e) => {
+ observer.record_event(&ObserverEvent::ToolCall {
+ tool: call.name.clone(),
+ duration: start.elapsed(),
+ success: false,
+ });
+ format!("Error executing {}: {e}", call.name)
+ }
+ }
+ } else {
+ format!("Unknown tool: {}", call.name)
+ };
+
+ individual_results.push(result.clone());
+ let _ = writeln!(
+ tool_results,
+ "\n{}\n ",
+ call.name, result
+ );
+ }
+
+ // Add assistant message with tool calls + tool results to history.
+ // Native mode: use JSON-structured messages so convert_messages() can
+ // reconstruct proper OpenAI-format tool_calls and tool result messages.
+ // Prompt mode: use XML-based text format as before.
+ history.push(ChatMessage::assistant(assistant_history_content));
+ if native_tool_calls.is_empty() {
+ history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}")));
+ } else {
+ for (native_call, result) in native_tool_calls.iter().zip(individual_results.iter()) {
+ let tool_msg = serde_json::json!({
+ "tool_call_id": native_call.id,
+ "content": result,
+ });
+ history.push(ChatMessage::tool(tool_msg.to_string()));
+ }
+ }
+ }
+
+ anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
+}
+
+/// Build the tool instruction block for the system prompt so the LLM knows
+/// how to invoke tools.
+pub(crate) fn build_tool_instructions(tools_registry: &[Box]) -> String {
+ let mut instructions = String::new();
+ instructions.push_str("\n## Tool Use Protocol\n\n");
+ instructions.push_str("To use a tool, wrap a JSON object in tags:\n\n");
+ instructions.push_str("```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n \n```\n\n");
+ instructions.push_str(
+ "CRITICAL: Output actual tags—never describe steps or give examples.\n\n",
+ );
+ instructions.push_str("Example: User says \"what's the date?\". You MUST respond with:\n\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n \n\n");
+ instructions.push_str("You may use multiple tool calls in a single response. ");
+ instructions.push_str("After tool execution, results appear in tags. ");
+ instructions
+ .push_str("Continue reasoning with the results until you can give a final answer.\n\n");
+ instructions.push_str("### Available Tools\n\n");
+
+ for tool in tools_registry {
+ let _ = writeln!(
+ instructions,
+ "**{}**: {}\nParameters: `{}`\n",
+ tool.name(),
+ tool.description(),
+ tool.parameters_schema()
+ );
+ }
+
+ instructions
+}
+
+#[allow(clippy::too_many_lines)]
+pub async fn run(
+ config: Config,
+ message: Option,
+ provider_override: Option,
+ model_override: Option,
+ temperature: f64,
+ peripheral_overrides: Vec,
+) -> Result {
+ // ── Wire up agnostic subsystems ──────────────────────────────
+ let base_observer = observability::create_observer(&config.observability);
+ let observer: Arc = Arc::from(base_observer);
+ let runtime: Arc =
+ Arc::from(runtime::create_runtime(&config.runtime)?);
+ let security = Arc::new(SecurityPolicy::from_config(
+ &config.autonomy,
+ &config.workspace_dir,
+ ));
+
+ // ── Memory (the brain) ────────────────────────────────────────
+ let mem: Arc = Arc::from(memory::create_memory_with_storage(
+ &config.memory,
+ Some(&config.storage.provider.config),
+ &config.workspace_dir,
+ config.api_key.as_deref(),
+ )?);
+ tracing::info!(backend = mem.name(), "Memory initialized");
+
+ // ── Peripherals (merge peripheral tools into registry) ─
+ if !peripheral_overrides.is_empty() {
+ tracing::info!(
+ peripherals = ?peripheral_overrides,
+ "Peripheral overrides from runtime flags (config boards take precedence)"
+ );
+ }
+
+ // ── Tools (including memory tools and peripherals) ────────────
+ let (composio_key, composio_entity_id) = if config.composio.enabled {
+ (
+ config.composio.api_key.as_deref(),
+ Some(config.composio.entity_id.as_str()),
+ )
+ } else {
+ (None, None)
+ };
+ let mut tools_registry = tools::all_tools_with_runtime(
+ Arc::new(config.clone()),
+ &security,
+ runtime,
+ mem.clone(),
+ composio_key,
+ composio_entity_id,
+ &config.browser,
+ &config.http_request,
+ &config.workspace_dir,
+ &config.agents,
+ config.api_key.as_deref(),
+ &config,
+ );
+
+ let peripheral_tools: Vec> =
+ crate::alphahuman::peripherals::create_peripheral_tools(&config.peripherals).await?;
+ if !peripheral_tools.is_empty() {
+ tracing::info!(count = peripheral_tools.len(), "Peripheral tools added");
+ tools_registry.extend(peripheral_tools);
+ }
+
+ // ── Resolve provider ─────────────────────────────────────────
+ let provider_name = provider_override
+ .as_deref()
+ .or(config.default_provider.as_deref())
+ .unwrap_or("openrouter");
+
+ let model_name = model_override
+ .as_deref()
+ .or(config.default_model.as_deref())
+ .unwrap_or("anthropic/claude-sonnet-4");
+
+ let provider_runtime_options = providers::ProviderRuntimeOptions {
+ auth_profile_override: None,
+ alphahuman_dir: config.config_path.parent().map(std::path::PathBuf::from),
+ secrets_encrypt: config.secrets.encrypt,
+ reasoning_enabled: config.runtime.reasoning_enabled,
+ };
+
+ let provider: Box = providers::create_routed_provider_with_options(
+ provider_name,
+ config.api_key.as_deref(),
+ config.api_url.as_deref(),
+ &config.reliability,
+ &config.model_routes,
+ model_name,
+ &provider_runtime_options,
+ )?;
+
+ observer.record_event(&ObserverEvent::AgentStart {
+ provider: provider_name.to_string(),
+ model: model_name.to_string(),
+ });
+
+ // ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ──
+ let hardware_rag: Option = config
+ .peripherals
+ .datasheet_dir
+ .as_ref()
+ .filter(|d| !d.trim().is_empty())
+ .map(|dir| crate::alphahuman::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
+ .and_then(Result::ok)
+ .filter(|r: &crate::alphahuman::rag::HardwareRag| !r.is_empty());
+ if let Some(ref rag) = hardware_rag {
+ tracing::info!(chunks = rag.len(), "Hardware RAG loaded");
+ }
+
+ let board_names: Vec = config
+ .peripherals
+ .boards
+ .iter()
+ .map(|b| b.board.clone())
+ .collect();
+
+ // ── Build system prompt from workspace MD files (OpenClaw framework) ──
+ let skills = crate::alphahuman::skills::load_skills(&config.workspace_dir);
+ let mut tool_descs: Vec<(&str, &str)> = vec![
+ (
+ "shell",
+ "Execute terminal commands. Use when: running local checks, build/test commands, diagnostics. Don't use when: a safer dedicated tool exists, or command is destructive without approval.",
+ ),
+ (
+ "file_read",
+ "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
+ ),
+ (
+ "file_write",
+ "Write file contents. Use when: applying focused edits, scaffolding files, updating docs/code. Don't use when: side effects are unclear or file ownership is uncertain.",
+ ),
+ (
+ "memory_store",
+ "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
+ ),
+ (
+ "memory_recall",
+ "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
+ ),
+ (
+ "memory_forget",
+ "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
+ ),
+ ];
+ tool_descs.push((
+ "cron_add",
+ "Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.",
+ ));
+ tool_descs.push((
+ "cron_list",
+ "List all cron jobs with schedule, status, and metadata.",
+ ));
+ tool_descs.push(("cron_remove", "Remove a cron job by job_id."));
+ tool_descs.push((
+ "cron_update",
+ "Patch a cron job (schedule, enabled, command/prompt, model, delivery, session_target).",
+ ));
+ tool_descs.push((
+ "cron_run",
+ "Force-run a cron job immediately and record a run history entry.",
+ ));
+ tool_descs.push(("cron_runs", "Show recent run history for a cron job."));
+ tool_descs.push((
+ "screenshot",
+ "Capture a screenshot of the current screen. Returns file path and base64-encoded PNG. Use when: visual verification, UI inspection, debugging displays.",
+ ));
+ tool_descs.push((
+ "image_info",
+ "Read image file metadata (format, dimensions, size) and optionally base64-encode it. Use when: inspecting images, preparing visual data for analysis.",
+ ));
+ if config.browser.enabled {
+ tool_descs.push((
+ "browser_open",
+ "Open approved HTTPS URLs in Brave Browser (allowlist-only, no scraping)",
+ ));
+ }
+ if config.composio.enabled {
+ tool_descs.push((
+ "composio",
+ "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run (optionally with connected_account_id), 'connect' to OAuth.",
+ ));
+ }
+ tool_descs.push((
+ "schedule",
+ "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
+ ));
+ if !config.agents.is_empty() {
+ tool_descs.push((
+ "delegate",
+ "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.",
+ ));
+ }
+ if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
+ tool_descs.push((
+ "gpio_read",
+ "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.",
+ ));
+ tool_descs.push((
+ "gpio_write",
+ "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.",
+ ));
+ tool_descs.push((
+ "arduino_upload",
+ "Upload agent-generated Arduino sketch. Use when: user asks for 'make a heart', 'blink pattern', or custom LED behavior on Arduino. You write the full .ino code; Alphahuman compiles and uploads it. Pin 13 = built-in LED on Uno.",
+ ));
+ tool_descs.push((
+ "hardware_memory_map",
+ "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.",
+ ));
+ tool_descs.push((
+ "hardware_board_info",
+ "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', or 'what hardware'.",
+ ));
+ tool_descs.push((
+ "hardware_memory_read",
+ "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory', 'dump lower memory 0-126', 'give address and value'. Params: address (hex, default 0x20000000), length (bytes, default 128).",
+ ));
+ tool_descs.push((
+ "hardware_capabilities",
+ "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
+ ));
+ }
+ let bootstrap_max_chars = if config.agent.compact_context {
+ Some(6000)
+ } else {
+ None
+ };
+ let mut system_prompt = crate::alphahuman::channels::build_system_prompt(
+ &config.workspace_dir,
+ model_name,
+ &tool_descs,
+ &skills,
+ Some(&config.identity),
+ bootstrap_max_chars,
+ );
+
+ // Append structured tool-use instructions with schemas
+ system_prompt.push_str(&build_tool_instructions(&tools_registry));
+
+ // ── Approval manager (supervised mode) ───────────────────────
+ let approval_manager = ApprovalManager::from_config(&config.autonomy);
+
+ // ── Execute ──────────────────────────────────────────────────
+ let start = Instant::now();
+
+ let mut final_output = String::new();
+
+ if let Some(msg) = message {
+ // Auto-save user message to memory
+ if config.memory.auto_save {
+ let user_key = autosave_memory_key("user_msg");
+ let _ = mem
+ .store(&user_key, &msg, MemoryCategory::Conversation, None)
+ .await;
+ }
+
+ // Inject memory + hardware RAG context into user message
+ let mem_context =
+ build_context(mem.as_ref(), &msg, config.memory.min_relevance_score).await;
+ let rag_limit = if config.agent.compact_context { 2 } else { 5 };
+ let hw_context = hardware_rag
+ .as_ref()
+ .map(|r| build_hardware_context(r, &msg, &board_names, rag_limit))
+ .unwrap_or_default();
+ let context = format!("{mem_context}{hw_context}");
+ let enriched = if context.is_empty() {
+ msg.clone()
+ } else {
+ format!("{context}{msg}")
+ };
+
+ let mut history = vec![
+ ChatMessage::system(&system_prompt),
+ ChatMessage::user(&enriched),
+ ];
+
+ let response = run_tool_call_loop(
+ provider.as_ref(),
+ &mut history,
+ &tools_registry,
+ observer.as_ref(),
+ provider_name,
+ model_name,
+ temperature,
+ false,
+ Some(&approval_manager),
+ "cli",
+ &config.multimodal,
+ config.agent.max_tool_iterations,
+ None,
+ )
+ .await?;
+ final_output = response.clone();
+ println!("{response}");
+ observer.record_event(&ObserverEvent::TurnComplete);
+
+ // Auto-save assistant response to daily log
+ if config.memory.auto_save {
+ let summary = truncate_with_ellipsis(&response, 100);
+ let response_key = autosave_memory_key("assistant_resp");
+ let _ = mem
+ .store(&response_key, &summary, MemoryCategory::Daily, None)
+ .await;
+ }
+ } else {
+ println!("🦀 Alphahuman Interactive Mode");
+ println!("Type /help for commands.\n");
+ let cli = crate::alphahuman::channels::CliChannel::new();
+
+ // Persistent conversation history across turns
+ let mut history = vec![ChatMessage::system(&system_prompt)];
+
+ loop {
+ print!("> ");
+ let _ = std::io::stdout().flush();
+
+ let mut input = String::new();
+ match std::io::stdin().read_line(&mut input) {
+ Ok(0) => break,
+ Ok(_) => {}
+ Err(e) => {
+ eprintln!("\nError reading input: {e}\n");
+ break;
+ }
+ }
+
+ let user_input = input.trim().to_string();
+ if user_input.is_empty() {
+ continue;
+ }
+ match user_input.as_str() {
+ "/quit" | "/exit" => break,
+ "/help" => {
+ println!("Available commands:");
+ println!(" /help Show this help message");
+ println!(" /clear /new Clear conversation history");
+ println!(" /quit /exit Exit interactive mode\n");
+ continue;
+ }
+ "/clear" | "/new" => {
+ println!(
+ "This will clear the current conversation and delete all session memory."
+ );
+ println!("Core memories (long-term facts/preferences) will be preserved.");
+ print!("Continue? [y/N] ");
+ let _ = std::io::stdout().flush();
+
+ let mut confirm = String::new();
+ if std::io::stdin().read_line(&mut confirm).is_err() {
+ continue;
+ }
+ if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
+ println!("Cancelled.\n");
+ continue;
+ }
+
+ history.clear();
+ history.push(ChatMessage::system(&system_prompt));
+ // Clear conversation and daily memory
+ let mut cleared = 0;
+ for category in [MemoryCategory::Conversation, MemoryCategory::Daily] {
+ let entries = mem.list(Some(&category), None).await.unwrap_or_default();
+ for entry in entries {
+ if mem.forget(&entry.key).await.unwrap_or(false) {
+ cleared += 1;
+ }
+ }
+ }
+ if cleared > 0 {
+ println!("Conversation cleared ({cleared} memory entries removed).\n");
+ } else {
+ println!("Conversation cleared.\n");
+ }
+ continue;
+ }
+ _ => {}
+ }
+
+ // Auto-save conversation turns
+ if config.memory.auto_save {
+ let user_key = autosave_memory_key("user_msg");
+ let _ = mem
+ .store(&user_key, &user_input, MemoryCategory::Conversation, None)
+ .await;
+ }
+
+ // Inject memory + hardware RAG context into user message
+ let mem_context =
+ build_context(mem.as_ref(), &user_input, config.memory.min_relevance_score).await;
+ let rag_limit = if config.agent.compact_context { 2 } else { 5 };
+ let hw_context = hardware_rag
+ .as_ref()
+ .map(|r| build_hardware_context(r, &user_input, &board_names, rag_limit))
+ .unwrap_or_default();
+ let context = format!("{mem_context}{hw_context}");
+ let enriched = if context.is_empty() {
+ user_input.clone()
+ } else {
+ format!("{context}{user_input}")
+ };
+
+ history.push(ChatMessage::user(&enriched));
+
+ let response = match run_tool_call_loop(
+ provider.as_ref(),
+ &mut history,
+ &tools_registry,
+ observer.as_ref(),
+ provider_name,
+ model_name,
+ temperature,
+ false,
+ Some(&approval_manager),
+ "cli",
+ &config.multimodal,
+ config.agent.max_tool_iterations,
+ None,
+ )
+ .await
+ {
+ Ok(resp) => resp,
+ Err(e) => {
+ eprintln!("\nError: {e}\n");
+ continue;
+ }
+ };
+ final_output = response.clone();
+ if let Err(e) = crate::alphahuman::channels::Channel::send(
+ &cli,
+ &crate::alphahuman::channels::traits::SendMessage::new(format!("\n{response}\n"), "user"),
+ )
+ .await
+ {
+ eprintln!("\nError sending console response: {e}\n");
+ }
+ observer.record_event(&ObserverEvent::TurnComplete);
+
+ // Auto-compaction before hard trimming to preserve long-context signal.
+ if let Ok(compacted) = auto_compact_history(
+ &mut history,
+ provider.as_ref(),
+ model_name,
+ config.agent.max_history_messages,
+ )
+ .await
+ {
+ if compacted {
+ println!("🧹 Auto-compaction complete");
+ }
+ }
+
+ // Hard cap as a safety net.
+ trim_history(&mut history, config.agent.max_history_messages);
+
+ if config.memory.auto_save {
+ let summary = truncate_with_ellipsis(&response, 100);
+ let response_key = autosave_memory_key("assistant_resp");
+ let _ = mem
+ .store(&response_key, &summary, MemoryCategory::Daily, None)
+ .await;
+ }
+ }
+ }
+
+ let duration = start.elapsed();
+ observer.record_event(&ObserverEvent::AgentEnd {
+ provider: provider_name.to_string(),
+ model: model_name.to_string(),
+ duration,
+ tokens_used: None,
+ cost_usd: None,
+ });
+
+ Ok(final_output)
+}
+
+/// Process a single message through the full agent (with tools, peripherals, memory).
+/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use.
+pub async fn process_message(config: Config, message: &str) -> Result {
+ let observer: Arc =
+ Arc::from(observability::create_observer(&config.observability));
+ let runtime: Arc =
+ Arc::from(runtime::create_runtime(&config.runtime)?);
+ let security = Arc::new(SecurityPolicy::from_config(
+ &config.autonomy,
+ &config.workspace_dir,
+ ));
+ let mem: Arc = Arc::from(memory::create_memory_with_storage(
+ &config.memory,
+ Some(&config.storage.provider.config),
+ &config.workspace_dir,
+ config.api_key.as_deref(),
+ )?);
+
+ let (composio_key, composio_entity_id) = if config.composio.enabled {
+ (
+ config.composio.api_key.as_deref(),
+ Some(config.composio.entity_id.as_str()),
+ )
+ } else {
+ (None, None)
+ };
+ let mut tools_registry = tools::all_tools_with_runtime(
+ Arc::new(config.clone()),
+ &security,
+ runtime,
+ mem.clone(),
+ composio_key,
+ composio_entity_id,
+ &config.browser,
+ &config.http_request,
+ &config.workspace_dir,
+ &config.agents,
+ config.api_key.as_deref(),
+ &config,
+ );
+ let peripheral_tools: Vec> =
+ crate::alphahuman::peripherals::create_peripheral_tools(&config.peripherals).await?;
+ tools_registry.extend(peripheral_tools);
+
+ let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
+ let model_name = config
+ .default_model
+ .clone()
+ .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
+ let provider_runtime_options = providers::ProviderRuntimeOptions {
+ auth_profile_override: None,
+ alphahuman_dir: config.config_path.parent().map(std::path::PathBuf::from),
+ secrets_encrypt: config.secrets.encrypt,
+ reasoning_enabled: config.runtime.reasoning_enabled,
+ };
+ let provider: Box = providers::create_routed_provider_with_options(
+ provider_name,
+ config.api_key.as_deref(),
+ config.api_url.as_deref(),
+ &config.reliability,
+ &config.model_routes,
+ &model_name,
+ &provider_runtime_options,
+ )?;
+
+ let hardware_rag: Option = config
+ .peripherals
+ .datasheet_dir
+ .as_ref()
+ .filter(|d| !d.trim().is_empty())
+ .map(|dir| crate::alphahuman::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
+ .and_then(Result::ok)
+ .filter(|r: &crate::alphahuman::rag::HardwareRag| !r.is_empty());
+ let board_names: Vec = config
+ .peripherals
+ .boards
+ .iter()
+ .map(|b| b.board.clone())
+ .collect();
+
+ let skills = crate::alphahuman::skills::load_skills(&config.workspace_dir);
+ let mut tool_descs: Vec<(&str, &str)> = vec![
+ ("shell", "Execute terminal commands."),
+ ("file_read", "Read file contents."),
+ ("file_write", "Write file contents."),
+ ("memory_store", "Save to memory."),
+ ("memory_recall", "Search memory."),
+ ("memory_forget", "Delete a memory entry."),
+ ("screenshot", "Capture a screenshot."),
+ ("image_info", "Read image metadata."),
+ ];
+ if config.browser.enabled {
+ tool_descs.push(("browser_open", "Open approved URLs in browser."));
+ }
+ if config.composio.enabled {
+ tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
+ }
+ if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
+ tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
+ tool_descs.push((
+ "gpio_write",
+ "Set GPIO pin high or low on connected hardware.",
+ ));
+ tool_descs.push((
+ "arduino_upload",
+ "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; Alphahuman uploads it.",
+ ));
+ tool_descs.push((
+ "hardware_memory_map",
+ "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
+ ));
+ tool_descs.push((
+ "hardware_board_info",
+ "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
+ ));
+ tool_descs.push((
+ "hardware_memory_read",
+ "Read actual memory/register values from Nucleo. Use when user asks to read registers, read memory, dump lower memory 0-126, or give address and value.",
+ ));
+ tool_descs.push((
+ "hardware_capabilities",
+ "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
+ ));
+ }
+ let bootstrap_max_chars = if config.agent.compact_context {
+ Some(6000)
+ } else {
+ None
+ };
+ let mut system_prompt = crate::alphahuman::channels::build_system_prompt(
+ &config.workspace_dir,
+ &model_name,
+ &tool_descs,
+ &skills,
+ Some(&config.identity),
+ bootstrap_max_chars,
+ );
+ system_prompt.push_str(&build_tool_instructions(&tools_registry));
+
+ let mem_context = build_context(mem.as_ref(), message, config.memory.min_relevance_score).await;
+ let rag_limit = if config.agent.compact_context { 2 } else { 5 };
+ let hw_context = hardware_rag
+ .as_ref()
+ .map(|r| build_hardware_context(r, message, &board_names, rag_limit))
+ .unwrap_or_default();
+ let context = format!("{mem_context}{hw_context}");
+ let enriched = if context.is_empty() {
+ message.to_string()
+ } else {
+ format!("{context}{message}")
+ };
+
+ let mut history = vec![
+ ChatMessage::system(&system_prompt),
+ ChatMessage::user(&enriched),
+ ];
+
+ agent_turn(
+ provider.as_ref(),
+ &mut history,
+ &tools_registry,
+ observer.as_ref(),
+ provider_name,
+ &model_name,
+ config.default_temperature,
+ true,
+ &config.multimodal,
+ config.agent.max_tool_iterations,
+ )
+ .await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use async_trait::async_trait;
+ use base64::{engine::general_purpose::STANDARD, Engine as _};
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::sync::Arc;
+
+ #[test]
+ fn test_scrub_credentials() {
+ let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\"";
+ let scrubbed = scrub_credentials(input);
+ assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]"));
+ assert!(scrubbed.contains("token: 1234*[REDACTED]"));
+ assert!(scrubbed.contains("password=\"secr*[REDACTED]\""));
+ assert!(!scrubbed.contains("abcdef"));
+ assert!(!scrubbed.contains("secret123456"));
+ }
+
+ #[test]
+ fn test_scrub_credentials_json() {
+ let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#;
+ let scrubbed = scrub_credentials(input);
+ assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
+ assert!(scrubbed.contains("public"));
+ }
+ use crate::alphahuman::memory::{Memory, MemoryCategory, SqliteMemory};
+ use crate::alphahuman::observability::NoopObserver;
+ use crate::alphahuman::providers::traits::ProviderCapabilities;
+ use crate::alphahuman::providers::ChatResponse;
+ use tempfile::TempDir;
+
+ struct NonVisionProvider {
+ calls: Arc,
+ }
+
+ #[async_trait]
+ impl Provider for NonVisionProvider {
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ Ok("ok".to_string())
+ }
+ }
+
+ struct VisionProvider {
+ calls: Arc,
+ }
+
+ #[async_trait]
+ impl Provider for VisionProvider {
+ fn capabilities(&self) -> ProviderCapabilities {
+ ProviderCapabilities {
+ native_tool_calling: false,
+ vision: true,
+ }
+ }
+
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ Ok("ok".to_string())
+ }
+
+ async fn chat(
+ &self,
+ request: ChatRequest<'_>,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ let marker_count = crate::alphahuman::multimodal::count_image_markers(request.messages);
+ if marker_count == 0 {
+ anyhow::bail!("expected image markers in request messages");
+ }
+
+ if request.tools.is_some() {
+ anyhow::bail!("no tools should be attached for this test");
+ }
+
+ Ok(ChatResponse {
+ text: Some("vision-ok".to_string()),
+ tool_calls: Vec::new(),
+ })
+ }
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() {
+ let calls = Arc::new(AtomicUsize::new(0));
+ let provider = NonVisionProvider {
+ calls: Arc::clone(&calls),
+ };
+
+ let mut history = vec![ChatMessage::user(
+ "please inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
+ )];
+ let tools_registry: Vec> = Vec::new();
+ let observer = NoopObserver;
+
+ let err = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::alphahuman::config::MultimodalConfig::default(),
+ 3,
+ None,
+ )
+ .await
+ .expect_err("provider without vision support should fail");
+
+ assert!(err.to_string().contains("provider_capability_error"));
+ assert!(err.to_string().contains("capability=vision"));
+ assert_eq!(calls.load(Ordering::SeqCst), 0);
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_rejects_oversized_image_payload() {
+ let calls = Arc::new(AtomicUsize::new(0));
+ let provider = VisionProvider {
+ calls: Arc::clone(&calls),
+ };
+
+ let oversized_payload = STANDARD.encode(vec![0_u8; (1024 * 1024) + 1]);
+ let mut history = vec![ChatMessage::user(format!(
+ "[IMAGE:data:image/png;base64,{oversized_payload}]"
+ ))];
+
+ let tools_registry: Vec> = Vec::new();
+ let observer = NoopObserver;
+ let multimodal = crate::alphahuman::config::MultimodalConfig {
+ max_images: 4,
+ max_image_size_mb: 1,
+ allow_remote_fetch: false,
+ };
+
+ let err = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &multimodal,
+ 3,
+ None,
+ )
+ .await
+ .expect_err("oversized payload must fail");
+
+ assert!(err
+ .to_string()
+ .contains("multimodal image size limit exceeded"));
+ assert_eq!(calls.load(Ordering::SeqCst), 0);
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() {
+ let calls = Arc::new(AtomicUsize::new(0));
+ let provider = VisionProvider {
+ calls: Arc::clone(&calls),
+ };
+
+ let mut history = vec![ChatMessage::user(
+ "Analyze this [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
+ )];
+ let tools_registry: Vec> = Vec::new();
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::alphahuman::config::MultimodalConfig::default(),
+ 3,
+ None,
+ )
+ .await
+ .expect("valid multimodal payload should pass");
+
+ assert_eq!(result, "vision-ok");
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+ }
+
+ #[test]
+ fn parse_tool_calls_extracts_single_call() {
+ let response = r#"Let me check that.
+
+{"name": "shell", "arguments": {"command": "ls -la"}}
+ "#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(text, "Let me check that.");
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "ls -la"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_extracts_multiple_calls() {
+ let response = r#"
+{"name": "file_read", "arguments": {"path": "a.txt"}}
+
+
+{"name": "file_read", "arguments": {"path": "b.txt"}}
+ "#;
+
+ let (_, calls) = parse_tool_calls(response);
+ assert_eq!(calls.len(), 2);
+ assert_eq!(calls[0].name, "file_read");
+ assert_eq!(calls[1].name, "file_read");
+ }
+
+ #[test]
+ fn parse_tool_calls_returns_text_only_when_no_calls() {
+ let response = "Just a normal response with no tools.";
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(text, "Just a normal response with no tools.");
+ assert!(calls.is_empty());
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_malformed_json() {
+ let response = r#"
+not valid json
+
+Some text after."#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(calls.is_empty());
+ assert!(text.contains("Some text after."));
+ }
+
+ #[test]
+ fn parse_tool_calls_text_before_and_after() {
+ let response = r#"Before text.
+
+{"name": "shell", "arguments": {"command": "echo hi"}}
+
+After text."#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.contains("Before text."));
+ assert!(text.contains("After text."));
+ assert_eq!(calls.len(), 1);
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_openai_format() {
+ // OpenAI-style response with tool_calls array
+ let response = r#"{"content": "Let me check that for you.", "tool_calls": [{"type": "function", "function": {"name": "shell", "arguments": "{\"command\": \"ls -la\"}"}}]}"#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(text, "Let me check that for you.");
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "ls -la"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_openai_format_multiple_calls() {
+ let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "file_read", "arguments": "{\"path\": \"a.txt\"}"}}, {"type": "function", "function": {"name": "file_read", "arguments": "{\"path\": \"b.txt\"}"}}]}"#;
+
+ let (_, calls) = parse_tool_calls(response);
+ assert_eq!(calls.len(), 2);
+ assert_eq!(calls[0].name, "file_read");
+ assert_eq!(calls[1].name, "file_read");
+ }
+
+ #[test]
+ fn parse_tool_calls_openai_format_without_content() {
+ // Some providers don't include content field with tool_calls
+ let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "memory_recall", "arguments": "{}"}}]}"#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.is_empty()); // No content field
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "memory_recall");
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_markdown_json_inside_tool_call_tag() {
+ let response = r#"
+```json
+{"name": "file_write", "arguments": {"path": "test.py", "content": "print('ok')"}}
+```
+ "#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.is_empty());
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "file_write");
+ assert_eq!(
+ calls[0].arguments.get("path").unwrap().as_str().unwrap(),
+ "test.py"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_noisy_tool_call_tag_body() {
+ let response = r#"
+I will now call the tool with this payload:
+{"name": "shell", "arguments": {"command": "pwd"}}
+ "#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.is_empty());
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "pwd"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_markdown_tool_call_fence() {
+ let response = r#"I'll check that.
+```tool_call
+{"name": "shell", "arguments": {"command": "pwd"}}
+```
+Done."#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "pwd"
+ );
+ assert!(text.contains("I'll check that."));
+ assert!(text.contains("Done."));
+ assert!(!text.contains("```tool_call"));
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_markdown_tool_call_hybrid_close_tag() {
+ let response = r#"Preface
+```tool-call
+{"name": "shell", "arguments": {"command": "date"}}
+
+Tail"#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "date"
+ );
+ assert!(text.contains("Preface"));
+ assert!(text.contains("Tail"));
+ assert!(!text.contains("```tool-call"));
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_markdown_invoke_fence() {
+ let response = r#"Checking.
+```invoke
+{"name": "shell", "arguments": {"command": "date"}}
+```
+Done."#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "date"
+ );
+ assert!(text.contains("Checking."));
+ assert!(text.contains("Done."));
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_toolcall_tag_alias() {
+ let response = r#"
+{"name": "shell", "arguments": {"command": "date"}}
+ "#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.is_empty());
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "date"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_tool_dash_call_tag_alias() {
+ let response = r#"
+{"name": "shell", "arguments": {"command": "whoami"}}
+ "#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.is_empty());
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "whoami"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_invoke_tag_alias() {
+ let response = r#"
+{"name": "shell", "arguments": {"command": "uptime"}}
+ "#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.is_empty());
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "uptime"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_recovers_unclosed_tool_call_with_json() {
+ let response = r#"I will call the tool now.
+
+{"name": "shell", "arguments": {"command": "uptime -p"}}"#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.contains("I will call the tool now."));
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "uptime -p"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_recovers_mismatched_close_tag() {
+ let response = r#"
+{"name": "shell", "arguments": {"command": "uptime"}}
+"#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.is_empty());
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "uptime"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_recovers_cross_alias_closing_tags() {
+ let response = r#"
+{"name": "shell", "arguments": {"command": "date"}}
+ "#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.is_empty());
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ }
+
+ #[test]
+ fn parse_tool_calls_rejects_raw_tool_json_without_tags() {
+ // SECURITY: Raw JSON without explicit wrappers should NOT be parsed
+ // This prevents prompt injection attacks where malicious content
+ // could include JSON that mimics a tool call.
+ let response = r#"Sure, creating the file now.
+{"name": "file_write", "arguments": {"path": "hello.py", "content": "print('hello')"}}"#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.contains("Sure, creating the file now."));
+ assert_eq!(
+ calls.len(),
+ 0,
+ "Raw JSON without wrappers should not be parsed"
+ );
+ }
+
+ #[test]
+ fn build_tool_instructions_includes_all_tools() {
+ use crate::alphahuman::security::SecurityPolicy;
+ let security = Arc::new(SecurityPolicy::from_config(
+ &crate::alphahuman::config::AutonomyConfig::default(),
+ std::path::Path::new("/tmp"),
+ ));
+ let tools = tools::default_tools(security);
+ let instructions = build_tool_instructions(&tools);
+
+ assert!(instructions.contains("## Tool Use Protocol"));
+ assert!(instructions.contains(""));
+ assert!(instructions.contains("shell"));
+ assert!(instructions.contains("file_read"));
+ assert!(instructions.contains("file_write"));
+ }
+
+ #[test]
+ fn tools_to_openai_format_produces_valid_schema() {
+ use crate::alphahuman::security::SecurityPolicy;
+ let security = Arc::new(SecurityPolicy::from_config(
+ &crate::alphahuman::config::AutonomyConfig::default(),
+ std::path::Path::new("/tmp"),
+ ));
+ let tools = tools::default_tools(security);
+ let formatted = tools_to_openai_format(&tools);
+
+ assert!(!formatted.is_empty());
+ for tool_json in &formatted {
+ assert_eq!(tool_json["type"], "function");
+ assert!(tool_json["function"]["name"].is_string());
+ assert!(tool_json["function"]["description"].is_string());
+ assert!(!tool_json["function"]["name"].as_str().unwrap().is_empty());
+ }
+ // Verify known tools are present
+ let names: Vec<&str> = formatted
+ .iter()
+ .filter_map(|t| t["function"]["name"].as_str())
+ .collect();
+ assert!(names.contains(&"shell"));
+ assert!(names.contains(&"file_read"));
+ }
+
+ #[test]
+ fn trim_history_preserves_system_prompt() {
+ let mut history = vec![ChatMessage::system("system prompt")];
+ for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
+ history.push(ChatMessage::user(format!("msg {i}")));
+ }
+ let original_len = history.len();
+ assert!(original_len > DEFAULT_MAX_HISTORY_MESSAGES + 1);
+
+ trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
+
+ // System prompt preserved
+ assert_eq!(history[0].role, "system");
+ assert_eq!(history[0].content, "system prompt");
+ // Trimmed to limit
+ assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES + 1); // +1 for system
+ // Most recent messages preserved
+ let last = &history[history.len() - 1];
+ assert_eq!(
+ last.content,
+ format!("msg {}", DEFAULT_MAX_HISTORY_MESSAGES + 19)
+ );
+ }
+
+ #[test]
+ fn trim_history_noop_when_within_limit() {
+ let mut history = vec![
+ ChatMessage::system("sys"),
+ ChatMessage::user("hello"),
+ ChatMessage::assistant("hi"),
+ ];
+ trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
+ assert_eq!(history.len(), 3);
+ }
+
+ #[test]
+ fn build_compaction_transcript_formats_roles() {
+ let messages = vec![
+ ChatMessage::user("I like dark mode"),
+ ChatMessage::assistant("Got it"),
+ ];
+ let transcript = build_compaction_transcript(&messages);
+ assert!(transcript.contains("USER: I like dark mode"));
+ assert!(transcript.contains("ASSISTANT: Got it"));
+ }
+
+ #[test]
+ fn apply_compaction_summary_replaces_old_segment() {
+ let mut history = vec![
+ ChatMessage::system("sys"),
+ ChatMessage::user("old 1"),
+ ChatMessage::assistant("old 2"),
+ ChatMessage::user("recent 1"),
+ ChatMessage::assistant("recent 2"),
+ ];
+
+ apply_compaction_summary(&mut history, 1, 3, "- user prefers concise replies");
+
+ assert_eq!(history.len(), 4);
+ assert!(history[1].content.contains("Compaction summary"));
+ assert!(history[2].content.contains("recent 1"));
+ assert!(history[3].content.contains("recent 2"));
+ }
+
+ #[test]
+ fn autosave_memory_key_has_prefix_and_uniqueness() {
+ let key1 = autosave_memory_key("user_msg");
+ let key2 = autosave_memory_key("user_msg");
+
+ assert!(key1.starts_with("user_msg_"));
+ assert!(key2.starts_with("user_msg_"));
+ assert_ne!(key1, key2);
+ }
+
+ #[tokio::test]
+ async fn autosave_memory_keys_preserve_multiple_turns() {
+ let tmp = TempDir::new().unwrap();
+ let mem = SqliteMemory::new(tmp.path()).unwrap();
+
+ let key1 = autosave_memory_key("user_msg");
+ let key2 = autosave_memory_key("user_msg");
+
+ mem.store(&key1, "I'm Paul", MemoryCategory::Conversation, None)
+ .await
+ .unwrap();
+ mem.store(&key2, "I'm 45", MemoryCategory::Conversation, None)
+ .await
+ .unwrap();
+
+ assert_eq!(mem.count().await.unwrap(), 2);
+
+ let recalled = mem.recall("45", 5, None).await.unwrap();
+ assert!(recalled.iter().any(|entry| entry.content.contains("45")));
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Recovery Tests - Tool Call Parsing Edge Cases
+ // ═══════════════════════════════════════════════════════════════════════
+
+ #[test]
+ fn parse_tool_calls_handles_empty_tool_result() {
+ // Recovery: Empty tool_result tag should be handled gracefully
+ let response = r#"I'll run that command.
+
+
+
+Done."#;
+ let (text, calls) = parse_tool_calls(response);
+ assert!(text.contains("Done."));
+ assert!(calls.is_empty());
+ }
+
+ #[test]
+ fn parse_arguments_value_handles_null() {
+ // Recovery: null arguments are returned as-is (Value::Null)
+ let value = serde_json::json!(null);
+ let result = parse_arguments_value(Some(&value));
+ assert!(result.is_null());
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_empty_tool_calls_array() {
+ // Recovery: Empty tool_calls array returns original response (no tool parsing)
+ let response = r#"{"content": "Hello", "tool_calls": []}"#;
+ let (text, calls) = parse_tool_calls(response);
+ // When tool_calls is empty, the entire JSON is returned as text
+ assert!(text.contains("Hello"));
+ assert!(calls.is_empty());
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_whitespace_only_name() {
+ // Recovery: Whitespace-only tool name should return None
+ let value = serde_json::json!({"function": {"name": " ", "arguments": {}}});
+ let result = parse_tool_call_value(&value);
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_empty_string_arguments() {
+ // Recovery: Empty string arguments should be handled
+ let value = serde_json::json!({"name": "test", "arguments": ""});
+ let result = parse_tool_call_value(&value);
+ assert!(result.is_some());
+ assert_eq!(result.unwrap().name, "test");
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Recovery Tests - History Management
+ // ═══════════════════════════════════════════════════════════════════════
+
+ #[test]
+ fn trim_history_with_no_system_prompt() {
+ // Recovery: History without system prompt should trim correctly
+ let mut history = vec![];
+ for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
+ history.push(ChatMessage::user(format!("msg {i}")));
+ }
+ trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
+ assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES);
+ }
+
+ #[test]
+ fn trim_history_preserves_role_ordering() {
+ // Recovery: After trimming, role ordering should remain consistent
+ let mut history = vec![ChatMessage::system("system")];
+ for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 10 {
+ history.push(ChatMessage::user(format!("user {i}")));
+ history.push(ChatMessage::assistant(format!("assistant {i}")));
+ }
+ trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
+ assert_eq!(history[0].role, "system");
+ assert_eq!(history[history.len() - 1].role, "assistant");
+ }
+
+ #[test]
+ fn trim_history_with_only_system_prompt() {
+ // Recovery: Only system prompt should not be trimmed
+ let mut history = vec![ChatMessage::system("system prompt")];
+ trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
+ assert_eq!(history.len(), 1);
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Recovery Tests - Arguments Parsing
+ // ═══════════════════════════════════════════════════════════════════════
+
+ #[test]
+ fn parse_arguments_value_handles_invalid_json_string() {
+ // Recovery: Invalid JSON string should return empty object
+ let value = serde_json::Value::String("not valid json".to_string());
+ let result = parse_arguments_value(Some(&value));
+ assert!(result.is_object());
+ assert!(result.as_object().unwrap().is_empty());
+ }
+
+ #[test]
+ fn parse_arguments_value_handles_none() {
+ // Recovery: None arguments should return empty object
+ let result = parse_arguments_value(None);
+ assert!(result.is_object());
+ assert!(result.as_object().unwrap().is_empty());
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Recovery Tests - JSON Extraction
+ // ═══════════════════════════════════════════════════════════════════════
+
+ #[test]
+ fn extract_json_values_handles_empty_string() {
+ // Recovery: Empty input should return empty vec
+ let result = extract_json_values("");
+ assert!(result.is_empty());
+ }
+
+ #[test]
+ fn extract_json_values_handles_whitespace_only() {
+ // Recovery: Whitespace only should return empty vec
+ let result = extract_json_values(" \n\t ");
+ assert!(result.is_empty());
+ }
+
+ #[test]
+ fn extract_json_values_handles_multiple_objects() {
+ // Recovery: Multiple JSON objects should all be extracted
+ let input = r#"{"a": 1}{"b": 2}{"c": 3}"#;
+ let result = extract_json_values(input);
+ assert_eq!(result.len(), 3);
+ }
+
+ #[test]
+ fn extract_json_values_handles_arrays() {
+ // Recovery: JSON arrays should be extracted
+ let input = r#"[1, 2, 3]{"key": "value"}"#;
+ let result = extract_json_values(input);
+ assert_eq!(result.len(), 2);
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Recovery Tests - Constants Validation
+ // ═══════════════════════════════════════════════════════════════════════
+
+ const _: () = {
+ assert!(DEFAULT_MAX_TOOL_ITERATIONS > 0);
+ assert!(DEFAULT_MAX_TOOL_ITERATIONS <= 100);
+ assert!(DEFAULT_MAX_HISTORY_MESSAGES > 0);
+ assert!(DEFAULT_MAX_HISTORY_MESSAGES <= 1000);
+ };
+
+ #[test]
+ fn constants_bounds_are_compile_time_checked() {
+ // Bounds are enforced by the const assertions above.
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Recovery Tests - Tool Call Value Parsing
+ // ═══════════════════════════════════════════════════════════════════════
+
+ #[test]
+ fn parse_tool_call_value_handles_missing_name_field() {
+ // Recovery: Missing name field should return None
+ let value = serde_json::json!({"function": {"arguments": {}}});
+ let result = parse_tool_call_value(&value);
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn parse_tool_call_value_handles_top_level_name() {
+ // Recovery: Tool call with name at top level (non-OpenAI format)
+ let value = serde_json::json!({"name": "test_tool", "arguments": {}});
+ let result = parse_tool_call_value(&value);
+ assert!(result.is_some());
+ assert_eq!(result.unwrap().name, "test_tool");
+ }
+
+ #[test]
+ fn parse_tool_calls_from_json_value_handles_empty_array() {
+ // Recovery: Empty tool_calls array should return empty vec
+ let value = serde_json::json!({"tool_calls": []});
+ let result = parse_tool_calls_from_json_value(&value);
+ assert!(result.is_empty());
+ }
+
+ #[test]
+ fn parse_tool_calls_from_json_value_handles_missing_tool_calls() {
+ // Recovery: Missing tool_calls field should fall through
+ let value = serde_json::json!({"name": "test", "arguments": {}});
+ let result = parse_tool_calls_from_json_value(&value);
+ assert_eq!(result.len(), 1);
+ }
+
+ #[test]
+ fn parse_tool_calls_from_json_value_handles_top_level_array() {
+ // Recovery: Top-level array of tool calls
+ let value = serde_json::json!([
+ {"name": "tool_a", "arguments": {}},
+ {"name": "tool_b", "arguments": {}}
+ ]);
+ let result = parse_tool_calls_from_json_value(&value);
+ assert_eq!(result.len(), 2);
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // GLM-Style Tool Call Parsing
+ // ═══════════════════════════════════════════════════════════════════════
+
+ #[test]
+ fn parse_glm_style_browser_open_url() {
+ let response = "browser_open/url>https://example.com";
+ let calls = parse_glm_style_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].0, "shell");
+ assert!(calls[0].1["command"].as_str().unwrap().contains("curl"));
+ assert!(calls[0].1["command"]
+ .as_str()
+ .unwrap()
+ .contains("example.com"));
+ }
+
+ #[test]
+ fn parse_glm_style_shell_command() {
+ let response = "shell/command>ls -la";
+ let calls = parse_glm_style_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].0, "shell");
+ assert_eq!(calls[0].1["command"], "ls -la");
+ }
+
+ #[test]
+ fn parse_glm_style_http_request() {
+ let response = "http_request/url>https://api.example.com/data";
+ let calls = parse_glm_style_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].0, "http_request");
+ assert_eq!(calls[0].1["url"], "https://api.example.com/data");
+ assert_eq!(calls[0].1["method"], "GET");
+ }
+
+ #[test]
+ fn parse_glm_style_plain_url() {
+ let response = "https://example.com/api";
+ let calls = parse_glm_style_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].0, "shell");
+ assert!(calls[0].1["command"].as_str().unwrap().contains("curl"));
+ }
+
+ #[test]
+ fn parse_glm_style_json_args() {
+ let response = r#"shell/{"command": "echo hello"}"#;
+ let calls = parse_glm_style_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].0, "shell");
+ assert_eq!(calls[0].1["command"], "echo hello");
+ }
+
+ #[test]
+ fn parse_glm_style_multiple_calls() {
+ let response = r#"shell/command>ls
+browser_open/url>https://example.com"#;
+ let calls = parse_glm_style_tool_calls(response);
+ assert_eq!(calls.len(), 2);
+ }
+
+ #[test]
+ fn parse_glm_style_tool_call_integration() {
+ // Integration test: GLM format should be parsed in parse_tool_calls
+ let response = "Checking...\nbrowser_open/url>https://example.com\nDone";
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert!(text.contains("Checking"));
+ assert!(text.contains("Done"));
+ }
+
+ #[test]
+ fn parse_glm_style_rejects_non_http_url_param() {
+ let response = "browser_open/url>javascript:alert(1)";
+ let calls = parse_glm_style_tool_calls(response);
+ assert!(calls.is_empty());
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_unclosed_tool_call_tag() {
+ let response = "{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\nDone";
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(calls[0].arguments["command"], "pwd");
+ assert_eq!(text, "Done");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // TG4 (inline): parse_tool_calls robustness — malformed/edge-case inputs
+ // Prevents: Pattern 4 issues #746, #418, #777, #848
+ // ─────────────────────────────────────────────────────────────────────
+
+ #[test]
+ fn parse_tool_calls_empty_input_returns_empty() {
+ let (text, calls) = parse_tool_calls("");
+ assert!(calls.is_empty(), "empty input should produce no tool calls");
+ assert!(text.is_empty(), "empty input should produce no text");
+ }
+
+ #[test]
+ fn parse_tool_calls_whitespace_only_returns_empty_calls() {
+ let (text, calls) = parse_tool_calls(" \n\t ");
+ assert!(calls.is_empty());
+ assert!(text.is_empty() || text.trim().is_empty());
+ }
+
+ #[test]
+ fn parse_tool_calls_nested_xml_tags_handled() {
+ // Double-wrapped tool call should still parse the inner call
+ let response = r#"{"name":"echo","arguments":{"msg":"hi"}} "#;
+ let (_text, calls) = parse_tool_calls(response);
+ // Should find at least one tool call
+ assert!(
+ !calls.is_empty(),
+ "nested XML tags should still yield at least one tool call"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_truncated_json_no_panic() {
+ // Incomplete JSON inside tool_call tags
+ let response = r#"{"name":"shell","arguments":{"command":"ls" "#;
+ let (_text, _calls) = parse_tool_calls(response);
+ // Should not panic — graceful handling of truncated JSON
+ }
+
+ #[test]
+ fn parse_tool_calls_empty_json_object_in_tag() {
+ let response = "{} ";
+ let (_text, calls) = parse_tool_calls(response);
+ // Empty JSON object has no name field — should not produce valid tool call
+ assert!(
+ calls.is_empty(),
+ "empty JSON object should not produce a tool call"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_closing_tag_only_returns_text() {
+ let response = "Some text more text";
+ let (text, calls) = parse_tool_calls(response);
+ assert!(calls.is_empty(), "closing tag only should not produce calls");
+ assert!(
+ !text.is_empty(),
+ "text around orphaned closing tag should be preserved"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_very_large_arguments_no_panic() {
+ let large_arg = "x".repeat(100_000);
+ let response = format!(
+ r#"{{"name":"echo","arguments":{{"message":"{}"}}}} "#,
+ large_arg
+ );
+ let (_text, calls) = parse_tool_calls(&response);
+ assert_eq!(calls.len(), 1, "large arguments should still parse");
+ assert_eq!(calls[0].name, "echo");
+ }
+
+ #[test]
+ fn parse_tool_calls_special_characters_in_arguments() {
+ let response = r#"{"name":"echo","arguments":{"message":"hello \"world\" <>&'\n\t"}} "#;
+ let (_text, calls) = parse_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "echo");
+ }
+
+ #[test]
+ fn parse_tool_calls_text_with_embedded_json_not_extracted() {
+ // Raw JSON without any tags should NOT be extracted as a tool call
+ let response = r#"Here is some data: {"name":"echo","arguments":{"message":"hi"}} end."#;
+ let (_text, calls) = parse_tool_calls(response);
+ assert!(
+ calls.is_empty(),
+ "raw JSON in text without tags should not be extracted"
+ );
+ }
+
+ #[test]
+ fn parse_tool_calls_multiple_formats_mixed() {
+ // Mix of text and properly tagged tool call
+ let response = r#"I'll help you with that.
+
+
+{"name":"shell","arguments":{"command":"echo hello"}}
+
+
+Let me check the result."#;
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(calls.len(), 1, "should extract one tool call from mixed content");
+ assert_eq!(calls[0].name, "shell");
+ assert!(
+ text.contains("help you"),
+ "text before tool call should be preserved"
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // TG4 (inline): scrub_credentials edge cases
+ // ─────────────────────────────────────────────────────────────────────
+
+ #[test]
+ fn scrub_credentials_empty_input() {
+ let result = scrub_credentials("");
+ assert_eq!(result, "");
+ }
+
+ #[test]
+ fn scrub_credentials_no_sensitive_data() {
+ let input = "normal text without any secrets";
+ let result = scrub_credentials(input);
+ assert_eq!(result, input, "non-sensitive text should pass through unchanged");
+ }
+
+ #[test]
+ fn scrub_credentials_short_values_not_redacted() {
+ // Values shorter than 8 chars should not be redacted
+ let input = r#"api_key="short""#;
+ let result = scrub_credentials(input);
+ assert_eq!(result, input, "short values should not be redacted");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // TG4 (inline): trim_history edge cases
+ // ─────────────────────────────────────────────────────────────────────
+
+ #[test]
+ fn trim_history_empty_history() {
+ let mut history: Vec = vec![];
+ trim_history(&mut history, 10);
+ assert!(history.is_empty());
+ }
+
+ #[test]
+ fn trim_history_system_only() {
+ let mut history = vec![crate::alphahuman::providers::ChatMessage::system("system prompt")];
+ trim_history(&mut history, 10);
+ assert_eq!(history.len(), 1);
+ assert_eq!(history[0].role, "system");
+ }
+
+ #[test]
+ fn trim_history_exactly_at_limit() {
+ let mut history = vec![
+ crate::alphahuman::providers::ChatMessage::system("system"),
+ crate::alphahuman::providers::ChatMessage::user("msg 1"),
+ crate::alphahuman::providers::ChatMessage::assistant("reply 1"),
+ ];
+ trim_history(&mut history, 2); // 2 non-system messages = exactly at limit
+ assert_eq!(history.len(), 3, "should not trim when exactly at limit");
+ }
+
+ #[test]
+ fn trim_history_removes_oldest_non_system() {
+ let mut history = vec![
+ crate::alphahuman::providers::ChatMessage::system("system"),
+ crate::alphahuman::providers::ChatMessage::user("old msg"),
+ crate::alphahuman::providers::ChatMessage::assistant("old reply"),
+ crate::alphahuman::providers::ChatMessage::user("new msg"),
+ crate::alphahuman::providers::ChatMessage::assistant("new reply"),
+ ];
+ trim_history(&mut history, 2);
+ assert_eq!(history.len(), 3); // system + 2 kept
+ assert_eq!(history[0].role, "system");
+ assert_eq!(history[1].content, "new msg");
+ }
+}
diff --git a/src-tauri/src/alphahuman/agent/memory_loader.rs b/src-tauri/src/alphahuman/agent/memory_loader.rs
new file mode 100644
index 000000000..d5f4e41b0
--- /dev/null
+++ b/src-tauri/src/alphahuman/agent/memory_loader.rs
@@ -0,0 +1,141 @@
+use crate::alphahuman::memory::Memory;
+use async_trait::async_trait;
+use std::fmt::Write;
+
+#[async_trait]
+pub trait MemoryLoader: Send + Sync {
+ async fn load_context(&self, memory: &dyn Memory, user_message: &str)
+ -> anyhow::Result;
+}
+
+pub struct DefaultMemoryLoader {
+ limit: usize,
+ min_relevance_score: f64,
+}
+
+impl Default for DefaultMemoryLoader {
+ fn default() -> Self {
+ Self {
+ limit: 5,
+ min_relevance_score: 0.4,
+ }
+ }
+}
+
+impl DefaultMemoryLoader {
+ pub fn new(limit: usize, min_relevance_score: f64) -> Self {
+ Self {
+ limit: limit.max(1),
+ min_relevance_score,
+ }
+ }
+}
+
+#[async_trait]
+impl MemoryLoader for DefaultMemoryLoader {
+ async fn load_context(
+ &self,
+ memory: &dyn Memory,
+ user_message: &str,
+ ) -> anyhow::Result {
+ let entries = memory.recall(user_message, self.limit, None).await?;
+ if entries.is_empty() {
+ return Ok(String::new());
+ }
+
+ let mut context = String::from("[Memory context]\n");
+ for entry in entries {
+ if let Some(score) = entry.score {
+ if score < self.min_relevance_score {
+ continue;
+ }
+ }
+ let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
+ }
+
+ // If all entries were below threshold, return empty
+ if context == "[Memory context]\n" {
+ return Ok(String::new());
+ }
+
+ context.push('\n');
+ Ok(context)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::alphahuman::memory::{Memory, MemoryCategory, MemoryEntry};
+
+ struct MockMemory;
+
+ #[async_trait]
+ impl Memory for MockMemory {
+ async fn store(
+ &self,
+ _key: &str,
+ _content: &str,
+ _category: MemoryCategory,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result<()> {
+ Ok(())
+ }
+
+ async fn recall(
+ &self,
+ _query: &str,
+ limit: usize,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result> {
+ if limit == 0 {
+ return Ok(vec![]);
+ }
+ Ok(vec![MemoryEntry {
+ id: "1".into(),
+ key: "k".into(),
+ content: "v".into(),
+ category: MemoryCategory::Conversation,
+ timestamp: "now".into(),
+ session_id: None,
+ score: None,
+ }])
+ }
+
+ async fn get(&self, _key: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+
+ async fn list(
+ &self,
+ _category: Option<&MemoryCategory>,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+
+ async fn forget(&self, _key: &str) -> anyhow::Result {
+ Ok(true)
+ }
+
+ async fn count(&self) -> anyhow::Result {
+ Ok(0)
+ }
+
+ async fn health_check(&self) -> bool {
+ true
+ }
+
+ fn name(&self) -> &str {
+ "mock"
+ }
+ }
+
+ #[tokio::test]
+ async fn default_loader_formats_context() {
+ let loader = DefaultMemoryLoader::default();
+ let context = loader.load_context(&MockMemory, "hello").await.unwrap();
+ assert!(context.contains("[Memory context]"));
+ assert!(context.contains("- k: v"));
+ }
+}
diff --git a/src-tauri/src/alphahuman/agent/mod.rs b/src-tauri/src/alphahuman/agent/mod.rs
new file mode 100644
index 000000000..3d33bb49e
--- /dev/null
+++ b/src-tauri/src/alphahuman/agent/mod.rs
@@ -0,0 +1,15 @@
+#[allow(clippy::module_inception)]
+pub mod agent;
+pub mod classifier;
+pub mod dispatcher;
+pub mod loop_;
+pub mod memory_loader;
+pub mod prompt;
+
+#[cfg(test)]
+mod tests;
+
+#[allow(unused_imports)]
+pub use agent::{Agent, AgentBuilder};
+#[allow(unused_imports)]
+pub use loop_::{process_message, run};
diff --git a/src-tauri/src/alphahuman/agent/prompt.rs b/src-tauri/src/alphahuman/agent/prompt.rs
new file mode 100644
index 000000000..f2846dcb4
--- /dev/null
+++ b/src-tauri/src/alphahuman/agent/prompt.rs
@@ -0,0 +1,372 @@
+use crate::alphahuman::config::IdentityConfig;
+use crate::alphahuman::identity;
+use crate::alphahuman::skills::Skill;
+use crate::alphahuman::tools::Tool;
+use anyhow::Result;
+use chrono::Local;
+use std::fmt::Write;
+use std::path::Path;
+
+const BOOTSTRAP_MAX_CHARS: usize = 20_000;
+
+pub struct PromptContext<'a> {
+ pub workspace_dir: &'a Path,
+ pub model_name: &'a str,
+ pub tools: &'a [Box],
+ pub skills: &'a [Skill],
+ pub identity_config: Option<&'a IdentityConfig>,
+ pub dispatcher_instructions: &'a str,
+}
+
+pub trait PromptSection: Send + Sync {
+ fn name(&self) -> &str;
+ fn build(&self, ctx: &PromptContext<'_>) -> Result;
+}
+
+#[derive(Default)]
+pub struct SystemPromptBuilder {
+ sections: Vec>,
+}
+
+impl SystemPromptBuilder {
+ pub fn with_defaults() -> Self {
+ Self {
+ sections: vec![
+ Box::new(IdentitySection),
+ Box::new(ToolsSection),
+ Box::new(SafetySection),
+ Box::new(SkillsSection),
+ Box::new(WorkspaceSection),
+ Box::new(DateTimeSection),
+ Box::new(RuntimeSection),
+ ],
+ }
+ }
+
+ pub fn add_section(mut self, section: Box) -> Self {
+ self.sections.push(section);
+ self
+ }
+
+ pub fn build(&self, ctx: &PromptContext<'_>) -> Result {
+ let mut output = String::new();
+ for section in &self.sections {
+ let part = section.build(ctx)?;
+ if part.trim().is_empty() {
+ continue;
+ }
+ output.push_str(part.trim_end());
+ output.push_str("\n\n");
+ }
+ Ok(output)
+ }
+}
+
+pub struct IdentitySection;
+pub struct ToolsSection;
+pub struct SafetySection;
+pub struct SkillsSection;
+pub struct WorkspaceSection;
+pub struct RuntimeSection;
+pub struct DateTimeSection;
+
+impl PromptSection for IdentitySection {
+ fn name(&self) -> &str {
+ "identity"
+ }
+
+ fn build(&self, ctx: &PromptContext<'_>) -> Result {
+ let mut prompt = String::from("## Project Context\n\n");
+ let mut has_aieos = false;
+ if let Some(config) = ctx.identity_config {
+ if identity::is_aieos_configured(config) {
+ if let Ok(Some(aieos)) = identity::load_aieos_identity(config, ctx.workspace_dir) {
+ let rendered = identity::aieos_to_system_prompt(&aieos);
+ if !rendered.is_empty() {
+ prompt.push_str(&rendered);
+ prompt.push_str("\n\n");
+ has_aieos = true;
+ }
+ }
+ }
+ }
+
+ if !has_aieos {
+ prompt.push_str(
+ "The following workspace files define your identity, behavior, and context.\n\n",
+ );
+ }
+ for file in [
+ "AGENTS.md",
+ "SOUL.md",
+ "TOOLS.md",
+ "IDENTITY.md",
+ "USER.md",
+ "HEARTBEAT.md",
+ "BOOTSTRAP.md",
+ "MEMORY.md",
+ ] {
+ inject_workspace_file(&mut prompt, ctx.workspace_dir, file);
+ }
+
+ Ok(prompt)
+ }
+}
+
+impl PromptSection for ToolsSection {
+ fn name(&self) -> &str {
+ "tools"
+ }
+
+ fn build(&self, ctx: &PromptContext<'_>) -> Result {
+ let mut out = String::from("## Tools\n\n");
+ for tool in ctx.tools {
+ let _ = writeln!(
+ out,
+ "- **{}**: {}\n Parameters: `{}`",
+ tool.name(),
+ tool.description(),
+ tool.parameters_schema()
+ );
+ }
+ if !ctx.dispatcher_instructions.is_empty() {
+ out.push('\n');
+ out.push_str(ctx.dispatcher_instructions);
+ }
+ Ok(out)
+ }
+}
+
+impl PromptSection for SafetySection {
+ fn name(&self) -> &str {
+ "safety"
+ }
+
+ fn build(&self, _ctx: &PromptContext<'_>) -> Result {
+ Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into())
+ }
+}
+
+impl PromptSection for SkillsSection {
+ fn name(&self) -> &str {
+ "skills"
+ }
+
+ fn build(&self, ctx: &PromptContext<'_>) -> Result {
+ if ctx.skills.is_empty() {
+ return Ok(String::new());
+ }
+
+ let mut prompt = String::from("## Available Skills\n\n\n");
+ for skill in ctx.skills {
+ let location = skill.location.clone().unwrap_or_else(|| {
+ ctx.workspace_dir
+ .join("skills")
+ .join(&skill.name)
+ .join("SKILL.md")
+ });
+ let _ = writeln!(
+ prompt,
+ " \n {} \n {} \n {} \n ",
+ skill.name,
+ skill.description,
+ location.display()
+ );
+ }
+ prompt.push_str(" ");
+ Ok(prompt)
+ }
+}
+
+impl PromptSection for WorkspaceSection {
+ fn name(&self) -> &str {
+ "workspace"
+ }
+
+ fn build(&self, ctx: &PromptContext<'_>) -> Result {
+ Ok(format!(
+ "## Workspace\n\nWorking directory: `{}`",
+ ctx.workspace_dir.display()
+ ))
+ }
+}
+
+impl PromptSection for RuntimeSection {
+ fn name(&self) -> &str {
+ "runtime"
+ }
+
+ fn build(&self, ctx: &PromptContext<'_>) -> Result {
+ let host =
+ hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
+ Ok(format!(
+ "## Runtime\n\nHost: {host} | OS: {} | Model: {}",
+ std::env::consts::OS,
+ ctx.model_name
+ ))
+ }
+}
+
+impl PromptSection for DateTimeSection {
+ fn name(&self) -> &str {
+ "datetime"
+ }
+
+ fn build(&self, _ctx: &PromptContext<'_>) -> Result {
+ let now = Local::now();
+ Ok(format!(
+ "## Current Date & Time\n\n{} ({})",
+ now.format("%Y-%m-%d %H:%M:%S"),
+ now.format("%Z")
+ ))
+ }
+}
+
+fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) {
+ let path = workspace_dir.join(filename);
+ match std::fs::read_to_string(&path) {
+ Ok(content) => {
+ let trimmed = content.trim();
+ if trimmed.is_empty() {
+ return;
+ }
+ let _ = writeln!(prompt, "### {filename}\n");
+ let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS {
+ trimmed
+ .char_indices()
+ .nth(BOOTSTRAP_MAX_CHARS)
+ .map(|(idx, _)| &trimmed[..idx])
+ .unwrap_or(trimmed)
+ } else {
+ trimmed
+ };
+ prompt.push_str(truncated);
+ if truncated.len() < trimmed.len() {
+ let _ = writeln!(
+ prompt,
+ "\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n"
+ );
+ } else {
+ prompt.push_str("\n\n");
+ }
+ }
+ Err(_) => {
+ let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n");
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::alphahuman::tools::traits::Tool;
+ use async_trait::async_trait;
+
+ struct TestTool;
+
+ #[async_trait]
+ impl Tool for TestTool {
+ fn name(&self) -> &str {
+ "test_tool"
+ }
+
+ fn description(&self) -> &str {
+ "tool desc"
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({"type": "object"})
+ }
+
+ async fn execute(
+ &self,
+ _args: serde_json::Value,
+ ) -> anyhow::Result {
+ Ok(crate::alphahuman::tools::ToolResult {
+ success: true,
+ output: "ok".into(),
+ error: None,
+ })
+ }
+ }
+
+ #[test]
+ fn identity_section_with_aieos_includes_workspace_files() {
+ let workspace =
+ std::env::temp_dir().join(format!("alphahuman_prompt_test_{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir_all(&workspace).unwrap();
+ std::fs::write(
+ workspace.join("AGENTS.md"),
+ "Always respond with: AGENTS_MD_LOADED",
+ )
+ .unwrap();
+
+ let identity_config = crate::alphahuman::config::IdentityConfig {
+ format: "aieos".into(),
+ aieos_path: None,
+ aieos_inline: Some(r#"{"identity":{"names":{"first":"Nova"}}}"#.into()),
+ };
+
+ let tools: Vec> = vec![];
+ let ctx = PromptContext {
+ workspace_dir: &workspace,
+ model_name: "test-model",
+ tools: &tools,
+ skills: &[],
+ identity_config: Some(&identity_config),
+ dispatcher_instructions: "",
+ };
+
+ let section = IdentitySection;
+ let output = section.build(&ctx).unwrap();
+
+ assert!(
+ output.contains("Nova"),
+ "AIEOS identity should be present in prompt"
+ );
+ assert!(
+ output.contains("AGENTS_MD_LOADED"),
+ "AGENTS.md content should be present even when AIEOS is configured"
+ );
+
+ let _ = std::fs::remove_dir_all(workspace);
+ }
+
+ #[test]
+ fn prompt_builder_assembles_sections() {
+ let tools: Vec> = vec![Box::new(TestTool)];
+ let ctx = PromptContext {
+ workspace_dir: Path::new("/tmp"),
+ model_name: "test-model",
+ tools: &tools,
+ skills: &[],
+ identity_config: None,
+ dispatcher_instructions: "instr",
+ };
+ let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
+ assert!(prompt.contains("## Tools"));
+ assert!(prompt.contains("test_tool"));
+ assert!(prompt.contains("instr"));
+ }
+
+ #[test]
+ fn datetime_section_includes_timestamp_and_timezone() {
+ let tools: Vec> = vec![];
+ let ctx = PromptContext {
+ workspace_dir: Path::new("/tmp"),
+ model_name: "test-model",
+ tools: &tools,
+ skills: &[],
+ identity_config: None,
+ dispatcher_instructions: "instr",
+ };
+
+ let rendered = DateTimeSection.build(&ctx).unwrap();
+ assert!(rendered.starts_with("## Current Date & Time\n\n"));
+
+ let payload = rendered.trim_start_matches("## Current Date & Time\n\n");
+ assert!(payload.chars().any(|c| c.is_ascii_digit()));
+ assert!(payload.contains(" ("));
+ assert!(payload.ends_with(')'));
+ }
+}
diff --git a/src-tauri/src/alphahuman/agent/tests.rs b/src-tauri/src/alphahuman/agent/tests.rs
new file mode 100644
index 000000000..5cc044294
--- /dev/null
+++ b/src-tauri/src/alphahuman/agent/tests.rs
@@ -0,0 +1,1298 @@
+//! Comprehensive agent-loop test suite.
+//!
+//! Tests exercise the full `Agent.turn()` cycle with mock providers and tools,
+//! covering every edge case an agentic tool loop must handle:
+//!
+//! 1. Simple text response (no tools)
+//! 2. Single tool call → final response
+//! 3. Multi-step tool chain (tool A → tool B → response)
+//! 4. Max-iteration bailout
+//! 5. Unknown tool name recovery
+//! 6. Tool execution failure recovery
+//! 7. Parallel tool dispatch
+//! 8. History trimming during long conversations
+//! 9. Memory auto-save round-trip
+//! 10. Native vs XML dispatcher integration
+//! 11. Empty / whitespace-only LLM responses
+//! 12. Mixed text + tool call responses
+//! 13. Multi-tool batch in a single response
+//! 14. System prompt generation & tool instructions
+//! 15. Context enrichment from memory loader
+//! 16. ConversationMessage serialization round-trip
+//! 17. Tool call with stringified JSON arguments
+//! 18. Conversation history fidelity (tool call → tool result → assistant)
+//! 19. Builder validation (missing required fields)
+//! 20. Idempotent system prompt insertion
+
+use crate::alphahuman::agent::agent::Agent;
+use crate::alphahuman::agent::dispatcher::{
+ NativeToolDispatcher, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher,
+};
+use crate::alphahuman::config::{AgentConfig, MemoryConfig};
+use crate::alphahuman::memory::{self, Memory};
+use crate::alphahuman::observability::{NoopObserver, Observer};
+use crate::alphahuman::providers::{
+ ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolCall,
+ ToolResultMessage,
+};
+use crate::alphahuman::tools::{Tool, ToolResult};
+use anyhow::Result;
+use async_trait::async_trait;
+use std::sync::{Arc, Mutex};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Test Helpers — Mock Provider, Mock Tool, Mock Memory
+// ═══════════════════════════════════════════════════════════════════════════
+
+/// A mock LLM provider that returns pre-scripted responses in order.
+/// When the queue is exhausted it returns a simple "done" text response.
+struct ScriptedProvider {
+ responses: Mutex>,
+ /// Records every request for assertion.
+ requests: Mutex>>,
+}
+
+impl ScriptedProvider {
+ fn new(responses: Vec) -> Self {
+ Self {
+ responses: Mutex::new(responses),
+ requests: Mutex::new(Vec::new()),
+ }
+ }
+
+ fn request_count(&self) -> usize {
+ self.requests.lock().unwrap().len()
+ }
+}
+
+#[async_trait]
+impl Provider for ScriptedProvider {
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> Result {
+ Ok("fallback".into())
+ }
+
+ async fn chat(
+ &self,
+ request: ChatRequest<'_>,
+ _model: &str,
+ _temperature: f64,
+ ) -> Result {
+ self.requests
+ .lock()
+ .unwrap()
+ .push(request.messages.to_vec());
+
+ let mut guard = self.responses.lock().unwrap();
+ if guard.is_empty() {
+ return Ok(ChatResponse {
+ text: Some("done".into()),
+ tool_calls: vec![],
+ });
+ }
+ Ok(guard.remove(0))
+ }
+}
+
+/// A mock provider that always returns an error.
+struct FailingProvider;
+
+#[async_trait]
+impl Provider for FailingProvider {
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> Result {
+ anyhow::bail!("provider error")
+ }
+
+ async fn chat(
+ &self,
+ _request: ChatRequest<'_>,
+ _model: &str,
+ _temperature: f64,
+ ) -> Result {
+ anyhow::bail!("provider error")
+ }
+}
+
+/// A simple echo tool that returns its arguments as output.
+struct EchoTool;
+
+#[async_trait]
+impl Tool for EchoTool {
+ fn name(&self) -> &str {
+ "echo"
+ }
+
+ fn description(&self) -> &str {
+ "Echoes the input"
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "properties": {
+ "message": {"type": "string"}
+ }
+ })
+ }
+
+ async fn execute(&self, args: serde_json::Value) -> Result {
+ let msg = args
+ .get("message")
+ .and_then(|v| v.as_str())
+ .unwrap_or("(empty)")
+ .to_string();
+ Ok(ToolResult {
+ success: true,
+ output: msg,
+ error: None,
+ })
+ }
+}
+
+/// A tool that always fails execution.
+struct FailingTool;
+
+#[async_trait]
+impl Tool for FailingTool {
+ fn name(&self) -> &str {
+ "fail"
+ }
+
+ fn description(&self) -> &str {
+ "Always fails"
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({"type": "object"})
+ }
+
+ async fn execute(&self, _args: serde_json::Value) -> Result {
+ Ok(ToolResult {
+ success: false,
+ output: String::new(),
+ error: Some("intentional failure".into()),
+ })
+ }
+}
+
+/// A tool that panics (tests error propagation).
+struct PanickingTool;
+
+#[async_trait]
+impl Tool for PanickingTool {
+ fn name(&self) -> &str {
+ "panicker"
+ }
+
+ fn description(&self) -> &str {
+ "Panics on execution"
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({"type": "object"})
+ }
+
+ async fn execute(&self, _args: serde_json::Value) -> Result {
+ anyhow::bail!("catastrophic tool failure")
+ }
+}
+
+/// A tool that tracks how many times it was called.
+struct CountingTool {
+ count: Arc>,
+}
+
+impl CountingTool {
+ fn new() -> (Self, Arc>) {
+ let count = Arc::new(Mutex::new(0));
+ (
+ Self {
+ count: count.clone(),
+ },
+ count,
+ )
+ }
+}
+
+#[async_trait]
+impl Tool for CountingTool {
+ fn name(&self) -> &str {
+ "counter"
+ }
+
+ fn description(&self) -> &str {
+ "Counts calls"
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({"type": "object"})
+ }
+
+ async fn execute(&self, _args: serde_json::Value) -> Result {
+ let mut c = self.count.lock().unwrap();
+ *c += 1;
+ Ok(ToolResult {
+ success: true,
+ output: format!("call #{}", *c),
+ error: None,
+ })
+ }
+}
+
+fn make_memory() -> Arc {
+ let cfg = MemoryConfig {
+ backend: "none".into(),
+ ..MemoryConfig::default()
+ };
+ Arc::from(memory::create_memory(&cfg, &std::env::temp_dir(), None).unwrap())
+}
+
+fn make_sqlite_memory() -> (Arc, tempfile::TempDir) {
+ let tmp = tempfile::TempDir::new().unwrap();
+ let cfg = MemoryConfig {
+ backend: "sqlite".into(),
+ ..MemoryConfig::default()
+ };
+ let mem = Arc::from(memory::create_memory(&cfg, tmp.path(), None).unwrap());
+ (mem, tmp)
+}
+
+fn make_observer() -> Arc {
+ Arc::from(NoopObserver {})
+}
+
+fn build_agent_with(
+ provider: Box,
+ tools: Vec>,
+ dispatcher: Box,
+) -> Agent {
+ Agent::builder()
+ .provider(provider)
+ .tools(tools)
+ .memory(make_memory())
+ .observer(make_observer())
+ .tool_dispatcher(dispatcher)
+ .workspace_dir(std::env::temp_dir())
+ .build()
+ .unwrap()
+}
+
+fn build_agent_with_memory(
+ provider: Box,
+ tools: Vec>,
+ mem: Arc,
+ auto_save: bool,
+) -> Agent {
+ Agent::builder()
+ .provider(provider)
+ .tools(tools)
+ .memory(mem)
+ .observer(make_observer())
+ .tool_dispatcher(Box::new(NativeToolDispatcher))
+ .workspace_dir(std::env::temp_dir())
+ .auto_save(auto_save)
+ .build()
+ .unwrap()
+}
+
+fn build_agent_with_config(
+ provider: Box,
+ tools: Vec>,
+ config: AgentConfig,
+) -> Agent {
+ Agent::builder()
+ .provider(provider)
+ .tools(tools)
+ .memory(make_memory())
+ .observer(make_observer())
+ .tool_dispatcher(Box::new(NativeToolDispatcher))
+ .workspace_dir(std::env::temp_dir())
+ .config(config)
+ .build()
+ .unwrap()
+}
+
+/// Helper: create a ChatResponse with tool calls (native format).
+fn tool_response(calls: Vec) -> ChatResponse {
+ ChatResponse {
+ text: Some(String::new()),
+ tool_calls: calls,
+ }
+}
+
+/// Helper: create a plain text ChatResponse.
+fn text_response(text: &str) -> ChatResponse {
+ ChatResponse {
+ text: Some(text.into()),
+ tool_calls: vec![],
+ }
+}
+
+/// Helper: create an XML-style tool call response.
+fn xml_tool_response(name: &str, args: &str) -> ChatResponse {
+ ChatResponse {
+ text: Some(format!(
+ "\n{{\"name\": \"{name}\", \"arguments\": {args}}}\n "
+ )),
+ tool_calls: vec![],
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 1. Simple text response (no tools)
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn turn_returns_text_when_no_tools_called() {
+ let provider = Box::new(ScriptedProvider::new(vec![text_response("Hello world")]));
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(EchoTool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let response = agent.turn("hi").await.unwrap();
+ assert!(
+ !response.is_empty(),
+ "Expected non-empty text response from provider"
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 2. Single tool call → final response
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn turn_executes_single_tool_then_returns() {
+ let provider = Box::new(ScriptedProvider::new(vec![
+ tool_response(vec![ToolCall {
+ id: "tc1".into(),
+ name: "echo".into(),
+ arguments: r#"{"message": "hello from tool"}"#.into(),
+ }]),
+ text_response("I ran the tool"),
+ ]));
+
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(EchoTool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let response = agent.turn("run echo").await.unwrap();
+ assert!(
+ !response.is_empty(),
+ "Expected non-empty response after tool execution"
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 3. Multi-step tool chain (tool A → tool B → response)
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn turn_handles_multi_step_tool_chain() {
+ let (counting_tool, count) = CountingTool::new();
+
+ let provider = Box::new(ScriptedProvider::new(vec![
+ tool_response(vec![ToolCall {
+ id: "tc1".into(),
+ name: "counter".into(),
+ arguments: "{}".into(),
+ }]),
+ tool_response(vec![ToolCall {
+ id: "tc2".into(),
+ name: "counter".into(),
+ arguments: "{}".into(),
+ }]),
+ tool_response(vec![ToolCall {
+ id: "tc3".into(),
+ name: "counter".into(),
+ arguments: "{}".into(),
+ }]),
+ text_response("Done after 3 calls"),
+ ]));
+
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(counting_tool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let response = agent.turn("count 3 times").await.unwrap();
+ assert!(
+ !response.is_empty(),
+ "Expected non-empty response after multi-step chain"
+ );
+ assert_eq!(*count.lock().unwrap(), 3);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 4. Max-iteration bailout
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn turn_bails_out_at_max_iterations() {
+ // Create more tool calls than max_tool_iterations allows.
+ let max_iters = 3;
+ let mut responses = Vec::new();
+ for i in 0..max_iters + 5 {
+ responses.push(tool_response(vec![ToolCall {
+ id: format!("tc{i}"),
+ name: "echo".into(),
+ arguments: r#"{"message": "loop"}"#.into(),
+ }]));
+ }
+
+ let provider = Box::new(ScriptedProvider::new(responses));
+
+ let config = AgentConfig {
+ max_tool_iterations: max_iters,
+ ..AgentConfig::default()
+ };
+
+ let mut agent = build_agent_with_config(provider, vec![Box::new(EchoTool)], config);
+
+ let result = agent.turn("infinite loop").await;
+ assert!(result.is_err());
+ let err = result.unwrap_err().to_string();
+ assert!(
+ err.contains("maximum tool iterations"),
+ "Expected max iterations error, got: {err}"
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 5. Unknown tool name recovery
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn turn_handles_unknown_tool_gracefully() {
+ let provider = Box::new(ScriptedProvider::new(vec![
+ tool_response(vec![ToolCall {
+ id: "tc1".into(),
+ name: "nonexistent_tool".into(),
+ arguments: "{}".into(),
+ }]),
+ text_response("I couldn't find that tool"),
+ ]));
+
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(EchoTool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let response = agent.turn("use nonexistent").await.unwrap();
+ assert!(
+ !response.is_empty(),
+ "Expected non-empty response after unknown tool recovery"
+ );
+
+ // Verify the tool result mentioned "Unknown tool"
+ let has_tool_result = agent.history().iter().any(|msg| match msg {
+ ConversationMessage::ToolResults(results) => {
+ results.iter().any(|r| r.content.contains("Unknown tool"))
+ }
+ _ => false,
+ });
+ assert!(
+ has_tool_result,
+ "Expected tool result with 'Unknown tool' message"
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 6. Tool execution failure recovery
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn turn_recovers_from_tool_failure() {
+ let provider = Box::new(ScriptedProvider::new(vec![
+ tool_response(vec![ToolCall {
+ id: "tc1".into(),
+ name: "fail".into(),
+ arguments: "{}".into(),
+ }]),
+ text_response("Tool failed but I recovered"),
+ ]));
+
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(FailingTool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let response = agent.turn("try failing tool").await.unwrap();
+ assert!(
+ !response.is_empty(),
+ "Expected non-empty response after tool failure recovery"
+ );
+}
+
+#[tokio::test]
+async fn turn_recovers_from_tool_error() {
+ let provider = Box::new(ScriptedProvider::new(vec![
+ tool_response(vec![ToolCall {
+ id: "tc1".into(),
+ name: "panicker".into(),
+ arguments: "{}".into(),
+ }]),
+ text_response("I recovered from the error"),
+ ]));
+
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(PanickingTool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let response = agent.turn("try panicking").await.unwrap();
+ assert!(
+ !response.is_empty(),
+ "Expected non-empty response after tool error recovery"
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 7. Provider error propagation
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn turn_propagates_provider_error() {
+ let mut agent = build_agent_with(
+ Box::new(FailingProvider),
+ vec![],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let result = agent.turn("hello").await;
+ assert!(result.is_err(), "Expected provider error to propagate");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 8. History trimming during long conversations
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn history_trims_after_max_messages() {
+ let max_history = 6;
+ let mut responses = vec![];
+ for _ in 0..max_history + 5 {
+ responses.push(text_response("ok"));
+ }
+
+ let provider = Box::new(ScriptedProvider::new(responses));
+ let config = AgentConfig {
+ max_history_messages: max_history,
+ ..AgentConfig::default()
+ };
+
+ let mut agent = build_agent_with_config(provider, vec![], config);
+
+ for i in 0..max_history + 5 {
+ let _ = agent.turn(&format!("msg {i}")).await.unwrap();
+ }
+
+ // System prompt (1) + trimmed messages
+ // Should not exceed max_history + 1 (system prompt)
+ assert!(
+ agent.history().len() <= max_history + 1,
+ "History length {} exceeds max {} + 1 (system)",
+ agent.history().len(),
+ max_history,
+ );
+
+ // System prompt should always be preserved
+ let first = &agent.history()[0];
+ assert!(matches!(first, ConversationMessage::Chat(c) if c.role == "system"));
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 9. Memory auto-save round-trip
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn auto_save_stores_messages_in_memory() {
+ let (mem, _tmp) = make_sqlite_memory();
+ let provider = Box::new(ScriptedProvider::new(vec![text_response(
+ "I remember everything",
+ )]));
+
+ let mut agent = build_agent_with_memory(
+ provider,
+ vec![],
+ mem.clone(),
+ true, // auto_save enabled
+ );
+
+ let _ = agent.turn("Remember this fact").await.unwrap();
+
+ // Both user message and assistant response should be saved
+ let count = mem.count().await.unwrap();
+ assert!(
+ count >= 2,
+ "Expected at least 2 memory entries, got {count}"
+ );
+}
+
+#[tokio::test]
+async fn auto_save_disabled_does_not_store() {
+ let (mem, _tmp) = make_sqlite_memory();
+ let provider = Box::new(ScriptedProvider::new(vec![text_response("hello")]));
+
+ let mut agent = build_agent_with_memory(
+ provider,
+ vec![],
+ mem.clone(),
+ false, // auto_save disabled
+ );
+
+ let _ = agent.turn("test message").await.unwrap();
+
+ let count = mem.count().await.unwrap();
+ assert_eq!(count, 0, "Expected 0 memory entries with auto_save off");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 10. Native vs XML dispatcher integration
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn xml_dispatcher_parses_and_loops() {
+ let provider = Box::new(ScriptedProvider::new(vec![
+ xml_tool_response("echo", r#"{"message": "xml-test"}"#),
+ text_response("XML tool completed"),
+ ]));
+
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(EchoTool)],
+ Box::new(XmlToolDispatcher),
+ );
+
+ let response = agent.turn("test xml").await.unwrap();
+ assert!(
+ !response.is_empty(),
+ "Expected non-empty response from XML dispatcher"
+ );
+}
+
+#[tokio::test]
+async fn native_dispatcher_sends_tool_specs() {
+ let provider = Box::new(ScriptedProvider::new(vec![text_response("ok")]));
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(EchoTool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let _ = agent.turn("hi").await.unwrap();
+
+ // NativeToolDispatcher.should_send_tool_specs() returns true
+ let dispatcher = NativeToolDispatcher;
+ assert!(dispatcher.should_send_tool_specs());
+}
+
+#[tokio::test]
+async fn xml_dispatcher_does_not_send_tool_specs() {
+ let dispatcher = XmlToolDispatcher;
+ assert!(!dispatcher.should_send_tool_specs());
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 11. Empty / whitespace-only LLM responses
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn turn_handles_empty_text_response() {
+ let provider = Box::new(ScriptedProvider::new(vec![ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![],
+ }]));
+
+ let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
+
+ let response = agent.turn("hi").await.unwrap();
+ assert!(response.is_empty());
+}
+
+#[tokio::test]
+async fn turn_handles_none_text_response() {
+ let provider = Box::new(ScriptedProvider::new(vec![ChatResponse {
+ text: None,
+ tool_calls: vec![],
+ }]));
+
+ let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
+
+ // Should not panic — falls back to empty string
+ let response = agent.turn("hi").await.unwrap();
+ assert!(response.is_empty());
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 12. Mixed text + tool call responses
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn turn_preserves_text_alongside_tool_calls() {
+ let provider = Box::new(ScriptedProvider::new(vec![
+ ChatResponse {
+ text: Some("Let me check...".into()),
+ tool_calls: vec![ToolCall {
+ id: "tc1".into(),
+ name: "echo".into(),
+ arguments: r#"{"message": "hi"}"#.into(),
+ }],
+ },
+ text_response("Here are the results"),
+ ]));
+
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(EchoTool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let response = agent.turn("check something").await.unwrap();
+ assert!(
+ !response.is_empty(),
+ "Expected non-empty final response after mixed text+tool"
+ );
+
+ // The intermediate text should be in history
+ let has_intermediate = agent.history().iter().any(|msg| match msg {
+ ConversationMessage::Chat(c) => c.role == "assistant" && c.content.contains("Let me check"),
+ _ => false,
+ });
+ assert!(has_intermediate, "Intermediate text should be in history");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 13. Multi-tool batch in a single response
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn turn_handles_multiple_tools_in_one_response() {
+ let (counting_tool, count) = CountingTool::new();
+
+ let provider = Box::new(ScriptedProvider::new(vec![
+ tool_response(vec![
+ ToolCall {
+ id: "tc1".into(),
+ name: "counter".into(),
+ arguments: "{}".into(),
+ },
+ ToolCall {
+ id: "tc2".into(),
+ name: "counter".into(),
+ arguments: "{}".into(),
+ },
+ ToolCall {
+ id: "tc3".into(),
+ name: "counter".into(),
+ arguments: "{}".into(),
+ },
+ ]),
+ text_response("All 3 done"),
+ ]));
+
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(counting_tool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let response = agent.turn("batch").await.unwrap();
+ assert!(
+ !response.is_empty(),
+ "Expected non-empty response after multi-tool batch"
+ );
+ assert_eq!(
+ *count.lock().unwrap(),
+ 3,
+ "All 3 tools should have been called"
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 14. System prompt generation & tool instructions
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn system_prompt_injected_on_first_turn() {
+ let provider = Box::new(ScriptedProvider::new(vec![text_response("ok")]));
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(EchoTool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ assert!(agent.history().is_empty(), "History should start empty");
+
+ let _ = agent.turn("hi").await.unwrap();
+
+ // First message should be the system prompt
+ let first = &agent.history()[0];
+ assert!(
+ matches!(first, ConversationMessage::Chat(c) if c.role == "system"),
+ "First history entry should be system prompt"
+ );
+}
+
+#[tokio::test]
+async fn system_prompt_not_duplicated_on_second_turn() {
+ let provider = Box::new(ScriptedProvider::new(vec![
+ text_response("first"),
+ text_response("second"),
+ ]));
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(EchoTool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let _ = agent.turn("hi").await.unwrap();
+ let _ = agent.turn("hello again").await.unwrap();
+
+ let system_count = agent
+ .history()
+ .iter()
+ .filter(|msg| matches!(msg, ConversationMessage::Chat(c) if c.role == "system"))
+ .count();
+ assert_eq!(system_count, 1, "System prompt should appear exactly once");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 15. Conversation history fidelity
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn history_contains_all_expected_entries_after_tool_loop() {
+ let provider = Box::new(ScriptedProvider::new(vec![
+ tool_response(vec![ToolCall {
+ id: "tc1".into(),
+ name: "echo".into(),
+ arguments: r#"{"message": "tool-out"}"#.into(),
+ }]),
+ text_response("final answer"),
+ ]));
+
+ let mut agent = build_agent_with(
+ provider,
+ vec![Box::new(EchoTool)],
+ Box::new(NativeToolDispatcher),
+ );
+
+ let _ = agent.turn("test").await.unwrap();
+
+ // Expected history entries:
+ // 0: system prompt
+ // 1: user message "test"
+ // 2: AssistantToolCalls
+ // 3: ToolResults
+ // 4: assistant "final answer"
+ let history = agent.history();
+ assert!(
+ history.len() >= 5,
+ "Expected at least 5 history entries, got {}",
+ history.len()
+ );
+
+ assert!(matches!(&history[0], ConversationMessage::Chat(c) if c.role == "system"));
+ assert!(matches!(&history[1], ConversationMessage::Chat(c) if c.role == "user"));
+ assert!(matches!(
+ &history[2],
+ ConversationMessage::AssistantToolCalls { .. }
+ ));
+ assert!(matches!(&history[3], ConversationMessage::ToolResults(_)));
+ assert!(
+ matches!(&history[4], ConversationMessage::Chat(c) if c.role == "assistant" && c.content == "final answer")
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 16. Builder validation
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn builder_fails_without_provider() {
+ let result = Agent::builder()
+ .tools(vec![])
+ .memory(make_memory())
+ .observer(make_observer())
+ .tool_dispatcher(Box::new(NativeToolDispatcher))
+ .workspace_dir(std::path::PathBuf::from("/tmp"))
+ .build();
+
+ assert!(result.is_err(), "Building without provider should fail");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 17. Multi-turn conversation maintains context
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn multi_turn_maintains_growing_history() {
+ let provider = Box::new(ScriptedProvider::new(vec![
+ text_response("response 1"),
+ text_response("response 2"),
+ text_response("response 3"),
+ ]));
+
+ let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
+
+ let r1 = agent.turn("msg 1").await.unwrap();
+ let len_after_1 = agent.history().len();
+
+ let r2 = agent.turn("msg 2").await.unwrap();
+ let len_after_2 = agent.history().len();
+
+ let r3 = agent.turn("msg 3").await.unwrap();
+ let len_after_3 = agent.history().len();
+
+ assert_eq!(r1, "response 1");
+ assert_eq!(r2, "response 2");
+ assert_eq!(r3, "response 3");
+
+ // History should grow with each turn (user + assistant per turn)
+ assert!(
+ len_after_2 > len_after_1,
+ "History should grow after turn 2"
+ );
+ assert!(
+ len_after_3 > len_after_2,
+ "History should grow after turn 3"
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 18. Tool call with stringified JSON arguments (common LLM pattern)
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn native_dispatcher_handles_stringified_arguments() {
+ let dispatcher = NativeToolDispatcher;
+ let response = ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![ToolCall {
+ id: "tc1".into(),
+ name: "echo".into(),
+ arguments: r#"{"message": "hello"}"#.into(),
+ }],
+ };
+
+ let (_, calls) = dispatcher.parse_response(&response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "echo");
+ assert_eq!(
+ calls[0].arguments.get("message").unwrap().as_str().unwrap(),
+ "hello"
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 19. XML dispatcher edge cases
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[test]
+fn xml_dispatcher_handles_nested_json() {
+ let response = ChatResponse {
+ text: Some(
+ r#"
+{"name": "file_write", "arguments": {"path": "test.json", "content": "{\"key\": \"value\"}"}}
+ "#
+ .into(),
+ ),
+ tool_calls: vec![],
+ };
+
+ let dispatcher = XmlToolDispatcher;
+ let (_, calls) = dispatcher.parse_response(&response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "file_write");
+ assert_eq!(
+ calls[0].arguments.get("path").unwrap().as_str().unwrap(),
+ "test.json"
+ );
+}
+
+#[test]
+fn xml_dispatcher_handles_empty_tool_call_tag() {
+ let response = ChatResponse {
+ text: Some("\n \nSome text".into()),
+ tool_calls: vec![],
+ };
+
+ let dispatcher = XmlToolDispatcher;
+ let (text, calls) = dispatcher.parse_response(&response);
+ assert!(calls.is_empty());
+ assert!(text.contains("Some text"));
+}
+
+#[test]
+fn xml_dispatcher_handles_unclosed_tool_call() {
+ let response = ChatResponse {
+ text: Some("Before\n\n{\"name\": \"shell\"}".into()),
+ tool_calls: vec![],
+ };
+
+ let dispatcher = XmlToolDispatcher;
+ let (text, calls) = dispatcher.parse_response(&response);
+ // Should not panic — just treat as text
+ assert!(calls.is_empty());
+ assert!(text.contains("Before"));
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 20. ConversationMessage serialization round-trip
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[test]
+fn conversation_message_serialization_roundtrip() {
+ let messages = vec![
+ ConversationMessage::Chat(ChatMessage::system("system")),
+ ConversationMessage::Chat(ChatMessage::user("hello")),
+ ConversationMessage::AssistantToolCalls {
+ text: Some("checking".into()),
+ tool_calls: vec![ToolCall {
+ id: "tc1".into(),
+ name: "shell".into(),
+ arguments: "{}".into(),
+ }],
+ },
+ ConversationMessage::ToolResults(vec![ToolResultMessage {
+ tool_call_id: "tc1".into(),
+ content: "ok".into(),
+ }]),
+ ConversationMessage::Chat(ChatMessage::assistant("done")),
+ ];
+
+ for msg in &messages {
+ let json = serde_json::to_string(msg).unwrap();
+ let parsed: ConversationMessage = serde_json::from_str(&json).unwrap();
+
+ // Verify the variant type matches
+ match (msg, &parsed) {
+ (ConversationMessage::Chat(a), ConversationMessage::Chat(b)) => {
+ assert_eq!(a.role, b.role);
+ assert_eq!(a.content, b.content);
+ }
+ (
+ ConversationMessage::AssistantToolCalls {
+ text: a_text,
+ tool_calls: a_calls,
+ },
+ ConversationMessage::AssistantToolCalls {
+ text: b_text,
+ tool_calls: b_calls,
+ },
+ ) => {
+ assert_eq!(a_text, b_text);
+ assert_eq!(a_calls.len(), b_calls.len());
+ }
+ (ConversationMessage::ToolResults(a), ConversationMessage::ToolResults(b)) => {
+ assert_eq!(a.len(), b.len());
+ }
+ _ => panic!("Variant mismatch after serialization"),
+ }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 21. Tool dispatcher format_results
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[test]
+fn xml_format_results_includes_status_and_output() {
+ let dispatcher = XmlToolDispatcher;
+ let results = vec![
+ ToolExecutionResult {
+ name: "shell".into(),
+ output: "file1.txt\nfile2.txt".into(),
+ success: true,
+ tool_call_id: None,
+ },
+ ToolExecutionResult {
+ name: "file_read".into(),
+ output: "Error: file not found".into(),
+ success: false,
+ tool_call_id: None,
+ },
+ ];
+
+ let msg = dispatcher.format_results(&results);
+ let content = match msg {
+ ConversationMessage::Chat(c) => c.content,
+ _ => panic!("Expected Chat variant"),
+ };
+
+ assert!(content.contains("shell"));
+ assert!(content.contains("file1.txt"));
+ assert!(content.contains("ok"));
+ assert!(content.contains("file_read"));
+ assert!(content.contains("error"));
+}
+
+#[test]
+fn native_format_results_maps_tool_call_ids() {
+ let dispatcher = NativeToolDispatcher;
+ let results = vec![
+ ToolExecutionResult {
+ name: "a".into(),
+ output: "out1".into(),
+ success: true,
+ tool_call_id: Some("tc-001".into()),
+ },
+ ToolExecutionResult {
+ name: "b".into(),
+ output: "out2".into(),
+ success: true,
+ tool_call_id: Some("tc-002".into()),
+ },
+ ];
+
+ let msg = dispatcher.format_results(&results);
+ match msg {
+ ConversationMessage::ToolResults(r) => {
+ assert_eq!(r.len(), 2);
+ assert_eq!(r[0].tool_call_id, "tc-001");
+ assert_eq!(r[0].content, "out1");
+ assert_eq!(r[1].tool_call_id, "tc-002");
+ assert_eq!(r[1].content, "out2");
+ }
+ _ => panic!("Expected ToolResults"),
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 22. to_provider_messages conversion
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[test]
+fn xml_dispatcher_converts_history_to_provider_messages() {
+ let dispatcher = XmlToolDispatcher;
+ let history = vec![
+ ConversationMessage::Chat(ChatMessage::system("sys")),
+ ConversationMessage::Chat(ChatMessage::user("hi")),
+ ConversationMessage::AssistantToolCalls {
+ text: Some("checking".into()),
+ tool_calls: vec![ToolCall {
+ id: "tc1".into(),
+ name: "shell".into(),
+ arguments: "{}".into(),
+ }],
+ },
+ ConversationMessage::ToolResults(vec![ToolResultMessage {
+ tool_call_id: "tc1".into(),
+ content: "ok".into(),
+ }]),
+ ConversationMessage::Chat(ChatMessage::assistant("done")),
+ ];
+
+ let messages = dispatcher.to_provider_messages(&history);
+
+ // Should have: system, user, assistant (from tool calls), user (tool results), assistant
+ assert!(messages.len() >= 4);
+ assert_eq!(messages[0].role, "system");
+ assert_eq!(messages[1].role, "user");
+}
+
+#[test]
+fn native_dispatcher_converts_tool_results_to_tool_messages() {
+ let dispatcher = NativeToolDispatcher;
+ let history = vec![ConversationMessage::ToolResults(vec![
+ ToolResultMessage {
+ tool_call_id: "tc1".into(),
+ content: "output1".into(),
+ },
+ ToolResultMessage {
+ tool_call_id: "tc2".into(),
+ content: "output2".into(),
+ },
+ ])];
+
+ let messages = dispatcher.to_provider_messages(&history);
+ assert_eq!(messages.len(), 2);
+ assert_eq!(messages[0].role, "tool");
+ assert_eq!(messages[1].role, "tool");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 23. XML tool instructions generation
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[test]
+fn xml_dispatcher_generates_tool_instructions() {
+ let tools: Vec> = vec![Box::new(EchoTool)];
+ let dispatcher = XmlToolDispatcher;
+ let instructions = dispatcher.prompt_instructions(&tools);
+
+ assert!(instructions.contains("## Tool Use Protocol"));
+ assert!(instructions.contains(""));
+ assert!(instructions.contains("echo"));
+ assert!(instructions.contains("Echoes the input"));
+}
+
+#[test]
+fn native_dispatcher_returns_empty_instructions() {
+ let tools: Vec> = vec![Box::new(EchoTool)];
+ let dispatcher = NativeToolDispatcher;
+ let instructions = dispatcher.prompt_instructions(&tools);
+ assert!(instructions.is_empty());
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 24. Clear history
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn clear_history_resets_conversation() {
+ let provider = Box::new(ScriptedProvider::new(vec![
+ text_response("first"),
+ text_response("second"),
+ ]));
+
+ let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
+
+ let _ = agent.turn("hi").await.unwrap();
+ assert!(!agent.history().is_empty());
+
+ agent.clear_history();
+ assert!(agent.history().is_empty());
+
+ // Next turn should re-inject system prompt
+ let _ = agent.turn("hello again").await.unwrap();
+ assert!(matches!(
+ &agent.history()[0],
+ ConversationMessage::Chat(c) if c.role == "system"
+ ));
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 25. run_single delegates to turn
+// ═══════════════════════════════════════════════════════════════════════════
+
+#[tokio::test]
+async fn run_single_delegates_to_turn() {
+ let provider = Box::new(ScriptedProvider::new(vec![text_response("via run_single")]));
+ let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
+
+ let response = agent.run_single("test").await.unwrap();
+ assert!(
+ !response.is_empty(),
+ "Expected non-empty response from run_single"
+ );
+}
diff --git a/src-tauri/src/alphahuman/agent/traits.rs b/src-tauri/src/alphahuman/agent/traits.rs
new file mode 100644
index 000000000..a30599b52
--- /dev/null
+++ b/src-tauri/src/alphahuman/agent/traits.rs
@@ -0,0 +1,579 @@
+//! Core agent traits ported from Alphahuman.
+//!
+//! Each trait defines an extension point. Noop implementations are provided
+//! as test doubles and reference implementations.
+
+use async_trait::async_trait;
+use serde::{Deserialize, Serialize};
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+// ═══════════════════════════════════════════════════════════════════
+// Provider trait — LLM model interface
+// ═══════════════════════════════════════════════════════════════════
+
+/// A single message in a conversation.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ChatMessage {
+ pub role: String,
+ pub content: String,
+}
+
+impl ChatMessage {
+ pub fn system(content: impl Into) -> Self {
+ Self {
+ role: "system".into(),
+ content: content.into(),
+ }
+ }
+
+ pub fn user(content: impl Into) -> Self {
+ Self {
+ role: "user".into(),
+ content: content.into(),
+ }
+ }
+
+ pub fn assistant(content: impl Into) -> Self {
+ Self {
+ role: "assistant".into(),
+ content: content.into(),
+ }
+ }
+
+ pub fn tool(content: impl Into) -> Self {
+ Self {
+ role: "tool".into(),
+ content: content.into(),
+ }
+ }
+}
+
+/// A tool call requested by the LLM.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ToolCall {
+ pub id: String,
+ pub name: String,
+ pub arguments: String,
+}
+
+/// An LLM response that may contain text, tool calls, or both.
+#[derive(Debug, Clone)]
+pub struct ChatResponse {
+ pub text: Option,
+ pub tool_calls: Vec,
+}
+
+impl ChatResponse {
+ pub fn has_tool_calls(&self) -> bool {
+ !self.tool_calls.is_empty()
+ }
+
+ pub fn text_or_empty(&self) -> &str {
+ self.text.as_deref().unwrap_or("")
+ }
+}
+
+/// Request payload for provider chat calls.
+#[derive(Debug, Clone, Copy)]
+pub struct ChatRequest<'a> {
+ pub messages: &'a [ChatMessage],
+ pub tools: Option<&'a [ToolSpec]>,
+}
+
+#[async_trait]
+pub trait Provider: Send + Sync {
+ /// One-shot chat with optional system prompt.
+ async fn chat_with_system(
+ &self,
+ system_prompt: Option<&str>,
+ message: &str,
+ model: &str,
+ temperature: f64,
+ ) -> anyhow::Result;
+
+ /// Multi-turn conversation.
+ async fn chat_with_history(
+ &self,
+ messages: &[ChatMessage],
+ model: &str,
+ temperature: f64,
+ ) -> anyhow::Result {
+ let system = messages
+ .iter()
+ .find(|m| m.role == "system")
+ .map(|m| m.content.as_str());
+ let last_user = messages
+ .iter()
+ .rfind(|m| m.role == "user")
+ .map(|m| m.content.as_str())
+ .unwrap_or("");
+ self.chat_with_system(system, last_user, model, temperature)
+ .await
+ }
+
+ /// Warm up the HTTP connection pool.
+ async fn warmup(&self) -> anyhow::Result<()> {
+ Ok(())
+ }
+}
+
+/// Noop provider that always returns an empty string.
+pub struct NoopProvider;
+
+#[async_trait]
+impl Provider for NoopProvider {
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ Ok(String::new())
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// Tool trait — executable capabilities
+// ═══════════════════════════════════════════════════════════════════
+
+/// Result of a tool execution.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ToolResult {
+ pub success: bool,
+ pub output: String,
+ pub error: Option,
+}
+
+/// Description of a tool for the LLM.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ToolSpec {
+ pub name: String,
+ pub description: String,
+ pub parameters: serde_json::Value,
+}
+
+/// Core tool trait — implement for any capability.
+#[async_trait]
+pub trait Tool: Send + Sync {
+ fn name(&self) -> &str;
+ fn description(&self) -> &str;
+ fn parameters_schema(&self) -> serde_json::Value;
+ async fn execute(&self, args: serde_json::Value) -> anyhow::Result;
+
+ fn spec(&self) -> ToolSpec {
+ ToolSpec {
+ name: self.name().to_string(),
+ description: self.description().to_string(),
+ parameters: self.parameters_schema(),
+ }
+ }
+}
+
+/// Noop tool that always succeeds with empty output.
+pub struct NoopTool;
+
+#[async_trait]
+impl Tool for NoopTool {
+ fn name(&self) -> &str {
+ "noop"
+ }
+ fn description(&self) -> &str {
+ "No-op tool (does nothing)"
+ }
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({"type": "object", "properties": {}})
+ }
+ async fn execute(&self, _args: serde_json::Value) -> anyhow::Result {
+ Ok(ToolResult {
+ success: true,
+ output: String::new(),
+ error: None,
+ })
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// Memory trait — persistence backends
+// ═══════════════════════════════════════════════════════════════════
+
+/// A single memory entry.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MemoryEntry {
+ pub id: String,
+ pub key: String,
+ pub content: String,
+ pub category: MemoryCategory,
+ pub timestamp: String,
+ pub session_id: Option,
+ pub score: Option,
+}
+
+/// Memory categories for organization.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum MemoryCategory {
+ Core,
+ Daily,
+ Conversation,
+ Custom(String),
+}
+
+impl std::fmt::Display for MemoryCategory {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Core => write!(f, "core"),
+ Self::Daily => write!(f, "daily"),
+ Self::Conversation => write!(f, "conversation"),
+ Self::Custom(name) => write!(f, "{name}"),
+ }
+ }
+}
+
+/// Core memory trait — implement for any persistence backend.
+#[async_trait]
+pub trait Memory: Send + Sync {
+ fn name(&self) -> &str;
+
+ async fn store(
+ &self,
+ key: &str,
+ content: &str,
+ category: MemoryCategory,
+ session_id: Option<&str>,
+ ) -> anyhow::Result<()>;
+
+ async fn recall(
+ &self,
+ query: &str,
+ limit: usize,
+ session_id: Option<&str>,
+ ) -> anyhow::Result>;
+
+ async fn get(&self, key: &str) -> anyhow::Result>;
+
+ async fn list(
+ &self,
+ category: Option<&MemoryCategory>,
+ session_id: Option<&str>,
+ ) -> anyhow::Result>;
+
+ async fn forget(&self, key: &str) -> anyhow::Result;
+
+ async fn count(&self) -> anyhow::Result;
+
+ async fn health_check(&self) -> bool;
+}
+
+/// Noop memory that stores nothing.
+pub struct NoopMemory;
+
+#[async_trait]
+impl Memory for NoopMemory {
+ fn name(&self) -> &str {
+ "noop"
+ }
+ async fn store(
+ &self,
+ _key: &str,
+ _content: &str,
+ _category: MemoryCategory,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result<()> {
+ Ok(())
+ }
+ async fn recall(
+ &self,
+ _query: &str,
+ _limit: usize,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn get(&self, _key: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+ async fn list(
+ &self,
+ _category: Option<&MemoryCategory>,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn forget(&self, _key: &str) -> anyhow::Result {
+ Ok(false)
+ }
+ async fn count(&self) -> anyhow::Result {
+ Ok(0)
+ }
+ async fn health_check(&self) -> bool {
+ true
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// Observer trait — observability backends
+// ═══════════════════════════════════════════════════════════════════
+
+/// Events the observer can record.
+#[derive(Debug, Clone)]
+pub enum ObserverEvent {
+ AgentStart {
+ provider: String,
+ model: String,
+ },
+ LlmRequest {
+ provider: String,
+ model: String,
+ messages_count: usize,
+ },
+ LlmResponse {
+ provider: String,
+ model: String,
+ duration: Duration,
+ success: bool,
+ error_message: Option,
+ },
+ AgentEnd {
+ provider: String,
+ model: String,
+ duration: Duration,
+ tokens_used: Option,
+ cost_usd: Option,
+ },
+ ToolCallStart {
+ tool: String,
+ },
+ ToolCall {
+ tool: String,
+ duration: Duration,
+ success: bool,
+ },
+ TurnComplete,
+ ChannelMessage {
+ channel: String,
+ direction: String,
+ },
+ HeartbeatTick,
+ Error {
+ component: String,
+ message: String,
+ },
+}
+
+/// Numeric metrics.
+#[derive(Debug, Clone)]
+pub enum ObserverMetric {
+ RequestLatency(Duration),
+ TokensUsed(u64),
+ ActiveSessions(u64),
+ QueueDepth(u64),
+}
+
+/// Core observability trait — implement for any backend.
+pub trait Observer: Send + Sync + 'static {
+ fn record_event(&self, event: &ObserverEvent);
+ fn record_metric(&self, metric: &ObserverMetric);
+ fn flush(&self) {}
+ fn name(&self) -> &str;
+ fn as_any(&self) -> &dyn std::any::Any;
+}
+
+/// Noop observer that discards all events.
+#[derive(Default)]
+pub struct NoopObserver;
+
+impl Observer for NoopObserver {
+ fn record_event(&self, _event: &ObserverEvent) {}
+ fn record_metric(&self, _metric: &ObserverMetric) {}
+ fn name(&self) -> &str {
+ "noop"
+ }
+ fn as_any(&self) -> &dyn std::any::Any {
+ self
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// RuntimeAdapter trait — platform abstractions
+// ═══════════════════════════════════════════════════════════════════
+
+/// Runtime adapter — abstracts platform differences.
+pub trait RuntimeAdapter: Send + Sync {
+ fn name(&self) -> &str;
+ fn has_shell_access(&self) -> bool;
+ fn has_filesystem_access(&self) -> bool;
+ fn storage_path(&self) -> PathBuf;
+ fn supports_long_running(&self) -> bool;
+ fn memory_budget(&self) -> u64 {
+ 0
+ }
+ fn build_shell_command(
+ &self,
+ command: &str,
+ workspace_dir: &Path,
+ ) -> anyhow::Result;
+}
+
+/// Noop runtime adapter that reports no capabilities.
+pub struct NoopRuntimeAdapter;
+
+impl RuntimeAdapter for NoopRuntimeAdapter {
+ fn name(&self) -> &str {
+ "noop"
+ }
+ fn has_shell_access(&self) -> bool {
+ false
+ }
+ fn has_filesystem_access(&self) -> bool {
+ false
+ }
+ fn storage_path(&self) -> PathBuf {
+ PathBuf::from("/dev/null")
+ }
+ fn supports_long_running(&self) -> bool {
+ false
+ }
+ fn build_shell_command(
+ &self,
+ _command: &str,
+ _workspace_dir: &Path,
+ ) -> anyhow::Result {
+ anyhow::bail!("NoopRuntimeAdapter does not support shell commands")
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// Tests
+// ═══════════════════════════════════════════════════════════════════
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn chat_message_constructors() {
+ let sys = ChatMessage::system("Be helpful");
+ assert_eq!(sys.role, "system");
+ assert_eq!(sys.content, "Be helpful");
+
+ let user = ChatMessage::user("Hello");
+ assert_eq!(user.role, "user");
+
+ let asst = ChatMessage::assistant("Hi there");
+ assert_eq!(asst.role, "assistant");
+
+ let tool = ChatMessage::tool("{}");
+ assert_eq!(tool.role, "tool");
+ }
+
+ #[test]
+ fn chat_response_helpers() {
+ let empty = ChatResponse {
+ text: None,
+ tool_calls: vec![],
+ };
+ assert!(!empty.has_tool_calls());
+ assert_eq!(empty.text_or_empty(), "");
+
+ let with_tools = ChatResponse {
+ text: Some("Let me check".into()),
+ tool_calls: vec![ToolCall {
+ id: "1".into(),
+ name: "shell".into(),
+ arguments: "{}".into(),
+ }],
+ };
+ assert!(with_tools.has_tool_calls());
+ assert_eq!(with_tools.text_or_empty(), "Let me check");
+ }
+
+ #[test]
+ fn tool_spec_from_noop_tool() {
+ let tool = NoopTool;
+ let spec = tool.spec();
+ assert_eq!(spec.name, "noop");
+ assert_eq!(spec.parameters["type"], "object");
+ }
+
+ #[tokio::test]
+ async fn noop_tool_execute() {
+ let tool = NoopTool;
+ let result = tool.execute(serde_json::json!({})).await.unwrap();
+ assert!(result.success);
+ assert!(result.output.is_empty());
+ }
+
+ #[tokio::test]
+ async fn noop_provider_returns_empty() {
+ let provider = NoopProvider;
+ let result = provider
+ .chat_with_system(None, "hello", "model", 0.7)
+ .await
+ .unwrap();
+ assert!(result.is_empty());
+ }
+
+ #[tokio::test]
+ async fn noop_memory_operations() {
+ let mem = NoopMemory;
+ assert_eq!(mem.name(), "noop");
+ assert!(mem.health_check().await);
+ assert_eq!(mem.count().await.unwrap(), 0);
+ assert!(mem.get("key").await.unwrap().is_none());
+ assert!(mem.recall("query", 10, None).await.unwrap().is_empty());
+ assert!(!mem.forget("key").await.unwrap());
+ }
+
+ #[test]
+ fn noop_observer_accepts_events() {
+ let observer = NoopObserver;
+ observer.record_event(&ObserverEvent::HeartbeatTick);
+ observer.record_metric(&ObserverMetric::TokensUsed(42));
+ observer.flush();
+ assert_eq!(observer.name(), "noop");
+ }
+
+ #[test]
+ fn noop_runtime_adapter_reports_no_capabilities() {
+ let runtime = NoopRuntimeAdapter;
+ assert_eq!(runtime.name(), "noop");
+ assert!(!runtime.has_shell_access());
+ assert!(!runtime.has_filesystem_access());
+ assert!(!runtime.supports_long_running());
+ assert_eq!(runtime.memory_budget(), 0);
+ }
+
+ #[test]
+ fn memory_category_display() {
+ assert_eq!(MemoryCategory::Core.to_string(), "core");
+ assert_eq!(MemoryCategory::Daily.to_string(), "daily");
+ assert_eq!(MemoryCategory::Conversation.to_string(), "conversation");
+ assert_eq!(
+ MemoryCategory::Custom("notes".into()).to_string(),
+ "notes"
+ );
+ }
+
+ #[test]
+ fn memory_category_serde() {
+ let core = serde_json::to_string(&MemoryCategory::Core).unwrap();
+ assert_eq!(core, "\"core\"");
+ let parsed: MemoryCategory = serde_json::from_str("\"daily\"").unwrap();
+ assert_eq!(parsed, MemoryCategory::Daily);
+ }
+
+ #[test]
+ fn tool_result_serialization_roundtrip() {
+ let result = ToolResult {
+ success: false,
+ output: String::new(),
+ error: Some("boom".into()),
+ };
+ let json = serde_json::to_string(&result).unwrap();
+ let parsed: ToolResult = serde_json::from_str(&json).unwrap();
+ assert!(!parsed.success);
+ assert_eq!(parsed.error.as_deref(), Some("boom"));
+ }
+}
diff --git a/src-tauri/src/alphahuman/approval/mod.rs b/src-tauri/src/alphahuman/approval/mod.rs
new file mode 100644
index 000000000..874e6666a
--- /dev/null
+++ b/src-tauri/src/alphahuman/approval/mod.rs
@@ -0,0 +1,426 @@
+//! Interactive approval workflow for supervised mode.
+//!
+//! Provides a pre-execution hook that prompts the user before tool calls,
+//! with session-scoped "Always" allowlists and audit logging.
+
+use crate::alphahuman::config::AutonomyConfig;
+use crate::alphahuman::security::AutonomyLevel;
+use chrono::Utc;
+use parking_lot::Mutex;
+use serde::{Deserialize, Serialize};
+use std::collections::HashSet;
+use std::io::{self, BufRead, Write};
+
+// ── Types ────────────────────────────────────────────────────────
+
+/// A request to approve a tool call before execution.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ApprovalRequest {
+ pub tool_name: String,
+ pub arguments: serde_json::Value,
+}
+
+/// The user's response to an approval request.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ApprovalResponse {
+ /// Execute this one call.
+ Yes,
+ /// Deny this call.
+ No,
+ /// Execute and add tool to session-scoped allowlist.
+ Always,
+}
+
+/// A single audit log entry for an approval decision.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ApprovalLogEntry {
+ pub timestamp: String,
+ pub tool_name: String,
+ pub arguments_summary: String,
+ pub decision: ApprovalResponse,
+ pub channel: String,
+}
+
+// ── ApprovalManager ──────────────────────────────────────────────
+
+/// Manages the interactive approval workflow.
+///
+/// - Checks config-level `auto_approve` / `always_ask` lists
+/// - Maintains a session-scoped "always" allowlist
+/// - Records an audit trail of all decisions
+pub struct ApprovalManager {
+ /// Tools that never need approval (from config).
+ auto_approve: HashSet,
+ /// Tools that always need approval, ignoring session allowlist.
+ always_ask: HashSet,
+ /// Autonomy level from config.
+ autonomy_level: AutonomyLevel,
+ /// Session-scoped allowlist built from "Always" responses.
+ session_allowlist: Mutex>,
+ /// Audit trail of approval decisions.
+ audit_log: Mutex>,
+}
+
+impl ApprovalManager {
+ /// Create from autonomy config.
+ pub fn from_config(config: &AutonomyConfig) -> Self {
+ Self {
+ auto_approve: config.auto_approve.iter().cloned().collect(),
+ always_ask: config.always_ask.iter().cloned().collect(),
+ autonomy_level: config.level,
+ session_allowlist: Mutex::new(HashSet::new()),
+ audit_log: Mutex::new(Vec::new()),
+ }
+ }
+
+ /// Check whether a tool call requires interactive approval.
+ ///
+ /// Returns `true` if the call needs a prompt, `false` if it can proceed.
+ pub fn needs_approval(&self, tool_name: &str) -> bool {
+ // Full autonomy never prompts.
+ if self.autonomy_level == AutonomyLevel::Full {
+ return false;
+ }
+
+ // ReadOnly blocks everything — handled elsewhere; no prompt needed.
+ if self.autonomy_level == AutonomyLevel::ReadOnly {
+ return false;
+ }
+
+ // always_ask overrides everything.
+ if self.always_ask.contains(tool_name) {
+ return true;
+ }
+
+ // auto_approve skips the prompt.
+ if self.auto_approve.contains(tool_name) {
+ return false;
+ }
+
+ // Session allowlist (from prior "Always" responses).
+ let allowlist = self.session_allowlist.lock();
+ if allowlist.contains(tool_name) {
+ return false;
+ }
+
+ // Default: supervised mode requires approval.
+ true
+ }
+
+ /// Record an approval decision and update session state.
+ pub fn record_decision(
+ &self,
+ tool_name: &str,
+ args: &serde_json::Value,
+ decision: ApprovalResponse,
+ channel: &str,
+ ) {
+ // If "Always", add to session allowlist.
+ if decision == ApprovalResponse::Always {
+ let mut allowlist = self.session_allowlist.lock();
+ allowlist.insert(tool_name.to_string());
+ }
+
+ // Append to audit log.
+ let summary = summarize_args(args);
+ let entry = ApprovalLogEntry {
+ timestamp: Utc::now().to_rfc3339(),
+ tool_name: tool_name.to_string(),
+ arguments_summary: summary,
+ decision,
+ channel: channel.to_string(),
+ };
+ let mut log = self.audit_log.lock();
+ log.push(entry);
+ }
+
+ /// Get a snapshot of the audit log.
+ pub fn audit_log(&self) -> Vec {
+ self.audit_log.lock().clone()
+ }
+
+ /// Get the current session allowlist.
+ pub fn session_allowlist(&self) -> HashSet {
+ self.session_allowlist.lock().clone()
+ }
+
+ /// Prompt the user on the local console and return their decision.
+ ///
+ /// In the web UI, approvals are handled elsewhere; this is a fallback
+ /// for non-UI environments.
+ pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse {
+ prompt_cli_interactive(request)
+ }
+}
+
+// ── Console prompt ───────────────────────────────────────────────
+
+/// Display the approval prompt and read user input from stdin.
+fn prompt_cli_interactive(request: &ApprovalRequest) -> ApprovalResponse {
+ let summary = summarize_args(&request.arguments);
+ eprintln!();
+ eprintln!("🔧 Agent wants to execute: {}", request.tool_name);
+ eprintln!(" {summary}");
+ eprint!(" [Y]es / [N]o / [A]lways for {}: ", request.tool_name);
+ let _ = io::stderr().flush();
+
+ let stdin = io::stdin();
+ let mut line = String::new();
+ if stdin.lock().read_line(&mut line).is_err() {
+ return ApprovalResponse::No;
+ }
+
+ match line.trim().to_ascii_lowercase().as_str() {
+ "y" | "yes" => ApprovalResponse::Yes,
+ "a" | "always" => ApprovalResponse::Always,
+ _ => ApprovalResponse::No,
+ }
+}
+
+/// Produce a short human-readable summary of tool arguments.
+fn summarize_args(args: &serde_json::Value) -> String {
+ match args {
+ serde_json::Value::Object(map) => {
+ let parts: Vec = map
+ .iter()
+ .map(|(k, v)| {
+ let val = match v {
+ serde_json::Value::String(s) => truncate_for_summary(s, 80),
+ other => {
+ let s = other.to_string();
+ truncate_for_summary(&s, 80)
+ }
+ };
+ format!("{k}: {val}")
+ })
+ .collect();
+ parts.join(", ")
+ }
+ other => {
+ let s = other.to_string();
+ truncate_for_summary(&s, 120)
+ }
+ }
+}
+
+fn truncate_for_summary(input: &str, max_chars: usize) -> String {
+ let mut chars = input.chars();
+ let truncated: String = chars.by_ref().take(max_chars).collect();
+ if chars.next().is_some() {
+ format!("{truncated}…")
+ } else {
+ input.to_string()
+ }
+}
+
+// ── Tests ────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::alphahuman::config::AutonomyConfig;
+
+ fn supervised_config() -> AutonomyConfig {
+ AutonomyConfig {
+ level: AutonomyLevel::Supervised,
+ auto_approve: vec!["file_read".into(), "memory_recall".into()],
+ always_ask: vec!["shell".into()],
+ ..AutonomyConfig::default()
+ }
+ }
+
+ fn full_config() -> AutonomyConfig {
+ AutonomyConfig {
+ level: AutonomyLevel::Full,
+ ..AutonomyConfig::default()
+ }
+ }
+
+ // ── needs_approval ───────────────────────────────────────
+
+ #[test]
+ fn auto_approve_tools_skip_prompt() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ assert!(!mgr.needs_approval("file_read"));
+ assert!(!mgr.needs_approval("memory_recall"));
+ }
+
+ #[test]
+ fn always_ask_tools_always_prompt() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ assert!(mgr.needs_approval("shell"));
+ }
+
+ #[test]
+ fn unknown_tool_needs_approval_in_supervised() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ assert!(mgr.needs_approval("file_write"));
+ assert!(mgr.needs_approval("http_request"));
+ }
+
+ #[test]
+ fn full_autonomy_never_prompts() {
+ let mgr = ApprovalManager::from_config(&full_config());
+ assert!(!mgr.needs_approval("shell"));
+ assert!(!mgr.needs_approval("file_write"));
+ assert!(!mgr.needs_approval("anything"));
+ }
+
+ #[test]
+ fn readonly_never_prompts() {
+ let config = AutonomyConfig {
+ level: AutonomyLevel::ReadOnly,
+ ..AutonomyConfig::default()
+ };
+ let mgr = ApprovalManager::from_config(&config);
+ assert!(!mgr.needs_approval("shell"));
+ }
+
+ // ── session allowlist ────────────────────────────────────
+
+ #[test]
+ fn always_response_adds_to_session_allowlist() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ assert!(mgr.needs_approval("file_write"));
+
+ mgr.record_decision(
+ "file_write",
+ &serde_json::json!({"path": "test.txt"}),
+ ApprovalResponse::Always,
+ "cli",
+ );
+
+ // Now file_write should be in session allowlist.
+ assert!(!mgr.needs_approval("file_write"));
+ }
+
+ #[test]
+ fn always_ask_overrides_session_allowlist() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+
+ // Even after "Always" for shell, it should still prompt.
+ mgr.record_decision(
+ "shell",
+ &serde_json::json!({"command": "ls"}),
+ ApprovalResponse::Always,
+ "cli",
+ );
+
+ // shell is in always_ask, so it still needs approval.
+ assert!(mgr.needs_approval("shell"));
+ }
+
+ #[test]
+ fn yes_response_does_not_add_to_allowlist() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ mgr.record_decision(
+ "file_write",
+ &serde_json::json!({}),
+ ApprovalResponse::Yes,
+ "cli",
+ );
+ assert!(mgr.needs_approval("file_write"));
+ }
+
+ // ── audit log ────────────────────────────────────────────
+
+ #[test]
+ fn audit_log_records_decisions() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+
+ mgr.record_decision(
+ "shell",
+ &serde_json::json!({"command": "rm -rf ./build/"}),
+ ApprovalResponse::No,
+ "cli",
+ );
+ mgr.record_decision(
+ "file_write",
+ &serde_json::json!({"path": "out.txt", "content": "hello"}),
+ ApprovalResponse::Yes,
+ "cli",
+ );
+
+ let log = mgr.audit_log();
+ assert_eq!(log.len(), 2);
+ assert_eq!(log[0].tool_name, "shell");
+ assert_eq!(log[0].decision, ApprovalResponse::No);
+ assert_eq!(log[1].tool_name, "file_write");
+ assert_eq!(log[1].decision, ApprovalResponse::Yes);
+ }
+
+ #[test]
+ fn audit_log_contains_timestamp_and_channel() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ mgr.record_decision(
+ "shell",
+ &serde_json::json!({"command": "ls"}),
+ ApprovalResponse::Yes,
+ "telegram",
+ );
+
+ let log = mgr.audit_log();
+ assert_eq!(log.len(), 1);
+ assert!(!log[0].timestamp.is_empty());
+ assert_eq!(log[0].channel, "telegram");
+ }
+
+ // ── summarize_args ───────────────────────────────────────
+
+ #[test]
+ fn summarize_args_object() {
+ let args = serde_json::json!({"command": "ls -la", "cwd": "/tmp"});
+ let summary = summarize_args(&args);
+ assert!(summary.contains("command: ls -la"));
+ assert!(summary.contains("cwd: /tmp"));
+ }
+
+ #[test]
+ fn summarize_args_truncates_long_values() {
+ let long_val = "x".repeat(200);
+ let args = serde_json::json!({"content": long_val});
+ let summary = summarize_args(&args);
+ assert!(summary.contains('…'));
+ assert!(summary.len() < 200);
+ }
+
+ #[test]
+ fn summarize_args_unicode_safe_truncation() {
+ let long_val = "🦀".repeat(120);
+ let args = serde_json::json!({"content": long_val});
+ let summary = summarize_args(&args);
+ assert!(summary.contains("content:"));
+ assert!(summary.contains('…'));
+ }
+
+ #[test]
+ fn summarize_args_non_object() {
+ let args = serde_json::json!("just a string");
+ let summary = summarize_args(&args);
+ assert!(summary.contains("just a string"));
+ }
+
+ // ── ApprovalResponse serde ───────────────────────────────
+
+ #[test]
+ fn approval_response_serde_roundtrip() {
+ let json = serde_json::to_string(&ApprovalResponse::Always).unwrap();
+ assert_eq!(json, "\"always\"");
+ let parsed: ApprovalResponse = serde_json::from_str("\"no\"").unwrap();
+ assert_eq!(parsed, ApprovalResponse::No);
+ }
+
+ // ── ApprovalRequest ──────────────────────────────────────
+
+ #[test]
+ fn approval_request_serde() {
+ let req = ApprovalRequest {
+ tool_name: "shell".into(),
+ arguments: serde_json::json!({"command": "echo hi"}),
+ };
+ let json = serde_json::to_string(&req).unwrap();
+ let parsed: ApprovalRequest = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed.tool_name, "shell");
+ }
+}
diff --git a/src-tauri/src/alphahuman/channels/cli.rs b/src-tauri/src/alphahuman/channels/cli.rs
new file mode 100644
index 000000000..e42f1891f
--- /dev/null
+++ b/src-tauri/src/alphahuman/channels/cli.rs
@@ -0,0 +1,138 @@
+use super::traits::{Channel, ChannelMessage, SendMessage};
+use async_trait::async_trait;
+use tokio::io::{self, AsyncBufReadExt, BufReader};
+use uuid::Uuid;
+
+/// Console channel — stdin/stdout, not used in the web UI, zero deps
+pub struct CliChannel;
+
+impl CliChannel {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+#[async_trait]
+impl Channel for CliChannel {
+ fn name(&self) -> &str {
+ "cli"
+ }
+
+ async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
+ println!("{}", message.content);
+ Ok(())
+ }
+
+ async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> {
+ let stdin = io::stdin();
+ let reader = BufReader::new(stdin);
+ let mut lines = reader.lines();
+
+ while let Ok(Some(line)) = lines.next_line().await {
+ let line = line.trim().to_string();
+ if line.is_empty() {
+ continue;
+ }
+ if line == "/quit" || line == "/exit" {
+ break;
+ }
+
+ let msg = ChannelMessage {
+ id: Uuid::new_v4().to_string(),
+ sender: "user".to_string(),
+ reply_target: "user".to_string(),
+ content: line,
+ channel: "cli".to_string(),
+ timestamp: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs(),
+ thread_ts: None,
+ };
+
+ if tx.send(msg).await.is_err() {
+ break;
+ }
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn cli_channel_name() {
+ assert_eq!(CliChannel::new().name(), "cli");
+ }
+
+ #[tokio::test]
+ async fn cli_channel_send_does_not_panic() {
+ let ch = CliChannel::new();
+ let result = ch
+ .send(&SendMessage {
+ content: "hello".into(),
+ recipient: "user".into(),
+ subject: None,
+ thread_ts: None,
+ })
+ .await;
+ assert!(result.is_ok());
+ }
+
+ #[tokio::test]
+ async fn cli_channel_send_empty_message() {
+ let ch = CliChannel::new();
+ let result = ch
+ .send(&SendMessage {
+ content: String::new(),
+ recipient: String::new(),
+ subject: None,
+ thread_ts: None,
+ })
+ .await;
+ assert!(result.is_ok());
+ }
+
+ #[tokio::test]
+ async fn cli_channel_health_check() {
+ let ch = CliChannel::new();
+ assert!(ch.health_check().await);
+ }
+
+ #[test]
+ fn channel_message_struct() {
+ let msg = ChannelMessage {
+ id: "test-id".into(),
+ sender: "user".into(),
+ reply_target: "user".into(),
+ content: "hello".into(),
+ channel: "cli".into(),
+ timestamp: 1_234_567_890,
+ thread_ts: None,
+ };
+ assert_eq!(msg.id, "test-id");
+ assert_eq!(msg.sender, "user");
+ assert_eq!(msg.reply_target, "user");
+ assert_eq!(msg.content, "hello");
+ assert_eq!(msg.channel, "cli");
+ assert_eq!(msg.timestamp, 1_234_567_890);
+ }
+
+ #[test]
+ fn channel_message_clone() {
+ let msg = ChannelMessage {
+ id: "id".into(),
+ sender: "s".into(),
+ reply_target: "s".into(),
+ content: "c".into(),
+ channel: "ch".into(),
+ timestamp: 0,
+ thread_ts: None,
+ };
+ let cloned = msg.clone();
+ assert_eq!(cloned.id, msg.id);
+ assert_eq!(cloned.content, msg.content);
+ }
+}
diff --git a/src-tauri/src/alphahuman/channels/commands.rs b/src-tauri/src/alphahuman/channels/commands.rs
new file mode 100644
index 000000000..1f3de2fdd
--- /dev/null
+++ b/src-tauri/src/alphahuman/channels/commands.rs
@@ -0,0 +1,272 @@
+//! Channel command handling and health checks.
+
+use super::dingtalk::DingTalkChannel;
+use super::discord::DiscordChannel;
+use super::email_channel::EmailChannel;
+use super::imessage::IMessageChannel;
+use super::irc;
+use super::irc::IrcChannel;
+use super::lark::LarkChannel;
+use super::linq::LinqChannel;
+#[cfg(feature = "channel-matrix")]
+use super::matrix::MatrixChannel;
+use super::qq::QQChannel;
+use super::signal::SignalChannel;
+use super::slack::SlackChannel;
+use super::telegram::TelegramChannel;
+use super::whatsapp::WhatsAppChannel;
+#[cfg(feature = "whatsapp-web")]
+use super::whatsapp_web::WhatsAppWebChannel;
+use super::Channel;
+use crate::alphahuman::config::Config;
+use anyhow::Result;
+use std::sync::Arc;
+use std::time::Duration;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ChannelHealthState {
+ Healthy,
+ Unhealthy,
+ Timeout,
+}
+
+pub(crate) fn classify_health_result(
+ result: &std::result::Result,
+) -> ChannelHealthState {
+ match result {
+ Ok(true) => ChannelHealthState::Healthy,
+ Ok(false) => ChannelHealthState::Unhealthy,
+ Err(_) => ChannelHealthState::Timeout,
+ }
+}
+
+/// Run health checks for configured channels.
+pub async fn doctor_channels(config: Config) -> Result<()> {
+ let mut channels: Vec<(&'static str, Arc)> = Vec::new();
+
+ if let Some(ref tg) = config.channels_config.telegram {
+ channels.push((
+ "Telegram",
+ Arc::new(
+ TelegramChannel::new(
+ tg.bot_token.clone(),
+ tg.allowed_users.clone(),
+ tg.mention_only,
+ )
+ .with_streaming(tg.stream_mode, tg.draft_update_interval_ms),
+ ),
+ ));
+ }
+
+ if let Some(ref dc) = config.channels_config.discord {
+ channels.push((
+ "Discord",
+ Arc::new(DiscordChannel::new(
+ dc.bot_token.clone(),
+ dc.guild_id.clone(),
+ dc.allowed_users.clone(),
+ dc.listen_to_bots,
+ dc.mention_only,
+ )),
+ ));
+ }
+
+ if let Some(ref sl) = config.channels_config.slack {
+ channels.push((
+ "Slack",
+ Arc::new(SlackChannel::new(
+ sl.bot_token.clone(),
+ sl.channel_id.clone(),
+ sl.allowed_users.clone(),
+ )),
+ ));
+ }
+
+ if let Some(ref im) = config.channels_config.imessage {
+ channels.push((
+ "iMessage",
+ Arc::new(IMessageChannel::new(im.allowed_contacts.clone())),
+ ));
+ }
+
+ #[cfg(feature = "channel-matrix")]
+ if let Some(ref mx) = config.channels_config.matrix {
+ channels.push((
+ "Matrix",
+ Arc::new(MatrixChannel::new_with_session_hint(
+ mx.homeserver.clone(),
+ mx.access_token.clone(),
+ mx.room_id.clone(),
+ mx.allowed_users.clone(),
+ mx.user_id.clone(),
+ mx.device_id.clone(),
+ )),
+ ));
+ }
+
+ #[cfg(not(feature = "channel-matrix"))]
+ if config.channels_config.matrix.is_some() {
+ tracing::warn!(
+ "Matrix channel is configured but this build was compiled without `channel-matrix`; skipping Matrix health check."
+ );
+ }
+
+ if let Some(ref sig) = config.channels_config.signal {
+ channels.push((
+ "Signal",
+ Arc::new(SignalChannel::new(
+ sig.http_url.clone(),
+ sig.account.clone(),
+ sig.group_id.clone(),
+ sig.allowed_from.clone(),
+ sig.ignore_attachments,
+ sig.ignore_stories,
+ )),
+ ));
+ }
+
+ if let Some(ref wa) = config.channels_config.whatsapp {
+ // Runtime negotiation: detect backend type from config
+ match wa.backend_type() {
+ "cloud" => {
+ // Cloud API mode: requires phone_number_id, access_token, verify_token
+ if wa.is_cloud_config() {
+ channels.push((
+ "WhatsApp",
+ Arc::new(WhatsAppChannel::new(
+ wa.access_token.clone().unwrap_or_default(),
+ wa.phone_number_id.clone().unwrap_or_default(),
+ wa.verify_token.clone().unwrap_or_default(),
+ wa.allowed_numbers.clone(),
+ )),
+ ));
+ } else {
+ tracing::warn!("WhatsApp Cloud API configured but missing required fields (phone_number_id, access_token, verify_token)");
+ }
+ }
+ "web" => {
+ // Web mode: requires session_path
+ #[cfg(feature = "whatsapp-web")]
+ if wa.is_web_config() {
+ channels.push((
+ "WhatsApp",
+ Arc::new(WhatsAppWebChannel::new(
+ wa.session_path.clone().unwrap_or_default(),
+ wa.pair_phone.clone(),
+ wa.pair_code.clone(),
+ wa.allowed_numbers.clone(),
+ )),
+ ));
+ } else {
+ tracing::warn!("WhatsApp Web configured but session_path not set");
+ }
+ #[cfg(not(feature = "whatsapp-web"))]
+ {
+ tracing::warn!("WhatsApp Web backend requires 'whatsapp-web' feature. Enable with: cargo build --features whatsapp-web");
+ }
+ }
+ _ => {
+ tracing::warn!("WhatsApp config invalid: neither phone_number_id (Cloud API) nor session_path (Web) is set");
+ }
+ }
+ }
+
+ if let Some(ref lq) = config.channels_config.linq {
+ channels.push((
+ "Linq",
+ Arc::new(LinqChannel::new(
+ lq.api_token.clone(),
+ lq.from_phone.clone(),
+ lq.allowed_senders.clone(),
+ )),
+ ));
+ }
+
+ if let Some(ref email_cfg) = config.channels_config.email {
+ channels.push(("Email", Arc::new(EmailChannel::new(email_cfg.clone()))));
+ }
+
+ if let Some(ref irc) = config.channels_config.irc {
+ channels.push((
+ "IRC",
+ Arc::new(IrcChannel::new(irc::IrcChannelConfig {
+ server: irc.server.clone(),
+ port: irc.port,
+ nickname: irc.nickname.clone(),
+ username: irc.username.clone(),
+ channels: irc.channels.clone(),
+ allowed_users: irc.allowed_users.clone(),
+ server_password: irc.server_password.clone(),
+ nickserv_password: irc.nickserv_password.clone(),
+ sasl_password: irc.sasl_password.clone(),
+ verify_tls: irc.verify_tls.unwrap_or(true),
+ })),
+ ));
+ }
+
+ if let Some(ref lk) = config.channels_config.lark {
+ channels.push(("Lark", Arc::new(LarkChannel::from_config(lk))));
+ }
+
+ if let Some(ref dt) = config.channels_config.dingtalk {
+ channels.push((
+ "DingTalk",
+ Arc::new(DingTalkChannel::new(
+ dt.client_id.clone(),
+ dt.client_secret.clone(),
+ dt.allowed_users.clone(),
+ )),
+ ));
+ }
+
+ if let Some(ref qq) = config.channels_config.qq {
+ channels.push((
+ "QQ",
+ Arc::new(QQChannel::new(
+ qq.app_id.clone(),
+ qq.app_secret.clone(),
+ qq.allowed_users.clone(),
+ )),
+ ));
+ }
+
+ if channels.is_empty() {
+ println!("No real-time channels configured. Configure channels in the web UI.");
+ return Ok(());
+ }
+
+ println!("🩺 Alphahuman Channel Doctor");
+ println!();
+
+ let mut healthy = 0_u32;
+ let mut unhealthy = 0_u32;
+ let mut timeout = 0_u32;
+
+ for (name, channel) in channels {
+ let result = tokio::time::timeout(Duration::from_secs(10), channel.health_check()).await;
+ let state = classify_health_result(&result);
+
+ match state {
+ ChannelHealthState::Healthy => {
+ healthy += 1;
+ println!(" ✅ {name:<9} healthy");
+ }
+ ChannelHealthState::Unhealthy => {
+ unhealthy += 1;
+ println!(" ❌ {name:<9} unhealthy (auth/config/network)");
+ }
+ ChannelHealthState::Timeout => {
+ timeout += 1;
+ println!(" ⏱️ {name:<9} timed out (>10s)");
+ }
+ }
+ }
+
+ if config.channels_config.webhook.is_some() {
+ println!(" ℹ️ Webhook check gateway health in the web UI");
+ }
+
+ println!();
+ println!("Summary: {healthy} healthy, {unhealthy} unhealthy, {timeout} timed out");
+ Ok(())
+}
diff --git a/src-tauri/src/alphahuman/channels/context.rs b/src-tauri/src/alphahuman/channels/context.rs
new file mode 100644
index 000000000..ab4146d7c
--- /dev/null
+++ b/src-tauri/src/alphahuman/channels/context.rs
@@ -0,0 +1,191 @@
+//! Shared channel runtime state and memory helpers.
+
+use crate::alphahuman::memory::Memory;
+use crate::alphahuman::observability::Observer;
+use crate::alphahuman::providers::{ChatMessage, Provider};
+use crate::alphahuman::tools::Tool;
+use crate::alphahuman::util::truncate_with_ellipsis;
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+
+/// Per-sender conversation history for channel messages.
+pub(crate) type ConversationHistoryMap = Arc>>>;
+/// Maximum history messages to keep per sender.
+pub(crate) const MAX_CHANNEL_HISTORY: usize = 50;
+
+pub(crate) const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
+pub(crate) const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
+pub(crate) const MIN_CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 30;
+/// Default timeout for processing a single channel message (LLM + tools).
+/// Used as fallback when not configured in channels_config.message_timeout_secs.
+pub(crate) const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300;
+pub(crate) const CHANNEL_PARALLELISM_PER_CHANNEL: usize = 4;
+pub(crate) const CHANNEL_MIN_IN_FLIGHT_MESSAGES: usize = 8;
+pub(crate) const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64;
+pub(crate) const CHANNEL_TYPING_REFRESH_INTERVAL_SECS: u64 = 4;
+pub(crate) const MEMORY_CONTEXT_MAX_ENTRIES: usize = 4;
+pub(crate) const MEMORY_CONTEXT_ENTRY_MAX_CHARS: usize = 800;
+pub(crate) const MEMORY_CONTEXT_MAX_CHARS: usize = 4_000;
+pub(crate) const CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES: usize = 12;
+pub(crate) const CHANNEL_HISTORY_COMPACT_CONTENT_CHARS: usize = 600;
+
+pub(crate) type ProviderCacheMap = Arc>>>;
+pub(crate) type RouteSelectionMap = Arc>>;
+
+pub(crate) fn effective_channel_message_timeout_secs(configured: u64) -> u64 {
+ configured.max(MIN_CHANNEL_MESSAGE_TIMEOUT_SECS)
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct ChannelRouteSelection {
+ pub(crate) provider: String,
+ pub(crate) model: String,
+}
+
+#[derive(Clone)]
+pub(crate) struct ChannelRuntimeContext {
+ pub(crate) channels_by_name: Arc>>,
+ pub(crate) provider: Arc,
+ pub(crate) default_provider: Arc,
+ pub(crate) memory: Arc,
+ pub(crate) tools_registry: Arc>>,
+ pub(crate) observer: Arc,
+ pub(crate) system_prompt: Arc,
+ pub(crate) model: Arc,
+ pub(crate) temperature: f64,
+ pub(crate) auto_save_memory: bool,
+ pub(crate) max_tool_iterations: usize,
+ pub(crate) min_relevance_score: f64,
+ pub(crate) conversation_histories: ConversationHistoryMap,
+ pub(crate) provider_cache: ProviderCacheMap,
+ pub(crate) route_overrides: RouteSelectionMap,
+ pub(crate) api_key: Option,
+ pub(crate) api_url: Option,
+ pub(crate) reliability: Arc,
+ pub(crate) provider_runtime_options: crate::alphahuman::providers::ProviderRuntimeOptions,
+ pub(crate) workspace_dir: Arc,
+ pub(crate) message_timeout_secs: u64,
+ pub(crate) multimodal: crate::alphahuman::config::MultimodalConfig,
+}
+
+pub(crate) fn conversation_memory_key(msg: &super::traits::ChannelMessage) -> String {
+ format!("{}_{}_{}", msg.channel, msg.sender, msg.id)
+}
+
+pub(crate) fn conversation_history_key(msg: &super::traits::ChannelMessage) -> String {
+ format!("{}_{}", msg.channel, msg.sender)
+}
+
+pub(crate) fn clear_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) {
+ ctx.conversation_histories
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .remove(sender_key);
+}
+
+pub(crate) fn compact_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool {
+ let mut histories = ctx
+ .conversation_histories
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+
+ let Some(turns) = histories.get_mut(sender_key) else {
+ return false;
+ };
+
+ if turns.is_empty() {
+ return false;
+ }
+
+ let keep_from = turns
+ .len()
+ .saturating_sub(CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
+ let mut compacted = turns[keep_from..].to_vec();
+
+ for turn in &mut compacted {
+ if turn.content.chars().count() > CHANNEL_HISTORY_COMPACT_CONTENT_CHARS {
+ turn.content =
+ truncate_with_ellipsis(&turn.content, CHANNEL_HISTORY_COMPACT_CONTENT_CHARS);
+ }
+ }
+
+ *turns = compacted;
+ true
+}
+
+pub(crate) fn should_skip_memory_context_entry(key: &str, content: &str) -> bool {
+ if key.trim().to_ascii_lowercase().ends_with("_history") {
+ return true;
+ }
+
+ content.chars().count() > MEMORY_CONTEXT_MAX_CHARS
+}
+
+pub(crate) fn is_context_window_overflow_error(err: &anyhow::Error) -> bool {
+ let lower = err.to_string().to_lowercase();
+ [
+ "exceeds the context window",
+ "context window of this model",
+ "maximum context length",
+ "context length exceeded",
+ "too many tokens",
+ "token limit exceeded",
+ "prompt is too long",
+ "input is too long",
+ ]
+ .iter()
+ .any(|hint| lower.contains(hint))
+}
+
+pub(crate) async fn build_memory_context(
+ mem: &dyn Memory,
+ user_msg: &str,
+ min_relevance_score: f64,
+) -> String {
+ let mut context = String::new();
+
+ if let Ok(entries) = mem.recall(user_msg, 5, None).await {
+ let mut included = 0usize;
+ let mut used_chars = 0usize;
+
+ for entry in entries.iter().filter(|e| match e.score {
+ Some(score) => score >= min_relevance_score,
+ None => true, // keep entries without a score (e.g. non-vector backends)
+ }) {
+ if included >= MEMORY_CONTEXT_MAX_ENTRIES {
+ break;
+ }
+
+ if should_skip_memory_context_entry(&entry.key, &entry.content) {
+ continue;
+ }
+
+ let content = if entry.content.chars().count() > MEMORY_CONTEXT_ENTRY_MAX_CHARS {
+ truncate_with_ellipsis(&entry.content, MEMORY_CONTEXT_ENTRY_MAX_CHARS)
+ } else {
+ entry.content.clone()
+ };
+
+ let line = format!("- {}: {}\n", entry.key, content);
+ let line_chars = line.chars().count();
+ if used_chars + line_chars > MEMORY_CONTEXT_MAX_CHARS {
+ break;
+ }
+
+ if included == 0 {
+ context.push_str("[Memory context]\n");
+ }
+
+ context.push_str(&line);
+ used_chars += line_chars;
+ included += 1;
+ }
+
+ if included > 0 {
+ context.push('\n');
+ }
+ }
+
+ context
+}
diff --git a/src-tauri/src/alphahuman/channels/dingtalk.rs b/src-tauri/src/alphahuman/channels/dingtalk.rs
new file mode 100644
index 000000000..6c9715b4f
--- /dev/null
+++ b/src-tauri/src/alphahuman/channels/dingtalk.rs
@@ -0,0 +1,382 @@
+use super::traits::{Channel, ChannelMessage, SendMessage};
+use async_trait::async_trait;
+use futures_util::{SinkExt, StreamExt};
+use std::collections::HashMap;
+use std::sync::Arc;
+use tokio::sync::RwLock;
+use tokio_tungstenite::tungstenite::Message;
+use uuid::Uuid;
+
+const DINGTALK_BOT_CALLBACK_TOPIC: &str = "/v1.0/im/bot/messages/get";
+
+/// DingTalk channel — connects via Stream Mode WebSocket for real-time messages.
+/// Replies are sent through per-message session webhook URLs.
+pub struct DingTalkChannel {
+ client_id: String,
+ client_secret: String,
+ allowed_users: Vec,
+ /// Per-chat session webhooks for sending replies (chatID -> webhook URL).
+ /// DingTalk provides a unique webhook URL with each incoming message.
+ session_webhooks: Arc>>,
+}
+
+/// Response from DingTalk gateway connection registration.
+#[derive(serde::Deserialize)]
+struct GatewayResponse {
+ endpoint: String,
+ ticket: String,
+}
+
+impl DingTalkChannel {
+ pub fn new(client_id: String, client_secret: String, allowed_users: Vec) -> Self {
+ Self {
+ client_id,
+ client_secret,
+ allowed_users,
+ session_webhooks: Arc::new(RwLock::new(HashMap::new())),
+ }
+ }
+
+ fn http_client(&self) -> reqwest::Client {
+ crate::alphahuman::config::build_runtime_proxy_client("channel.dingtalk")
+ }
+
+ fn is_user_allowed(&self, user_id: &str) -> bool {
+ self.allowed_users.iter().any(|u| u == "*" || u == user_id)
+ }
+
+ fn parse_stream_data(frame: &serde_json::Value) -> Option {
+ match frame.get("data") {
+ Some(serde_json::Value::String(raw)) => serde_json::from_str(raw).ok(),
+ Some(serde_json::Value::Object(_)) => frame.get("data").cloned(),
+ _ => None,
+ }
+ }
+
+ fn resolve_chat_id(data: &serde_json::Value, sender_id: &str) -> String {
+ let is_private_chat = data
+ .get("conversationType")
+ .and_then(|value| {
+ value
+ .as_str()
+ .map(|v| v == "1")
+ .or_else(|| value.as_i64().map(|v| v == 1))
+ })
+ .unwrap_or(true);
+
+ if is_private_chat {
+ sender_id.to_string()
+ } else {
+ data.get("conversationId")
+ .and_then(|c| c.as_str())
+ .unwrap_or(sender_id)
+ .to_string()
+ }
+ }
+
+ /// Register a connection with DingTalk's gateway to get a WebSocket endpoint.
+ async fn register_connection(&self) -> anyhow::Result {
+ let body = serde_json::json!({
+ "clientId": self.client_id,
+ "clientSecret": self.client_secret,
+ "subscriptions": [
+ {
+ "type": "CALLBACK",
+ "topic": DINGTALK_BOT_CALLBACK_TOPIC,
+ }
+ ],
+ });
+
+ let resp = self
+ .http_client()
+ .post("https://api.dingtalk.com/v1.0/gateway/connections/open")
+ .json(&body)
+ .send()
+ .await?;
+
+ if !resp.status().is_success() {
+ let status = resp.status();
+ let err = resp.text().await.unwrap_or_default();
+ anyhow::bail!("DingTalk gateway registration failed ({status}): {err}");
+ }
+
+ let gw: GatewayResponse = resp.json().await?;
+ Ok(gw)
+ }
+}
+
+#[async_trait]
+impl Channel for DingTalkChannel {
+ fn name(&self) -> &str {
+ "dingtalk"
+ }
+
+ async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
+ let webhooks = self.session_webhooks.read().await;
+ let webhook_url = webhooks.get(&message.recipient).ok_or_else(|| {
+ anyhow::anyhow!(
+ "No session webhook found for chat {}. \
+ The user must send a message first to establish a session.",
+ message.recipient
+ )
+ })?;
+
+ let title = message.subject.as_deref().unwrap_or("Alphahuman");
+ let body = serde_json::json!({
+ "msgtype": "markdown",
+ "markdown": {
+ "title": title,
+ "text": message.content,
+ }
+ });
+
+ let resp = self
+ .http_client()
+ .post(webhook_url)
+ .json(&body)
+ .send()
+ .await?;
+
+ if !resp.status().is_success() {
+ let status = resp.status();
+ let err = resp.text().await.unwrap_or_default();
+ anyhow::bail!("DingTalk webhook reply failed ({status}): {err}");
+ }
+
+ Ok(())
+ }
+
+ async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> {
+ tracing::info!("DingTalk: registering gateway connection...");
+
+ let gw = self.register_connection().await?;
+ let ws_url = format!("{}?ticket={}", gw.endpoint, gw.ticket);
+
+ tracing::info!("DingTalk: connecting to stream WebSocket...");
+ let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url).await?;
+ let (mut write, mut read) = ws_stream.split();
+
+ tracing::info!("DingTalk: connected and listening for messages...");
+
+ while let Some(msg) = read.next().await {
+ let msg = match msg {
+ Ok(Message::Text(t)) => t,
+ Ok(Message::Close(_)) => break,
+ Err(e) => {
+ tracing::warn!("DingTalk WebSocket error: {e}");
+ break;
+ }
+ _ => continue,
+ };
+
+ let frame: serde_json::Value = match serde_json::from_str(msg.as_ref()) {
+ Ok(v) => v,
+ Err(_) => continue,
+ };
+
+ let frame_type = frame.get("type").and_then(|t| t.as_str()).unwrap_or("");
+
+ match frame_type {
+ "SYSTEM" => {
+ // Respond to system pings to keep the connection alive
+ let message_id = frame
+ .get("headers")
+ .and_then(|h| h.get("messageId"))
+ .and_then(|m| m.as_str())
+ .unwrap_or("");
+
+ let pong = serde_json::json!({
+ "code": 200,
+ "headers": {
+ "contentType": "application/json",
+ "messageId": message_id,
+ },
+ "message": "OK",
+ "data": "",
+ });
+
+ if let Err(e) = write.send(Message::Text(pong.to_string().into())).await {
+ tracing::warn!("DingTalk: failed to send pong: {e}");
+ break;
+ }
+ }
+ "EVENT" | "CALLBACK" => {
+ // Parse the chatbot callback data from the frame.
+ let data = match Self::parse_stream_data(&frame) {
+ Some(v) => v,
+ None => {
+ tracing::debug!("DingTalk: frame has no parseable data payload");
+ continue;
+ }
+ };
+
+ // Extract message content
+ let content = data
+ .get("text")
+ .and_then(|t| t.get("content"))
+ .and_then(|c| c.as_str())
+ .unwrap_or("")
+ .trim();
+
+ if content.is_empty() {
+ continue;
+ }
+
+ let sender_id = data
+ .get("senderStaffId")
+ .and_then(|s| s.as_str())
+ .unwrap_or("unknown");
+
+ if !self.is_user_allowed(sender_id) {
+ tracing::warn!(
+ "DingTalk: ignoring message from unauthorized user: {sender_id}"
+ );
+ continue;
+ }
+
+ // Private chat uses sender ID, group chat uses conversation ID.
+ let chat_id = Self::resolve_chat_id(&data, sender_id);
+
+ // Store session webhook for later replies
+ if let Some(webhook) = data.get("sessionWebhook").and_then(|w| w.as_str()) {
+ let webhook = webhook.to_string();
+ let mut webhooks = self.session_webhooks.write().await;
+ // Use both keys so reply routing works for both group and private flows.
+ webhooks.insert(chat_id.clone(), webhook.clone());
+ webhooks.insert(sender_id.to_string(), webhook);
+ }
+
+ // Acknowledge the event
+ let message_id = frame
+ .get("headers")
+ .and_then(|h| h.get("messageId"))
+ .and_then(|m| m.as_str())
+ .unwrap_or("");
+
+ let ack = serde_json::json!({
+ "code": 200,
+ "headers": {
+ "contentType": "application/json",
+ "messageId": message_id,
+ },
+ "message": "OK",
+ "data": "",
+ });
+ let _ = write.send(Message::Text(ack.to_string().into())).await;
+
+ let channel_msg = ChannelMessage {
+ id: Uuid::new_v4().to_string(),
+ sender: sender_id.to_string(),
+ reply_target: chat_id,
+ content: content.to_string(),
+ channel: "dingtalk".to_string(),
+ timestamp: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs(),
+ thread_ts: None,
+ };
+
+ if tx.send(channel_msg).await.is_err() {
+ tracing::warn!("DingTalk: message channel closed");
+ break;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ anyhow::bail!("DingTalk WebSocket stream ended")
+ }
+
+ async fn health_check(&self) -> bool {
+ self.register_connection().await.is_ok()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_name() {
+ let ch = DingTalkChannel::new("id".into(), "secret".into(), vec![]);
+ assert_eq!(ch.name(), "dingtalk");
+ }
+
+ #[test]
+ fn test_user_allowed_wildcard() {
+ let ch = DingTalkChannel::new("id".into(), "secret".into(), vec!["*".into()]);
+ assert!(ch.is_user_allowed("anyone"));
+ }
+
+ #[test]
+ fn test_user_allowed_specific() {
+ let ch = DingTalkChannel::new("id".into(), "secret".into(), vec!["user123".into()]);
+ assert!(ch.is_user_allowed("user123"));
+ assert!(!ch.is_user_allowed("other"));
+ }
+
+ #[test]
+ fn test_user_denied_empty() {
+ let ch = DingTalkChannel::new("id".into(), "secret".into(), vec![]);
+ assert!(!ch.is_user_allowed("anyone"));
+ }
+
+ #[test]
+ fn test_config_serde() {
+ let toml_str = r#"
+client_id = "app_id_123"
+client_secret = "secret_456"
+allowed_users = ["user1", "*"]
+"#;
+ let config: crate::alphahuman::config::schema::DingTalkConfig = toml::from_str(toml_str).unwrap();
+ assert_eq!(config.client_id, "app_id_123");
+ assert_eq!(config.client_secret, "secret_456");
+ assert_eq!(config.allowed_users, vec!["user1", "*"]);
+ }
+
+ #[test]
+ fn test_config_serde_defaults() {
+ let toml_str = r#"
+client_id = "id"
+client_secret = "secret"
+"#;
+ let config: crate::alphahuman::config::schema::DingTalkConfig = toml::from_str(toml_str).unwrap();
+ assert!(config.allowed_users.is_empty());
+ }
+
+ #[test]
+ fn parse_stream_data_supports_string_payload() {
+ let frame = serde_json::json!({
+ "data": "{\"text\":{\"content\":\"hello\"}}"
+ });
+ let parsed = DingTalkChannel::parse_stream_data(&frame).unwrap();
+ assert_eq!(
+ parsed.get("text").and_then(|v| v.get("content")),
+ Some(&serde_json::json!("hello"))
+ );
+ }
+
+ #[test]
+ fn parse_stream_data_supports_object_payload() {
+ let frame = serde_json::json!({
+ "data": {"text": {"content": "hello"}}
+ });
+ let parsed = DingTalkChannel::parse_stream_data(&frame).unwrap();
+ assert_eq!(
+ parsed.get("text").and_then(|v| v.get("content")),
+ Some(&serde_json::json!("hello"))
+ );
+ }
+
+ #[test]
+ fn resolve_chat_id_handles_numeric_group_conversation_type() {
+ let data = serde_json::json!({
+ "conversationType": 2,
+ "conversationId": "cid-group",
+ });
+ let chat_id = DingTalkChannel::resolve_chat_id(&data, "staff-1");
+ assert_eq!(chat_id, "cid-group");
+ }
+}
diff --git a/src-tauri/src/alphahuman/channels/discord.rs b/src-tauri/src/alphahuman/channels/discord.rs
new file mode 100644
index 000000000..ed8eb7a1b
--- /dev/null
+++ b/src-tauri/src/alphahuman/channels/discord.rs
@@ -0,0 +1,952 @@
+use super::traits::{Channel, ChannelMessage, SendMessage};
+use async_trait::async_trait;
+use futures_util::{SinkExt, StreamExt};
+use parking_lot::Mutex;
+use serde_json::json;
+use tokio_tungstenite::tungstenite::Message;
+use uuid::Uuid;
+
+/// Discord channel — connects via Gateway WebSocket for real-time messages
+pub struct DiscordChannel {
+ bot_token: String,
+ guild_id: Option,
+ allowed_users: Vec,
+ listen_to_bots: bool,
+ mention_only: bool,
+ typing_handle: Mutex>>,
+}
+
+impl DiscordChannel {
+ pub fn new(
+ bot_token: String,
+ guild_id: Option,
+ allowed_users: Vec,
+ listen_to_bots: bool,
+ mention_only: bool,
+ ) -> Self {
+ Self {
+ bot_token,
+ guild_id,
+ allowed_users,
+ listen_to_bots,
+ mention_only,
+ typing_handle: Mutex::new(None),
+ }
+ }
+
+ fn http_client(&self) -> reqwest::Client {
+ crate::alphahuman::config::build_runtime_proxy_client("channel.discord")
+ }
+
+ /// Check if a Discord user ID is in the allowlist.
+ /// Empty list means deny everyone until explicitly configured.
+ /// `"*"` means allow everyone.
+ fn is_user_allowed(&self, user_id: &str) -> bool {
+ self.allowed_users.iter().any(|u| u == "*" || u == user_id)
+ }
+
+ fn bot_user_id_from_token(token: &str) -> Option {
+ // Discord bot tokens are base64(bot_user_id).timestamp.hmac
+ let part = token.split('.').next()?;
+ base64_decode(part)
+ }
+}
+
+const BASE64_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+/// Discord's maximum message length for regular messages.
+///
+/// Discord rejects longer payloads with `50035 Invalid Form Body`.
+const DISCORD_MAX_MESSAGE_LENGTH: usize = 2000;
+
+/// Split a message into chunks that respect Discord's 2000-character limit.
+/// Tries to split at word boundaries when possible.
+fn split_message_for_discord(message: &str) -> Vec {
+ if message.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH {
+ return vec![message.to_string()];
+ }
+
+ let mut chunks = Vec::new();
+ let mut remaining = message;
+
+ while !remaining.is_empty() {
+ // Find the byte offset for the 2000th character boundary.
+ // If there are fewer than 2000 chars left, we can emit the tail directly.
+ let hard_split = remaining
+ .char_indices()
+ .nth(DISCORD_MAX_MESSAGE_LENGTH)
+ .map_or(remaining.len(), |(idx, _)| idx);
+
+ let chunk_end = if hard_split == remaining.len() {
+ hard_split
+ } else {
+ // Try to find a good break point (newline, then space)
+ let search_area = &remaining[..hard_split];
+
+ // Prefer splitting at newline
+ if let Some(pos) = search_area.rfind('\n') {
+ // Don't split if the newline is too close to the end
+ if search_area[..pos].chars().count() >= DISCORD_MAX_MESSAGE_LENGTH / 2 {
+ pos + 1
+ } else {
+ // Try space as fallback
+ search_area.rfind(' ').map_or(hard_split, |space| space + 1)
+ }
+ } else if let Some(pos) = search_area.rfind(' ') {
+ pos + 1
+ } else {
+ // Hard split at the limit
+ hard_split
+ }
+ };
+
+ chunks.push(remaining[..chunk_end].to_string());
+ remaining = &remaining[chunk_end..];
+ }
+
+ chunks
+}
+
+fn mention_tags(bot_user_id: &str) -> [String; 2] {
+ [format!("<@{bot_user_id}>"), format!("<@!{bot_user_id}>")]
+}
+
+fn contains_bot_mention(content: &str, bot_user_id: &str) -> bool {
+ let tags = mention_tags(bot_user_id);
+ content.contains(&tags[0]) || content.contains(&tags[1])
+}
+
+fn normalize_incoming_content(
+ content: &str,
+ mention_only: bool,
+ bot_user_id: &str,
+) -> Option {
+ if content.is_empty() {
+ return None;
+ }
+
+ if mention_only && !contains_bot_mention(content, bot_user_id) {
+ return None;
+ }
+
+ let mut normalized = content.to_string();
+ if mention_only {
+ for tag in mention_tags(bot_user_id) {
+ normalized = normalized.replace(&tag, " ");
+ }
+ }
+
+ let normalized = normalized.trim().to_string();
+ if normalized.is_empty() {
+ return None;
+ }
+
+ Some(normalized)
+}
+
+/// Minimal base64 decode (no extra dep) — only needs to decode the user ID portion
+#[allow(clippy::cast_possible_truncation)]
+fn base64_decode(input: &str) -> Option {
+ let padded = match input.len() % 4 {
+ 2 => format!("{input}=="),
+ 3 => format!("{input}="),
+ _ => input.to_string(),
+ };
+
+ let mut bytes = Vec::new();
+ let chars: Vec = padded.bytes().collect();
+
+ for chunk in chars.chunks(4) {
+ if chunk.len() < 4 {
+ break;
+ }
+
+ let mut v = [0usize; 4];
+ for (i, &b) in chunk.iter().enumerate() {
+ if b == b'=' {
+ v[i] = 0;
+ } else {
+ v[i] = BASE64_ALPHABET.iter().position(|&a| a == b)?;
+ }
+ }
+
+ bytes.push(((v[0] << 2) | (v[1] >> 4)) as u8);
+ if chunk[2] != b'=' {
+ bytes.push((((v[1] & 0xF) << 4) | (v[2] >> 2)) as u8);
+ }
+ if chunk[3] != b'=' {
+ bytes.push((((v[2] & 0x3) << 6) | v[3]) as u8);
+ }
+ }
+
+ String::from_utf8(bytes).ok()
+}
+
+#[async_trait]
+impl Channel for DiscordChannel {
+ fn name(&self) -> &str {
+ "discord"
+ }
+
+ async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
+ let chunks = split_message_for_discord(&message.content);
+
+ for (i, chunk) in chunks.iter().enumerate() {
+ let url = format!(
+ "https://discord.com/api/v10/channels/{}/messages",
+ message.recipient
+ );
+
+ let body = json!({ "content": chunk });
+
+ let resp = self
+ .http_client()
+ .post(&url)
+ .header("Authorization", format!("Bot {}", self.bot_token))
+ .json(&body)
+ .send()
+ .await?;
+
+ if !resp.status().is_success() {
+ let status = resp.status();
+ let err = resp
+ .text()
+ .await
+ .unwrap_or_else(|e| format!(""));
+ anyhow::bail!("Discord send message failed ({status}): {err}");
+ }
+
+ // Add a small delay between chunks to avoid rate limiting
+ if i < chunks.len() - 1 {
+ tokio::time::sleep(std::time::Duration::from_millis(500)).await;
+ }
+ }
+
+ Ok(())
+ }
+
+ #[allow(clippy::too_many_lines)]
+ async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> {
+ let bot_user_id = Self::bot_user_id_from_token(&self.bot_token).unwrap_or_default();
+
+ // Get Gateway URL
+ let gw_resp: serde_json::Value = self
+ .http_client()
+ .get("https://discord.com/api/v10/gateway/bot")
+ .header("Authorization", format!("Bot {}", self.bot_token))
+ .send()
+ .await?
+ .json()
+ .await?;
+
+ let gw_url = gw_resp
+ .get("url")
+ .and_then(|u| u.as_str())
+ .unwrap_or("wss://gateway.discord.gg");
+
+ let ws_url = format!("{gw_url}/?v=10&encoding=json");
+ tracing::info!("Discord: connecting to gateway...");
+
+ let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url).await?;
+ let (mut write, mut read) = ws_stream.split();
+
+ // Read Hello (opcode 10)
+ let hello = read.next().await.ok_or(anyhow::anyhow!("No hello"))??;
+ let hello_data: serde_json::Value = serde_json::from_str(&hello.to_string())?;
+ let heartbeat_interval = hello_data
+ .get("d")
+ .and_then(|d| d.get("heartbeat_interval"))
+ .and_then(serde_json::Value::as_u64)
+ .unwrap_or(41250);
+
+ // Send Identify (opcode 2)
+ let identify = json!({
+ "op": 2,
+ "d": {
+ "token": self.bot_token,
+ "intents": 37377, // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT | DIRECT_MESSAGES
+ "properties": {
+ "os": "linux",
+ "browser": "alphahuman",
+ "device": "alphahuman"
+ }
+ }
+ });
+ write
+ .send(Message::Text(identify.to_string().into()))
+ .await?;
+
+ tracing::info!("Discord: connected and identified");
+
+ // Track the last sequence number for heartbeats and resume.
+ // Only accessed in the select! loop below, so a plain i64 suffices.
+ let mut sequence: i64 = -1;
+
+ // Spawn heartbeat timer — sends a tick signal, actual heartbeat
+ // is assembled in the select! loop where `sequence` lives.
+ let (hb_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1);
+ let hb_interval = heartbeat_interval;
+ tokio::spawn(async move {
+ let mut interval = tokio::time::interval(std::time::Duration::from_millis(hb_interval));
+ loop {
+ interval.tick().await;
+ if hb_tx.send(()).await.is_err() {
+ break;
+ }
+ }
+ });
+
+ let guild_filter = self.guild_id.clone();
+
+ loop {
+ tokio::select! {
+ _ = hb_rx.recv() => {
+ let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
+ let hb = json!({"op": 1, "d": d});
+ if write.send(Message::Text(hb.to_string().into())).await.is_err() {
+ break;
+ }
+ }
+ msg = read.next() => {
+ let msg = match msg {
+ Some(Ok(Message::Text(t))) => t,
+ Some(Ok(Message::Close(_))) | None => break,
+ _ => continue,
+ };
+
+ let event: serde_json::Value = match serde_json::from_str(msg.as_ref()) {
+ Ok(e) => e,
+ Err(_) => continue,
+ };
+
+ // Track sequence number from all dispatch events
+ if let Some(s) = event.get("s").and_then(serde_json::Value::as_i64) {
+ sequence = s;
+ }
+
+ let op = event.get("op").and_then(serde_json::Value::as_u64).unwrap_or(0);
+
+ match op {
+ // Op 1: Server requests an immediate heartbeat
+ 1 => {
+ let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
+ let hb = json!({"op": 1, "d": d});
+ if write.send(Message::Text(hb.to_string().into())).await.is_err() {
+ break;
+ }
+ continue;
+ }
+ // Op 7: Reconnect
+ 7 => {
+ tracing::warn!("Discord: received Reconnect (op 7), closing for restart");
+ break;
+ }
+ // Op 9: Invalid Session
+ 9 => {
+ tracing::warn!("Discord: received Invalid Session (op 9), closing for restart");
+ break;
+ }
+ _ => {}
+ }
+
+ // Only handle MESSAGE_CREATE (opcode 0, type "MESSAGE_CREATE")
+ let event_type = event.get("t").and_then(|t| t.as_str()).unwrap_or("");
+ if event_type != "MESSAGE_CREATE" {
+ continue;
+ }
+
+ let Some(d) = event.get("d") else {
+ continue;
+ };
+
+ // Skip messages from the bot itself
+ let author_id = d.get("author").and_then(|a| a.get("id")).and_then(|i| i.as_str()).unwrap_or("");
+ if author_id == bot_user_id {
+ continue;
+ }
+
+ // Skip bot messages (unless listen_to_bots is enabled)
+ if !self.listen_to_bots && d.get("author").and_then(|a| a.get("bot")).and_then(serde_json::Value::as_bool).unwrap_or(false) {
+ continue;
+ }
+
+ // Sender validation
+ if !self.is_user_allowed(author_id) {
+ tracing::warn!("Discord: ignoring message from unauthorized user: {author_id}");
+ continue;
+ }
+
+ // Guild filter
+ if let Some(ref gid) = guild_filter {
+ let msg_guild = d.get("guild_id").and_then(serde_json::Value::as_str);
+ // DMs have no guild_id — let them through; for guild messages, enforce the filter
+ if let Some(g) = msg_guild {
+ if g != gid {
+ continue;
+ }
+ }
+ }
+
+ let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("");
+ let Some(clean_content) =
+ normalize_incoming_content(content, self.mention_only, &bot_user_id)
+ else {
+ continue;
+ };
+
+ let message_id = d.get("id").and_then(|i| i.as_str()).unwrap_or("");
+ let channel_id = d.get("channel_id").and_then(|c| c.as_str()).unwrap_or("").to_string();
+
+ let channel_msg = ChannelMessage {
+ id: if message_id.is_empty() {
+ Uuid::new_v4().to_string()
+ } else {
+ format!("discord_{message_id}")
+ },
+ sender: author_id.to_string(),
+ reply_target: if channel_id.is_empty() {
+ author_id.to_string()
+ } else {
+ channel_id.clone()
+ },
+ content: clean_content,
+ channel: "discord".to_string(),
+ timestamp: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs(),
+ thread_ts: None,
+ };
+
+ if tx.send(channel_msg).await.is_err() {
+ break;
+ }
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ async fn health_check(&self) -> bool {
+ self.http_client()
+ .get("https://discord.com/api/v10/users/@me")
+ .header("Authorization", format!("Bot {}", self.bot_token))
+ .send()
+ .await
+ .map(|r| r.status().is_success())
+ .unwrap_or(false)
+ }
+
+ async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
+ self.stop_typing(recipient).await?;
+
+ let client = self.http_client();
+ let token = self.bot_token.clone();
+ let channel_id = recipient.to_string();
+
+ let handle = tokio::spawn(async move {
+ let url = format!("https://discord.com/api/v10/channels/{channel_id}/typing");
+ loop {
+ let _ = client
+ .post(&url)
+ .header("Authorization", format!("Bot {token}"))
+ .send()
+ .await;
+ tokio::time::sleep(std::time::Duration::from_secs(8)).await;
+ }
+ });
+
+ let mut guard = self.typing_handle.lock();
+ *guard = Some(handle);
+
+ Ok(())
+ }
+
+ async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
+ let mut guard = self.typing_handle.lock();
+ if let Some(handle) = guard.take() {
+ handle.abort();
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn discord_channel_name() {
+ let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
+ assert_eq!(ch.name(), "discord");
+ }
+
+ #[test]
+ fn base64_decode_bot_id() {
+ // "MTIzNDU2" decodes to "123456"
+ let decoded = base64_decode("MTIzNDU2");
+ assert_eq!(decoded, Some("123456".to_string()));
+ }
+
+ #[test]
+ fn bot_user_id_extraction() {
+ // Token format: base64(user_id).timestamp.hmac
+ let token = "MTIzNDU2.fake.hmac";
+ let id = DiscordChannel::bot_user_id_from_token(token);
+ assert_eq!(id, Some("123456".to_string()));
+ }
+
+ #[test]
+ fn empty_allowlist_denies_everyone() {
+ let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
+ assert!(!ch.is_user_allowed("12345"));
+ assert!(!ch.is_user_allowed("anyone"));
+ }
+
+ #[test]
+ fn wildcard_allows_everyone() {
+ let ch = DiscordChannel::new("fake".into(), None, vec!["*".into()], false, false);
+ assert!(ch.is_user_allowed("12345"));
+ assert!(ch.is_user_allowed("anyone"));
+ }
+
+ #[test]
+ fn specific_allowlist_filters() {
+ let ch = DiscordChannel::new(
+ "fake".into(),
+ None,
+ vec!["111".into(), "222".into()],
+ false,
+ false,
+ );
+ assert!(ch.is_user_allowed("111"));
+ assert!(ch.is_user_allowed("222"));
+ assert!(!ch.is_user_allowed("333"));
+ assert!(!ch.is_user_allowed("unknown"));
+ }
+
+ #[test]
+ fn allowlist_is_exact_match_not_substring() {
+ let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false, false);
+ assert!(!ch.is_user_allowed("1111"));
+ assert!(!ch.is_user_allowed("11"));
+ assert!(!ch.is_user_allowed("0111"));
+ }
+
+ #[test]
+ fn allowlist_empty_string_user_id() {
+ let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false, false);
+ assert!(!ch.is_user_allowed(""));
+ }
+
+ #[test]
+ fn allowlist_with_wildcard_and_specific() {
+ let ch = DiscordChannel::new(
+ "fake".into(),
+ None,
+ vec!["111".into(), "*".into()],
+ false,
+ false,
+ );
+ assert!(ch.is_user_allowed("111"));
+ assert!(ch.is_user_allowed("anyone_else"));
+ }
+
+ #[test]
+ fn allowlist_case_sensitive() {
+ let ch = DiscordChannel::new("fake".into(), None, vec!["ABC".into()], false, false);
+ assert!(ch.is_user_allowed("ABC"));
+ assert!(!ch.is_user_allowed("abc"));
+ assert!(!ch.is_user_allowed("Abc"));
+ }
+
+ #[test]
+ fn base64_decode_empty_string() {
+ let decoded = base64_decode("");
+ assert_eq!(decoded, Some(String::new()));
+ }
+
+ #[test]
+ fn base64_decode_invalid_chars() {
+ let decoded = base64_decode("!!!!");
+ assert!(decoded.is_none());
+ }
+
+ #[test]
+ fn bot_user_id_from_empty_token() {
+ let id = DiscordChannel::bot_user_id_from_token("");
+ assert_eq!(id, Some(String::new()));
+ }
+
+ #[test]
+ fn contains_bot_mention_supports_plain_and_nick_forms() {
+ assert!(contains_bot_mention("hi <@12345>", "12345"));
+ assert!(contains_bot_mention("hi <@!12345>", "12345"));
+ assert!(!contains_bot_mention("hi <@99999>", "12345"));
+ }
+
+ #[test]
+ fn normalize_incoming_content_requires_mention_when_enabled() {
+ let cleaned = normalize_incoming_content("hello there", true, "12345");
+ assert!(cleaned.is_none());
+ }
+
+ #[test]
+ fn normalize_incoming_content_strips_mentions_and_trims() {
+ let cleaned = normalize_incoming_content(" <@!12345> run status ", true, "12345");
+ assert_eq!(cleaned.as_deref(), Some("run status"));
+ }
+
+ #[test]
+ fn normalize_incoming_content_rejects_empty_after_strip() {
+ let cleaned = normalize_incoming_content("<@12345>", true, "12345");
+ assert!(cleaned.is_none());
+ }
+
+ // Message splitting tests
+
+ #[test]
+ fn split_empty_message() {
+ let chunks = split_message_for_discord("");
+ assert_eq!(chunks, vec![""]);
+ }
+
+ #[test]
+ fn split_short_message_under_limit() {
+ let msg = "Hello, world!";
+ let chunks = split_message_for_discord(msg);
+ assert_eq!(chunks, vec![msg]);
+ }
+
+ #[test]
+ fn split_message_exactly_2000_chars() {
+ let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH);
+ let chunks = split_message_for_discord(&msg);
+ assert_eq!(chunks.len(), 1);
+ assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
+ }
+
+ #[test]
+ fn split_message_just_over_limit() {
+ let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH + 1);
+ let chunks = split_message_for_discord(&msg);
+ assert_eq!(chunks.len(), 2);
+ assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
+ assert_eq!(chunks[1].chars().count(), 1);
+ }
+
+ #[test]
+ fn split_very_long_message() {
+ let msg = "word ".repeat(2000); // 10000 characters (5 chars per "word ")
+ let chunks = split_message_for_discord(&msg);
+ // Should split into 5 chunks of <= 2000 chars
+ assert_eq!(chunks.len(), 5);
+ assert!(chunks
+ .iter()
+ .all(|chunk| chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH));
+ // Verify total content is preserved
+ let reconstructed = chunks.concat();
+ assert_eq!(reconstructed, msg);
+ }
+
+ #[test]
+ fn split_prefer_newline_break() {
+ let msg = format!("{}\n{}", "a".repeat(1500), "b".repeat(500));
+ let chunks = split_message_for_discord(&msg);
+ // Should split at the newline
+ assert_eq!(chunks.len(), 2);
+ assert!(chunks[0].ends_with('\n'));
+ assert!(chunks[1].starts_with('b'));
+ }
+
+ #[test]
+ fn split_prefer_space_break() {
+ let msg = format!("{} {}", "a".repeat(1500), "b".repeat(600));
+ let chunks = split_message_for_discord(&msg);
+ assert_eq!(chunks.len(), 2);
+ }
+
+ #[test]
+ fn split_without_good_break_points_hard_split() {
+ // No spaces or newlines - should hard split at 2000
+ let msg = "a".repeat(5000);
+ let chunks = split_message_for_discord(&msg);
+ assert_eq!(chunks.len(), 3);
+ assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
+ assert_eq!(chunks[1].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
+ assert_eq!(chunks[2].chars().count(), 1000);
+ }
+
+ #[test]
+ fn split_multiple_breaks() {
+ // Create a message with multiple newlines
+ let part1 = "a".repeat(900);
+ let part2 = "b".repeat(900);
+ let part3 = "c".repeat(900);
+ let msg = format!("{part1}\n{part2}\n{part3}");
+ let chunks = split_message_for_discord(&msg);
+ // Should split into 2 chunks (first two parts + third part)
+ assert_eq!(chunks.len(), 2);
+ assert!(chunks[0].chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
+ assert!(chunks[1].chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
+ }
+
+ #[test]
+ fn split_preserves_content() {
+ let original = "Hello world! This is a test message with some content. ".repeat(200);
+ let chunks = split_message_for_discord(&original);
+ let reconstructed = chunks.concat();
+ assert_eq!(reconstructed, original);
+ }
+
+ #[test]
+ fn split_unicode_content() {
+ // Test with emoji and multi-byte characters
+ let msg = "🦀 Rust is awesome! ".repeat(500);
+ let chunks = split_message_for_discord(&msg);
+ // All chunks should be valid UTF-8
+ for chunk in &chunks {
+ assert!(std::str::from_utf8(chunk.as_bytes()).is_ok());
+ assert!(chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
+ }
+ // Reconstruct and verify
+ let reconstructed = chunks.concat();
+ assert_eq!(reconstructed, msg);
+ }
+
+ #[test]
+ fn split_newline_too_close_to_end() {
+ // If newline is in the first half, don't use it - use space instead or hard split
+ let msg = format!("{}\n{}", "a".repeat(1900), "b".repeat(500));
+ let chunks = split_message_for_discord(&msg);
+ // Should split at newline since it's in the second half of the window
+ assert_eq!(chunks.len(), 2);
+ }
+
+ #[test]
+ fn split_multibyte_only_content_without_panics() {
+ let msg = "🦀".repeat(2500);
+ let chunks = split_message_for_discord(&msg);
+ assert_eq!(chunks.len(), 2);
+ assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
+ assert_eq!(chunks[1].chars().count(), 500);
+ let reconstructed = chunks.concat();
+ assert_eq!(reconstructed, msg);
+ }
+
+ #[test]
+ fn split_chunks_always_within_discord_limit() {
+ let msg = "x".repeat(12_345);
+ let chunks = split_message_for_discord(&msg);
+ assert!(chunks
+ .iter()
+ .all(|chunk| chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH));
+ }
+
+ #[test]
+ fn split_message_with_multiple_newlines() {
+ let msg = "Line 1\nLine 2\nLine 3\n".repeat(1000);
+ let chunks = split_message_for_discord(&msg);
+ assert!(chunks.len() > 1);
+ let reconstructed = chunks.concat();
+ assert_eq!(reconstructed, msg);
+ }
+
+ #[test]
+ fn typing_handle_starts_as_none() {
+ let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
+ let guard = ch.typing_handle.lock();
+ assert!(guard.is_none());
+ }
+
+ #[tokio::test]
+ async fn start_typing_sets_handle() {
+ let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
+ let _ = ch.start_typing("123456").await;
+ let guard = ch.typing_handle.lock();
+ assert!(guard.is_some());
+ }
+
+ #[tokio::test]
+ async fn stop_typing_clears_handle() {
+ let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
+ let _ = ch.start_typing("123456").await;
+ let _ = ch.stop_typing("123456").await;
+ let guard = ch.typing_handle.lock();
+ assert!(guard.is_none());
+ }
+
+ #[tokio::test]
+ async fn stop_typing_is_idempotent() {
+ let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
+ assert!(ch.stop_typing("123456").await.is_ok());
+ assert!(ch.stop_typing("123456").await.is_ok());
+ }
+
+ #[tokio::test]
+ async fn start_typing_replaces_existing_task() {
+ let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
+ let _ = ch.start_typing("111").await;
+ let _ = ch.start_typing("222").await;
+ let guard = ch.typing_handle.lock();
+ assert!(guard.is_some());
+ }
+
+ // ── Message ID edge cases ─────────────────────────────────────
+
+ #[test]
+ fn discord_message_id_format_includes_discord_prefix() {
+ // Verify that message IDs follow the format: discord_{message_id}
+ let message_id = "123456789012345678";
+ let expected_id = format!("discord_{message_id}");
+ assert_eq!(expected_id, "discord_123456789012345678");
+ }
+
+ #[test]
+ fn discord_message_id_is_deterministic() {
+ // Same message_id = same ID (prevents duplicates after restart)
+ let message_id = "123456789012345678";
+ let id1 = format!("discord_{message_id}");
+ let id2 = format!("discord_{message_id}");
+ assert_eq!(id1, id2);
+ }
+
+ #[test]
+ fn discord_message_id_different_message_different_id() {
+ // Different message IDs produce different IDs
+ let id1 = "discord_123456789012345678".to_string();
+ let id2 = "discord_987654321098765432".to_string();
+ assert_ne!(id1, id2);
+ }
+
+ #[test]
+ fn discord_message_id_uses_snowflake_id() {
+ // Discord snowflake IDs are numeric strings
+ let message_id = "123456789012345678"; // Typical snowflake format
+ let id = format!("discord_{message_id}");
+ assert!(id.starts_with("discord_"));
+ // Snowflake IDs are numeric
+ assert!(message_id.chars().all(|c| c.is_ascii_digit()));
+ }
+
+ #[test]
+ fn discord_message_id_fallback_to_uuid_on_empty() {
+ // Edge case: empty message_id falls back to UUID
+ let message_id = "";
+ let id = if message_id.is_empty() {
+ format!("discord_{}", uuid::Uuid::new_v4())
+ } else {
+ format!("discord_{message_id}")
+ };
+ assert!(id.starts_with("discord_"));
+ // Should have UUID dashes
+ assert!(id.contains('-'));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // TG6: Channel platform limit edge cases for Discord (2000 char limit)
+ // Prevents: Pattern 6 — issues #574, #499
+ // ─────────────────────────────────────────────────────────────────────
+
+ #[test]
+ fn split_message_code_block_at_boundary() {
+ // Code block that spans the split boundary
+ let mut msg = String::new();
+ msg.push_str("```rust\n");
+ msg.push_str(&"x".repeat(1990));
+ msg.push_str("\n```\nMore text after code block");
+ let parts = split_message_for_discord(&msg);
+ assert!(parts.len() >= 2, "code block spanning boundary should split");
+ for part in &parts {
+ assert!(
+ part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
+ "each part must be <= {DISCORD_MAX_MESSAGE_LENGTH}, got {}",
+ part.len()
+ );
+ }
+ }
+
+ #[test]
+ fn split_message_single_long_word_exceeds_limit() {
+ // A single word longer than 2000 chars must be hard-split
+ let long_word = "a".repeat(2500);
+ let parts = split_message_for_discord(&long_word);
+ assert!(parts.len() >= 2, "word exceeding limit must be split");
+ for part in &parts {
+ assert!(
+ part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
+ "hard-split part must be <= {DISCORD_MAX_MESSAGE_LENGTH}, got {}",
+ part.len()
+ );
+ }
+ // Reassembled content should match original
+ let reassembled: String = parts.join("");
+ assert_eq!(reassembled, long_word);
+ }
+
+ #[test]
+ fn split_message_exactly_at_limit_no_split() {
+ let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH);
+ let parts = split_message_for_discord(&msg);
+ assert_eq!(parts.len(), 1, "message exactly at limit should not split");
+ assert_eq!(parts[0].len(), DISCORD_MAX_MESSAGE_LENGTH);
+ }
+
+ #[test]
+ fn split_message_one_over_limit_splits() {
+ let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH + 1);
+ let parts = split_message_for_discord(&msg);
+ assert!(parts.len() >= 2, "message 1 char over limit must split");
+ }
+
+ #[test]
+ fn split_message_many_short_lines() {
+ // Many short lines should be batched into chunks under the limit
+ let msg: String = (0..500).map(|i| format!("line {i}\n")).collect();
+ let parts = split_message_for_discord(&msg);
+ for part in &parts {
+ assert!(
+ part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
+ "short-line batch must be <= limit"
+ );
+ }
+ // All content should be preserved
+ let reassembled: String = parts.join("");
+ assert_eq!(reassembled.trim(), msg.trim());
+ }
+
+ #[test]
+ fn split_message_only_whitespace() {
+ let msg = " \n\n\t ";
+ let parts = split_message_for_discord(msg);
+ // Should handle gracefully without panic
+ assert!(parts.len() <= 1);
+ }
+
+ #[test]
+ fn split_message_emoji_at_boundary() {
+ // Emoji are multi-byte; ensure we don't split mid-emoji
+ let mut msg = "a".repeat(1998);
+ msg.push_str("🎉🎊"); // 2 emoji at the boundary (2000 chars total)
+ let parts = split_message_for_discord(&msg);
+ for part in &parts {
+ // The function splits on character count, not byte count
+ assert!(
+ part.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH,
+ "emoji boundary split must respect limit"
+ );
+ }
+ }
+
+ #[test]
+ fn split_message_consecutive_newlines_at_boundary() {
+ let mut msg = "a".repeat(1995);
+ msg.push_str("\n\n\n\n\n");
+ msg.push_str(&"b".repeat(100));
+ let parts = split_message_for_discord(&msg);
+ for part in &parts {
+ assert!(part.len() <= DISCORD_MAX_MESSAGE_LENGTH);
+ }
+ }
+}
diff --git a/src-tauri/src/alphahuman/channels/email_channel.rs b/src-tauri/src/alphahuman/channels/email_channel.rs
new file mode 100644
index 000000000..91e9db397
--- /dev/null
+++ b/src-tauri/src/alphahuman/channels/email_channel.rs
@@ -0,0 +1,968 @@
+#![allow(clippy::uninlined_format_args)]
+#![allow(clippy::map_unwrap_or)]
+#![allow(clippy::redundant_closure_for_method_calls)]
+#![allow(clippy::cast_lossless)]
+#![allow(clippy::trim_split_whitespace)]
+#![allow(clippy::doc_link_with_quotes)]
+#![allow(clippy::doc_markdown)]
+#![allow(clippy::too_many_lines)]
+#![allow(clippy::unnecessary_map_or)]
+
+use anyhow::{anyhow, Result};
+use async_imap::extensions::idle::IdleResponse;
+use async_imap::types::Fetch;
+use async_imap::Session;
+use async_trait::async_trait;
+use futures::TryStreamExt;
+use lettre::message::SinglePart;
+use lettre::transport::smtp::authentication::Credentials;
+use lettre::{Message, SmtpTransport, Transport};
+use mail_parser::{MessageParser, MimeHeaders};
+use rustls::{ClientConfig, RootCertStore};
+use rustls_pki_types::DnsName;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::collections::HashSet;
+use std::sync::Arc;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+use tokio::net::TcpStream;
+use tokio::sync::{mpsc, Mutex};
+use tokio::time::{sleep, timeout};
+use tokio_rustls::client::TlsStream;
+use tokio_rustls::TlsConnector;
+use tracing::{debug, error, info, warn};
+use uuid::Uuid;
+
+use super::traits::{Channel, ChannelMessage, SendMessage};
+
+/// Email channel configuration
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct EmailConfig {
+ /// IMAP server hostname
+ pub imap_host: String,
+ /// IMAP server port (default: 993 for TLS)
+ #[serde(default = "default_imap_port")]
+ pub imap_port: u16,
+ /// IMAP folder to poll (default: INBOX)
+ #[serde(default = "default_imap_folder")]
+ pub imap_folder: String,
+ /// SMTP server hostname
+ pub smtp_host: String,
+ /// SMTP server port (default: 465 for TLS)
+ #[serde(default = "default_smtp_port")]
+ pub smtp_port: u16,
+ /// Use TLS for SMTP (default: true)
+ #[serde(default = "default_true")]
+ pub smtp_tls: bool,
+ /// Email username for authentication
+ pub username: String,
+ /// Email password for authentication
+ pub password: String,
+ /// From address for outgoing emails
+ pub from_address: String,
+ /// IDLE timeout in seconds before re-establishing connection (default: 1740 = 29 minutes)
+ /// RFC 2177 recommends clients restart IDLE every 29 minutes
+ #[serde(default = "default_idle_timeout", alias = "poll_interval_secs")]
+ pub idle_timeout_secs: u64,
+ /// Allowed sender addresses/domains (empty = deny all, ["*"] = allow all)
+ #[serde(default)]
+ pub allowed_senders: Vec,
+}
+
+fn default_imap_port() -> u16 {
+ 993
+}
+fn default_smtp_port() -> u16 {
+ 465
+}
+fn default_imap_folder() -> String {
+ "INBOX".into()
+}
+fn default_idle_timeout() -> u64 {
+ 1740 // 29 minutes per RFC 2177
+}
+fn default_true() -> bool {
+ true
+}
+
+impl Default for EmailConfig {
+ fn default() -> Self {
+ Self {
+ imap_host: String::new(),
+ imap_port: default_imap_port(),
+ imap_folder: default_imap_folder(),
+ smtp_host: String::new(),
+ smtp_port: default_smtp_port(),
+ smtp_tls: true,
+ username: String::new(),
+ password: String::new(),
+ from_address: String::new(),
+ idle_timeout_secs: default_idle_timeout(),
+ allowed_senders: Vec::new(),
+ }
+ }
+}
+
+type ImapSession = Session>;
+
+/// Email channel — IMAP IDLE for instant push notifications, SMTP for outbound
+pub struct EmailChannel {
+ pub config: EmailConfig,
+ seen_messages: Arc>>,
+}
+
+impl EmailChannel {
+ pub fn new(config: EmailConfig) -> Self {
+ Self {
+ config,
+ seen_messages: Arc::new(Mutex::new(HashSet::new())),
+ }
+ }
+
+ /// Check if a sender email is in the allowlist
+ pub fn is_sender_allowed(&self, email: &str) -> bool {
+ if self.config.allowed_senders.is_empty() {
+ return false; // Empty = deny all
+ }
+ if self.config.allowed_senders.iter().any(|a| a == "*") {
+ return true; // Wildcard = allow all
+ }
+ let email_lower = email.to_lowercase();
+ self.config.allowed_senders.iter().any(|allowed| {
+ if allowed.starts_with('@') {
+ // Domain match with @ prefix: "@example.com"
+ email_lower.ends_with(&allowed.to_lowercase())
+ } else if allowed.contains('@') {
+ // Full email address match
+ allowed.eq_ignore_ascii_case(email)
+ } else {
+ // Domain match without @ prefix: "example.com"
+ email_lower.ends_with(&format!("@{}", allowed.to_lowercase()))
+ }
+ })
+ }
+
+ /// Strip HTML tags from content (basic)
+ pub fn strip_html(html: &str) -> String {
+ let mut result = String::new();
+ let mut in_tag = false;
+ for ch in html.chars() {
+ match ch {
+ '<' => in_tag = true,
+ '>' => in_tag = false,
+ _ if !in_tag => result.push(ch),
+ _ => {}
+ }
+ }
+ let mut normalized = String::with_capacity(result.len());
+ for word in result.split_whitespace() {
+ if !normalized.is_empty() {
+ normalized.push(' ');
+ }
+ normalized.push_str(word);
+ }
+ normalized
+ }
+
+ /// Extract the sender address from a parsed email
+ fn extract_sender(parsed: &mail_parser::Message) -> String {
+ parsed
+ .from()
+ .and_then(|addr| addr.first())
+ .and_then(|a| a.address())
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| "unknown".into())
+ }
+
+ /// Extract readable text from a parsed email
+ fn extract_text(parsed: &mail_parser::Message) -> String {
+ if let Some(text) = parsed.body_text(0) {
+ return text.to_string();
+ }
+ if let Some(html) = parsed.body_html(0) {
+ return Self::strip_html(html.as_ref());
+ }
+ for part in parsed.attachments() {
+ let part: &mail_parser::MessagePart = part;
+ if let Some(ct) = MimeHeaders::content_type(part) {
+ if ct.ctype() == "text" {
+ if let Ok(text) = std::str::from_utf8(part.contents()) {
+ let name = MimeHeaders::attachment_name(part).unwrap_or("file");
+ return format!("[Attachment: {}]\n{}", name, text);
+ }
+ }
+ }
+ }
+ "(no readable content)".to_string()
+ }
+
+ /// Connect to IMAP server with TLS and authenticate
+ async fn connect_imap(&self) -> Result {
+ let addr = format!("{}:{}", self.config.imap_host, self.config.imap_port);
+ debug!("Connecting to IMAP server at {}", addr);
+
+ // Connect TCP
+ let tcp = TcpStream::connect(&addr).await?;
+
+ // Establish TLS using rustls
+ let certs = RootCertStore {
+ roots: webpki_roots::TLS_SERVER_ROOTS.into(),
+ };
+ let config = ClientConfig::builder()
+ .with_root_certificates(certs)
+ .with_no_client_auth();
+ let tls_stream: TlsConnector = Arc::new(config).into();
+ let sni: DnsName = self.config.imap_host.clone().try_into()?;
+ let stream = tls_stream.connect(sni.into(), tcp).await?;
+
+ // Create IMAP client
+ let client = async_imap::Client::new(stream);
+
+ // Login
+ let session = client
+ .login(&self.config.username, &self.config.password)
+ .await
+ .map_err(|(e, _)| anyhow!("IMAP login failed: {}", e))?;
+
+ debug!("IMAP login successful");
+ Ok(session)
+ }
+
+ /// Fetch and process unseen messages from the selected mailbox
+ async fn fetch_unseen(&self, session: &mut ImapSession) -> Result> {
+ // Search for unseen messages
+ let uids = session.uid_search("UNSEEN").await?;
+ if uids.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ debug!("Found {} unseen messages", uids.len());
+
+ let mut results = Vec::new();
+ let uid_set: String = uids
+ .iter()
+ .map(|u| u.to_string())
+ .collect::>()
+ .join(",");
+
+ // Fetch message bodies
+ let messages = session.uid_fetch(&uid_set, "RFC822").await?;
+ let messages: Vec = messages.try_collect().await?;
+
+ for msg in messages {
+ let uid = msg.uid.unwrap_or(0);
+ if let Some(body) = msg.body() {
+ if let Some(parsed) = MessageParser::default().parse(body) {
+ let sender = Self::extract_sender(&parsed);
+ let subject = parsed.subject().unwrap_or("(no subject)").to_string();
+ let body_text = Self::extract_text(&parsed);
+ let content = format!("Subject: {}\n\n{}", subject, body_text);
+ let msg_id = parsed
+ .message_id()
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| format!("gen-{}", Uuid::new_v4()));
+
+ #[allow(clippy::cast_sign_loss)]
+ let ts = parsed
+ .date()
+ .map(|d| {
+ let naive = chrono::NaiveDate::from_ymd_opt(
+ d.year as i32,
+ u32::from(d.month),
+ u32::from(d.day),
+ )
+ .and_then(|date| {
+ date.and_hms_opt(
+ u32::from(d.hour),
+ u32::from(d.minute),
+ u32::from(d.second),
+ )
+ });
+ naive.map_or(0, |n| n.and_utc().timestamp() as u64)
+ })
+ .unwrap_or_else(|| {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+ });
+
+ results.push(ParsedEmail {
+ _uid: uid,
+ msg_id,
+ sender,
+ content,
+ timestamp: ts,
+ });
+ }
+ }
+ }
+
+ // Mark fetched messages as seen
+ if !results.is_empty() {
+ let _ = session
+ .uid_store(&uid_set, "+FLAGS (\\Seen)")
+ .await?
+ .try_collect::