Compare commits

...

9 Commits

Author SHA1 Message Date
jbrechtel 38014aaeb6 Updates README 2026-05-09 16:39:25 -04:00
jbrechtel 2d04e21e7a Proxies approval mode to goose 2026-05-09 16:37:33 -04:00
jbrechtel 8c098c56fc Batching of thoughts, improved permissions 2026-05-09 16:32:12 -04:00
jbrechtel 722169adfd Expanded ACP support
Can now send chain-of-thought messages
Manage terminals
Control agent working directory
Adding more tool support and notifications
Some session management
Added image support
Added slash commands
2026-05-09 16:25:20 -04:00
jbrechtel 3b1738fdfb Fixes goose acp initialization 2026-05-09 15:59:59 -04:00
jbrechtel 94563b1f1d Permission prompting 2026-05-09 15:51:48 -04:00
jbrechtel bd0242027f Initial ACP connection 2026-05-09 15:46:23 -04:00
jbrechtel bc253f3c67 Ignores dev config 2026-05-09 15:19:16 -04:00
jbrechtel 8c00488583 Basic config 2026-05-09 15:17:47 -04:00
16 changed files with 1508 additions and 52 deletions
+25 -1
View File
@@ -1 +1,25 @@
logs # Python
__pycache__/
*.py[cod]
*.egg-info/
*.egg
dist/
build/
# Virtual environments
.venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Honker
logs/
config.dev.yaml
+117 -6
View File
@@ -1,13 +1,124 @@
# Honker # Honker
Honker acts as an ACP (https://agentclientprotocol.com/protocol/overview) bridge to let a user use Signal Messenger to drive the Goose https://goose-docs.ai/ coding agent Honker is an [ACP](https://agentclientprotocol.com/protocol/overview) bridge that lets you drive the [Goose](https://goose-docs.ai/) coding agent from Signal Messenger.
## Technical details Send a message to your Signal account, Goose does the work, and the response comes back as a Signal message.
Honker is written in Python and uses SQLite as its backing store for minimal state tracking. ## Prerequisites
Configuration is done via ~/.config/honker/config.yaml Honker expects both tools to already be installed and configured on the machine where it runs:
Honker expects both Goose and signal-cli to already be configured on the machine where it runs. - **[Goose](https://goose-docs.ai/)** — installed and configured with an AI provider (`goose configure`)
- **[signal-cli](https://github.com/AsamK/signal-cli)** — installed and registered/linked to a Signal account
- **[uv](https://docs.astral.sh/uv/)** — Python package manager
- **Python 3.12+**
Honker manages the lifecycle of both goose and signal-cli rather than expecting them to be ran as a separate service. ## Quick Start
1. **Clone and install**
```bash
git clone <repo-url> honker
cd honker
uv sync
```
2. **Create a config file**
```bash
mkdir -p ~/.config/honker
cp config.sample.yaml ~/.config/honker/config.yaml
```
Edit `~/.config/honker/config.yaml` with your details:
```yaml
account: "+15551234567"
trusted_contacts:
- name: "Alice"
number: "+15559876543"
working_directory: "/home/user/projects"
```
3. **Run honker**
```bash
uv run honker
```
That's it. Honker starts signal-cli and Goose, connects them, and waits for messages from your trusted contacts.
## Configuration
All configuration lives in `~/.config/honker/config.yaml` (or pass `--config <path>`).
| Option | Default | Description |
|---|---|---|
| `account` | *(required)* | Your Signal phone number (e.g. `"+15551234567"`) |
| `trusted_contacts` | `[]` | List of contacts allowed to interact with Goose. Each has a `name` and `number`. |
| `working_directory` | `.` | Working directory for Goose sessions. Use an absolute path. |
| `auto_approve_tools` | `false` | When `true`, Goose runs tools without asking. When `false`, each tool call prompts you for approval over Signal. |
| `forward_thoughts` | `false` | When `true`, forwards Goose's chain-of-thought reasoning to Signal. Can be noisy. |
See [config.sample.yaml](config.sample.yaml) for a complete example.
## Usage
Send a regular text message from a trusted contact's Signal account. Honker forwards it to Goose, waits for the response, and sends it back.
### Slash Commands
| Command | Description |
|---|---|
| `/cancel` | Cancel the current in-progress request |
| `/new` | Close the current session and start a fresh one |
| `/help` | Show available commands |
### Tool Approval
When `auto_approve_tools` is `false` (the default), Goose asks for permission before running tools. You'll receive a message like:
```
🔐 *Permission requested*
shell · rm -rf /tmp/old-stuff
1. allow_always
2. allow_once
3. reject_once
4. reject_always
Reply with a number to choose:
```
Reply with a number or the option name (e.g. `2` or `allow`).
### Images
You can send images to Goose via Signal — they're forwarded as part of the prompt. Useful for asking about screenshots, diagrams, etc.
## Debugging
```bash
uv run honker --debug ./logs
```
This enables verbose logging and writes per-process output to the given directory:
- `logs/signal-cli.stdout.log` / `logs/signal-cli.stderr.log`
Goose maintains its own logs at `~/.local/state/goose/logs/`.
## Technical Details
- Honker manages the lifecycle of both Goose and signal-cli rather than expecting them as separate services
- Goose is launched via `goose acp` (stdio-based ACP protocol) using the [agent-client-protocol](https://pypi.org/project/agent-client-protocol/) Python library
- signal-cli runs as a daemon with a Unix socket JSON-RPC interface
- Each trusted contact gets their own Goose session with separate conversation history
- Only one prompt per user is processed at a time — additional messages get a "please wait" reply
## License
TBD
+22
View File
@@ -0,0 +1,22 @@
# Honker configuration
# Copy this file to ~/.config/honker/config.yaml and edit to suit your setup.
# The Signal account number honker will use
account: "+15551234567"
# Contacts that are allowed to interact with honker
trusted_contacts:
- name: "Alice"
number: "+15559876543"
- name: "Bob"
number: "+15551112222"
# Working directory for Goose sessions (absolute path recommended)
working_directory: "/home/user/projects"
# Forward Goose's chain-of-thought reasoning to Signal (can be noisy)
forward_thoughts: false
# Auto-approve all tool permission requests without asking the user.
# When false (default), each tool call prompts the user for approval via Signal.
auto_approve_tools: false
+233
View File
@@ -0,0 +1,233 @@
# Honker ACP Bridge — Implementation Plan
## Current State
What's implemented today:
- ✅ Initialize ACP connection to Goose (`goose acp` over stdio)
- ✅ Create new sessions (one per Signal contact)
- ✅ Send text prompts and collect text responses
- ✅ Interactive permission requests over Signal (numbered options)
- ✅ Signal-cli lifecycle management and socket communication
---
## Phase 1: Essential — Make the Bridge Robust
### 1.1 Terminal Execution (Client ← Agent)
**Priority: HIGH — Goose cannot function without this**
Goose's primary tool is running shell commands. The ACP protocol has the agent
ask the *client* to execute commands via `create_terminal`, then polls output
with `terminal_output` and `wait_for_terminal_exit`.
Currently we return `None` for all terminal methods, which means Goose can't
run any commands. We need to:
- `create_terminal(command, args, cwd, env)` → spawn a real subprocess, return
a `terminal_id`
- `terminal_output(terminal_id)` → return accumulated stdout/stderr
- `wait_for_terminal_exit(terminal_id)` → await process completion, return exit code
- `release_terminal(terminal_id)` → clean up
- `kill_terminal(terminal_id)` → kill the subprocess
Implementation: a `TerminalManager` class that tracks running subprocesses
keyed by terminal_id.
### 1.2 File System Operations (Client ← Agent)
**Priority: HIGH — Goose cannot edit code without this**
The agent asks the client to read/write files:
- `read_text_file(path, line, limit)` → read from the local filesystem, return content
- `write_text_file(path, content)` → write to the local filesystem
Currently we return `None`, so Goose can't read or write files.
These should be straightforward to implement against the local filesystem.
We should also advertise these capabilities in `ClientCapabilities` during
`initialize`:
```python
ClientCapabilities(
fs=FileSystemCapabilities(read_text_file=True, write_text_file=True),
terminal=True,
)
```
### 1.3 Cancellation
**Priority: MEDIUM**
Allow Signal users to cancel a running prompt. The user could send a special
command (e.g. `/cancel`) while a prompt is in progress. We'd call
`conn.cancel(session_id)` to tell Goose to stop.
The `PromptResponse.stop_reason` already distinguishes `cancelled` from
`end_turn`, `max_tokens`, `refusal`, and `max_turn_requests` — we should
handle each appropriately in the reply.
### 1.4 Stop Reason Handling
**Priority: MEDIUM**
Currently we only look at the response text. We should handle all stop reasons:
- `end_turn` — normal completion (current behavior)
- `max_tokens` — context limit hit; notify user
- `max_turn_requests` — agent hit its turn limit; notify user
- `refusal` — agent refused the request; notify user
- `cancelled` — user cancelled; acknowledge
---
## Phase 2: Enriched Feedback
### 2.1 Tool Call Notifications
**Priority: MEDIUM**
Forward tool activity as status messages so the Signal user knows what Goose
is doing while working. On `ToolCallStart`, send a brief message like:
"🔧 Running: `ls -la /src`"
On `ToolCallProgress` with status updates, optionally send progress. We need
to be careful not to spam — batch or throttle these.
### 2.2 Agent Thoughts
**Priority: LOW-MEDIUM**
`AgentThoughtChunk` contains the agent's chain-of-thought reasoning. Could
forward these optionally (config flag) as italicized or prefixed messages.
Useful for debugging but noisy for everyday use.
### 2.3 Plan Updates
**Priority: LOW-MEDIUM**
`AgentPlanUpdate` sends a structured plan with entries (content, priority,
status). Could render as a checklist:
```
📋 Plan:
✅ Read the project structure
🔄 Implement the new feature
⬚ Write tests
⬚ Update documentation
```
### 2.4 Usage/Cost Updates
**Priority: LOW**
`UsageUpdate` reports token usage (size, used) and optional `Cost` (amount,
currency). Could send periodic summaries or on-demand via a `/usage` command.
---
## Phase 3: Session Management
### 3.1 Session Persistence (Load/Resume)
**Priority: MEDIUM**
Goose supports `load_session` and `resume_session`. We could persist the
session_id→contact mapping in SQLite (as the README mentions) and restore
sessions across honker restarts.
- `load_session(cwd, session_id)` — restore conversation history
- `resume_session(cwd, session_id)` — resume an interrupted session
- `list_sessions()` — list available sessions
- `close_session(session_id)` — explicitly end a session
User commands: `/sessions`, `/load <id>`, `/new` (force new session)
### 3.2 Session Commands
**Priority: MEDIUM**
Signal user sends special commands (prefix with `/`):
- `/new` — start a fresh session
- `/sessions` — list previous sessions
- `/load <id>` — resume a previous session
- `/cancel` — cancel current prompt
- `/mode <mode>` — change session mode (via `set_session_mode`)
- `/model <model>` — change model (via `set_session_model`)
- `/usage` — show token usage / cost
### 3.3 Session Modes
**Priority: LOW**
Goose supports modes (e.g., "auto" vs "manual" approval). `set_session_mode`
and `CurrentModeUpdate` notifications. Could expose via `/mode` command.
### 3.4 Session Configuration
**Priority: LOW**
`set_config_option` lets the client change boolean/select config options.
`ConfigOptionUpdate` notifications report changes. Could expose via a
`/config` command.
---
## Phase 4: Rich Content
### 4.1 Image Support (Input)
**Priority: MEDIUM**
Signal users can send images. signal-cli provides attachment data. ACP
supports `ImageContentBlock` in prompts (with base64 `data` and `mime_type`).
We could forward Signal image attachments to Goose as image content blocks.
### 4.2 Image Support (Output)
**Priority: LOW**
If Goose responds with `ImageContentBlock` in agent messages, we could send
them as Signal attachments. Less common but possible.
### 4.3 File/Resource Content
**Priority: LOW**
`ResourceContentBlock` and `EmbeddedResourceContentBlock` in agent messages.
Could send file contents as Signal attachments or inline code blocks.
### 4.4 Audio Support
**Priority: LOW**
ACP has `AudioContentBlock`. Signal supports voice messages. Theoretically
bridgeable but Goose doesn't currently use audio.
---
## Phase 5: Advanced / Optional
### 5.1 Multi-User Concurrency
**Priority: MEDIUM**
Currently each contact gets their own session, and prompts run as async tasks.
But there's no protection against a user sending a second message while the
first is still processing. Options:
- Queue messages per-user (process sequentially)
- Allow concurrent prompts (may confuse Goose session state)
- Tell the user "please wait" if a prompt is in progress
### 5.2 Group Messaging
**Priority: LOW**
Signal groups could map to shared Goose sessions. Would need the account
number (already in config) for group message handling. signal-cli has
different envelope structure for group messages.
### 5.3 Extension Methods
**Priority: LOW**
`ext_method` and `ext_notification` are catch-alls for custom protocol
extensions. Log them for now; implement specific ones if Goose uses them.
### 5.4 MCP Server Configuration
**Priority: LOW**
`new_session` accepts `mcp_servers` to connect external tool servers. Could
be configured per-contact or globally in honker config.
---
## Decisions Made
1. **Terminal execution**: Permission gate is enough. No additional sandboxing.
2. **Tool call notifications**: Light — send "🔧 Running: ..." on tool start.
Goose already keeps extensive logs at `~/.local/state/goose/logs/`.
3. **Agent thoughts**: Opt-in via config flag (`forward_thoughts: true`).
4. **Cancellation UX**: `/cancel` plus general `/command` prefix convention.
5. **Multi-user concurrency**: "Please wait" — reject concurrent prompts per user.
6. **Image attachments**: Yes, wire up in Phase 1.
7. **Working directory**: Global config option (`working_directory`).
+1
View File
@@ -8,6 +8,7 @@ authors = [
] ]
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"agent-client-protocol>=0.10.0",
"pyyaml>=6.0", "pyyaml>=6.0",
] ]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+243
View File
@@ -0,0 +1,243 @@
"""Bridge — ties Signal and Goose together."""
import asyncio
import base64
import logging
from pathlib import Path
from honker.config import Config
from honker.goose import GooseConnection
from honker.signal import SignalClient
log = logging.getLogger(__name__)
# Signal has a message size limit; we split long responses
MAX_SIGNAL_MESSAGE_LENGTH = 4096
# How long to wait for a permission reply before timing out
PERMISSION_TIMEOUT_SECONDS = 120
def _split_message(text: str, max_len: int = MAX_SIGNAL_MESSAGE_LENGTH) -> list[str]:
"""Split a long message into chunks, breaking at newlines where possible."""
if len(text) <= max_len:
return [text]
chunks = []
while text:
if len(text) <= max_len:
chunks.append(text)
break
# Try to break at a newline
split_at = text.rfind("\n", 0, max_len)
if split_at == -1:
split_at = max_len
chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n")
return chunks
class Bridge:
"""Orchestrates message flow between Signal and Goose."""
def __init__(self, config: Config, signal: SignalClient, goose: GooseConnection):
self.config = config
self.signal = signal
self.goose = goose
# Pending interactive questions: source phone -> Future[str]
self._pending_replies: dict[str, asyncio.Future[str]] = {}
# Track which users have a prompt in progress
self._active_prompts: set[str] = set()
# Wire up callbacks so GooseClient can communicate with Signal users
self.goose.client.set_ask_user(self._ask_user)
self.goose.client.set_notify_user(self._notify_user)
async def _send(self, source: str, text: str) -> None:
"""Send a message to a Signal user, splitting if needed."""
for chunk in _split_message(text):
await self.signal.send_message(source, chunk)
async def _notify_user(self, session_id: str, message: str) -> None:
"""Send a status notification to the Signal user for a session."""
source = self.goose.session_key_for_id(session_id)
if source:
await self._send(source, message)
async def _ask_user(
self, session_id: str, question: str, option_labels: list[str]
) -> str:
"""Send a question to the Signal user and wait for their reply."""
source = self.goose.session_key_for_id(session_id)
if source is None:
raise RuntimeError(f"No contact found for session {session_id}")
contact = self.config.contact_by_number(source)
contact_name = contact.name if contact else source
log.info("Asking %s for permission [%s]", contact_name, session_id[:8])
await self._send(source, question)
# Create a future and wait for the user's reply
future: asyncio.Future[str] = asyncio.get_event_loop().create_future()
self._pending_replies[source] = future
try:
reply = await asyncio.wait_for(future, timeout=PERMISSION_TIMEOUT_SECONDS)
log.info("Got permission reply from %s: %s", contact_name, reply[:80])
return reply
except asyncio.TimeoutError:
log.warning("Permission request timed out for %s [%s]", contact_name, session_id[:8])
await self._send(source, "⏰ Permission request timed out — denying by default.")
raise
finally:
self._pending_replies.pop(source, None)
async def run(self) -> None:
"""Main loop: read Signal messages, forward to Goose, send replies."""
log.info("Bridge running — waiting for messages from trusted contacts")
async for incoming in self.signal.receive_messages():
source = incoming["source"]
message = incoming.get("message", "")
attachments = incoming.get("attachments", [])
# Check trust
if not self.config.is_trusted(source):
log.warning("Ignoring message from untrusted number: %s", source)
continue
contact = self.config.contact_by_number(source)
contact_name = contact.name if contact else source
# If there's a pending permission question for this user,
# route the reply there instead of creating a new prompt
if source in self._pending_replies:
log.info(
"Routing reply from %s to pending permission request: %s",
contact_name,
message[:80],
)
self._pending_replies[source].set_result(message)
continue
# Check for slash commands
if message.startswith("/"):
await self._handle_command(source, contact_name, message)
continue
# Reject if a prompt is already in progress
if source in self._active_prompts:
log.info("Rejecting message from %s — prompt in progress", contact_name)
await self._send(source, "⏳ Please wait — I'm still working on your previous message.")
continue
log.info("Message from %s (%s): %s", contact_name, source, message[:80])
# Process the prompt in the background
asyncio.create_task(
self._handle_prompt(source, contact_name, message, attachments),
name=f"prompt-{source}",
)
async def _handle_command(self, source: str, contact_name: str, message: str) -> None:
"""Handle a slash command from a Signal user."""
parts = message.strip().split(maxsplit=1)
command = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
log.info("Command from %s: %s", contact_name, command)
if command == "/cancel":
if source in self._active_prompts:
cancelled = await self.goose.cancel(source)
if cancelled:
await self._send(source, "🛑 Cancelling current request…")
else:
await self._send(source, "No active request to cancel.")
else:
await self._send(source, "No active request to cancel.")
elif command == "/new":
# Close existing session and start fresh
await self.goose.close_session(source)
await self._send(source, "🆕 Starting a new session.")
elif command == "/help":
help_text = (
"Available commands:\n"
"/cancel — Cancel the current request\n"
"/new — Start a new session\n"
"/help — Show this help message"
)
await self._send(source, help_text)
else:
await self._send(source, f"Unknown command: {command}\nType /help for available commands.")
async def _handle_prompt(
self,
source: str,
contact_name: str,
message: str,
attachments: list[dict],
) -> None:
"""Handle a single prompt from a Signal user."""
self._active_prompts.add(source)
try:
session_id = await self.goose.get_or_create_session(source)
# Check for image attachments
image_data, mime_type = await self._extract_image(attachments)
if image_data:
response_text = await self.goose.prompt_with_image(
session_id, message or None, image_data, mime_type
)
else:
if not message:
return # No text and no image, nothing to do
response_text = await self.goose.prompt(session_id, message)
if not response_text.strip():
response_text = "(Goose returned an empty response)"
await self._send(source, response_text)
log.info("Sent reply to %s (%d chars)", contact_name, len(response_text))
except Exception:
log.exception("Error processing message from %s", contact_name)
try:
await self._send(
source, "⚠️ Sorry, something went wrong processing your message."
)
except Exception:
log.exception("Failed to send error message to %s", contact_name)
finally:
self._active_prompts.discard(source)
async def _extract_image(self, attachments: list[dict]) -> tuple[str | None, str | None]:
"""Extract the first image attachment as base64 data.
Returns (base64_data, mime_type) or (None, None).
"""
for att in attachments:
content_type = att.get("contentType", "")
if not content_type.startswith("image/"):
continue
# signal-cli stores attachments locally; the path is in the "file" field
file_path = att.get("file")
if file_path and Path(file_path).is_file():
log.info("Reading image attachment: %s (%s)", file_path, content_type)
data = Path(file_path).read_bytes()
return base64.b64encode(data).decode("ascii"), content_type
# Some versions use "id" - try fetching via signal-cli's attachment path
att_id = att.get("id")
if att_id:
log.info("Attachment has id %s but no file path; skipping", att_id)
return None, None
+54 -4
View File
@@ -1,4 +1,4 @@
import os from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import yaml import yaml
@@ -7,12 +7,62 @@ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "honker" / "config.yaml"
DEFAULT_SIGNAL_SOCKET_PATH = Path.home() / ".local" / "share" / "honker" / "signal-cli.sock" DEFAULT_SIGNAL_SOCKET_PATH = Path.home() / ".local" / "share" / "honker" / "signal-cli.sock"
def load_config(path: Path | None = None) -> dict: @dataclass
class Contact:
name: str
number: str
@dataclass
class Config:
account: str
trusted_contacts: list[Contact] = field(default_factory=list)
working_directory: str = "."
forward_thoughts: bool = False
auto_approve_tools: bool = False
def is_trusted(self, number: str) -> bool:
"""Check whether a phone number belongs to a trusted contact."""
return any(c.number == number for c in self.trusted_contacts)
def contact_by_number(self, number: str) -> Contact | None:
"""Look up a trusted contact by phone number."""
return next((c for c in self.trusted_contacts if c.number == number), None)
def load_config(path: Path | None = None) -> Config:
"""Load configuration from ~/.config/honker/config.yaml""" """Load configuration from ~/.config/honker/config.yaml"""
config_path = path or DEFAULT_CONFIG_PATH config_path = path or DEFAULT_CONFIG_PATH
if not config_path.exists(): if not config_path.exists():
return {} raise FileNotFoundError(
f"Config file not found: {config_path}\n"
"Create it with at least:\n"
" account: +1234567890\n"
" trusted_contacts:\n"
' - name: "Alice"\n'
" number: +1234567890"
)
with open(config_path) as f: with open(config_path) as f:
return yaml.safe_load(f) or {} raw = yaml.safe_load(f) or {}
if "account" not in raw:
raise ValueError("Config is missing required field: account")
contacts = [
Contact(name=c["name"], number=c["number"])
for c in raw.get("trusted_contacts", [])
]
working_directory = raw.get("working_directory", ".")
forward_thoughts = raw.get("forward_thoughts", False)
auto_approve_tools = raw.get("auto_approve_tools", False)
return Config(
account=raw["account"],
trusted_contacts=contacts,
working_directory=working_directory,
forward_thoughts=forward_thoughts,
auto_approve_tools=auto_approve_tools,
)
+442
View File
@@ -0,0 +1,442 @@
"""Goose ACP client — manages the connection and sessions."""
import asyncio
import logging
import os
from typing import Any, Awaitable, Callable
from acp import (
Agent,
Client,
spawn_agent_process,
PROTOCOL_VERSION,
RequestPermissionRequest,
RequestPermissionResponse,
)
from acp.schema import (
AgentMessageChunk,
AgentThoughtChunk,
AllowedOutcome,
ClientCapabilities,
DeniedOutcome,
Implementation,
PermissionOption,
PermissionOptionKind,
TextContentBlock,
ToolCallStart,
ToolCallProgress,
ToolCallUpdate,
)
log = logging.getLogger(__name__)
# Type for the callback the bridge provides: (session_id, question, options) -> answer
AskUserFunc = Callable[[str, str, list[str]], Awaitable[str]]
# Type for sending a notification to the user: (session_id, message) -> None
NotifyUserFunc = Callable[[str, str], Awaitable[None]]
def _format_tool_call(tool_call: ToolCallUpdate) -> str:
"""Format a tool call into a human-readable description."""
parts = []
if tool_call.title:
parts.append(tool_call.title)
if tool_call.raw_input:
raw = str(tool_call.raw_input)
if len(raw) > 200:
raw = raw[:200] + ""
parts.append(f"Input: {raw}")
return "\n".join(parts) if parts else "(unknown tool call)"
def _format_permission_message(
tool_call: ToolCallUpdate, options: list[PermissionOption]
) -> tuple[str, list[str]]:
"""Build the question text and option labels for a permission request."""
question_parts = [
"🔐 *Permission requested*",
"",
_format_tool_call(tool_call),
"",
]
option_labels = []
for i, opt in enumerate(options, 1):
label = f"{i}. {opt.name}"
option_labels.append(label)
question_parts.append("\n".join(option_labels))
question_parts.append("")
question_parts.append("Reply with a number to choose:")
return "\n".join(question_parts), [opt.name for opt in options]
class GooseClient(Client):
"""ACP Client implementation that handles agent requests and notifications.
Goose runs locally and uses its own built-in tools for file I/O and shell
execution, so we don't advertise client-side fs or terminal capabilities.
The client-side methods exist only as no-op stubs required by the protocol.
"""
# Flush accumulated thoughts after this many seconds of quiet
THOUGHT_FLUSH_DELAY = 3.0
# Flush immediately if accumulated thought text exceeds this length
THOUGHT_FLUSH_SIZE = 1000
def __init__(self, forward_thoughts: bool = False, auto_approve_tools: bool = False) -> None:
self._response_chunks: dict[str, list[str]] = {} # session_id -> chunks
self._forward_thoughts = forward_thoughts
self._auto_approve_tools = auto_approve_tools
self._ask_user: AskUserFunc | None = None
self._notify_user: NotifyUserFunc | None = None
# Thought batching state per session
self._thought_buf: dict[str, list[str]] = {}
self._thought_flush_tasks: dict[str, asyncio.Task] = {}
def set_ask_user(self, func: AskUserFunc) -> None:
"""Set the callback used to ask the Signal user a question."""
self._ask_user = func
def set_notify_user(self, func: NotifyUserFunc) -> None:
"""Set the callback used to send status notifications to the Signal user."""
self._notify_user = func
def take_response(self, session_id: str) -> str:
"""Take the accumulated response text for a session, clearing the buffer."""
chunks = self._response_chunks.pop(session_id, [])
return "".join(chunks)
def _accumulate_thought(self, session_id: str, text: str) -> None:
"""Add thought text to the buffer and schedule a flush."""
self._thought_buf.setdefault(session_id, []).append(text)
# Cancel any existing flush timer and restart it
if session_id in self._thought_flush_tasks:
self._thought_flush_tasks[session_id].cancel()
# Flush immediately if buffer is large enough
buf_text = "".join(self._thought_buf.get(session_id, []))
if len(buf_text) >= self.THOUGHT_FLUSH_SIZE:
self._thought_flush_tasks[session_id] = asyncio.create_task(
self._flush_thoughts(session_id)
)
else:
# Otherwise schedule a delayed flush
self._thought_flush_tasks[session_id] = asyncio.create_task(
self._delayed_flush(session_id)
)
async def _delayed_flush(self, session_id: str) -> None:
"""Wait for the debounce delay, then flush thoughts."""
await asyncio.sleep(self.THOUGHT_FLUSH_DELAY)
await self._flush_thoughts(session_id)
async def _flush_thoughts(self, session_id: str) -> None:
"""Send any accumulated thought text to the user."""
# Cancel the timer if one is running
task = self._thought_flush_tasks.pop(session_id, None)
if task is not None and not task.done():
task.cancel()
chunks = self._thought_buf.pop(session_id, [])
if not chunks or self._notify_user is None:
return
text = "".join(chunks).strip()
if text:
await self._notify_user(session_id, f"💭 {text}")
# -- Session update notifications --
async def session_update(self, session_id: str, update, **kwargs: Any) -> None:
if isinstance(update, AgentMessageChunk):
content = update.content
if isinstance(content, TextContentBlock):
self._response_chunks.setdefault(session_id, []).append(content.text)
log.debug("Agent chunk [%s]: %s", session_id[:8], content.text[:80])
elif isinstance(update, AgentThoughtChunk):
content = update.content
if isinstance(content, TextContentBlock):
log.debug("Agent thought [%s]: %s", session_id[:8], content.text[:80])
if self._forward_thoughts and self._notify_user:
self._accumulate_thought(session_id, content.text)
elif isinstance(update, ToolCallStart):
# Flush any pending thoughts before reporting the tool call
await self._flush_thoughts(session_id)
title = update.title or "(unknown)"
log.info("Tool call started [%s]: %s", session_id[:8], title)
if self._notify_user:
await self._notify_user(session_id, f"🔧 {title}")
elif isinstance(update, ToolCallProgress):
log.debug("Tool call progress [%s]", session_id[:8])
# -- Permission requests --
async def request_permission(
self,
options: list[PermissionOption],
session_id: str,
tool_call: ToolCallUpdate,
**kwargs: Any,
) -> RequestPermissionResponse:
"""Ask the Signal user for permission, or auto-approve if configured."""
if self._auto_approve_tools:
# Find the most permissive allow option
allow_option = None
for opt in options:
if opt.kind == PermissionOptionKind.ALWAYS_ALLOW:
allow_option = opt
break
elif opt.kind == PermissionOptionKind.ALLOW:
allow_option = opt
if allow_option is None:
allow_option = options[0]
log.info(
"Auto-approving tool [%s]: %s (option: %s)",
session_id[:8],
tool_call.title or "(unknown)",
allow_option.name,
)
return RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", option_id=allow_option.option_id
)
)
question, option_labels = _format_permission_message(tool_call, options)
if self._ask_user is not None:
try:
reply = await self._ask_user(session_id, question, option_labels)
chosen = _parse_permission_reply(reply, options)
if chosen is not None:
log.info(
"User chose [%s]: %s (option: %s)",
session_id[:8],
chosen.kind,
chosen.name,
)
return RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", option_id=chosen.option_id
)
)
else:
log.warning(
"Could not parse permission reply [%s]: %r", session_id[:8], reply
)
except Exception:
log.exception("Error asking user for permission [%s]", session_id[:8])
# Fallback: deny if we couldn't get a valid answer
log.info("Denying permission request [%s] (no valid user response)", session_id[:8])
return RequestPermissionResponse(
outcome=DeniedOutcome(outcome="cancelled")
)
# -- Client-side stubs (Goose uses its own built-in tools instead) --
async def read_text_file(self, path, session_id, **kwargs):
log.debug("read_text_file called (unused) [%s]: %s", session_id[:8], path)
return None
async def write_text_file(self, content, path, session_id, **kwargs):
log.debug("write_text_file called (unused) [%s]: %s", session_id[:8], path)
return None
async def create_terminal(self, command, session_id, **kwargs):
log.debug("create_terminal called (unused) [%s]: %s", session_id[:8], command)
return None
async def terminal_output(self, session_id, terminal_id, **kwargs):
return None
async def release_terminal(self, session_id, terminal_id, **kwargs):
return None
async def wait_for_terminal_exit(self, session_id, terminal_id, **kwargs):
return None
async def kill_terminal(self, session_id, terminal_id, **kwargs):
return None
async def ext_method(self, method, params):
log.debug("ext_method: %s", method)
return {}
async def ext_notification(self, method, params):
log.debug("ext_notification: %s", method)
def on_connect(self, conn: Agent) -> None:
pass
def _parse_permission_reply(
reply: str, options: list[PermissionOption]
) -> PermissionOption | None:
"""Parse a user's reply to a permission prompt.
Accepts:
- A number (1-based index into the options list)
- The option name (case-insensitive, partial match)
"""
reply = reply.strip()
# Try as a number
try:
idx = int(reply) - 1
if 0 <= idx < len(options):
return options[idx]
except ValueError:
pass
# Try as a name match
reply_lower = reply.lower()
for opt in options:
if reply_lower in opt.name.lower():
return opt
return None
class GooseConnection:
"""Manages the ACP connection to a Goose agent process."""
def __init__(self, cwd: str = ".", forward_thoughts: bool = False, auto_approve_tools: bool = False):
self.cwd = cwd
self._auto_approve_tools = auto_approve_tools
self.client = GooseClient(
forward_thoughts=forward_thoughts,
auto_approve_tools=auto_approve_tools,
)
self._conn = None
self._process = None
self._ctx = None
self._sessions: dict[str, str] = {} # key -> session_id
def session_key_for_id(self, session_id: str) -> str | None:
"""Look up the key (phone number) for a session ID."""
for key, sid in self._sessions.items():
if sid == session_id:
return key
return None
async def start(self) -> None:
"""Spawn goose and initialize the ACP connection."""
self._ctx = spawn_agent_process(self.client, "goose", "acp", env=os.environ.copy())
self._conn, self._process = await self._ctx.__aenter__()
# Initialize — don't advertise fs or terminal capabilities since
# Goose runs locally and uses its own built-in tools
resp = await self._conn.initialize(
protocol_version=PROTOCOL_VERSION,
client_info=Implementation(name="honker", version="0.1.0"),
client_capabilities=ClientCapabilities(),
)
log.info(
"ACP initialized (protocol_version=%s, agent=%s)",
resp.protocol_version,
resp.agent_info,
)
log.debug("ACP full init response: %s", resp)
async def stop(self) -> None:
"""Shut down the ACP connection and goose process."""
if self._ctx is not None:
await self._ctx.__aexit__(None, None, None)
self._ctx = None
async def get_or_create_session(self, key: str) -> str:
"""Get an existing session or create a new one for the given key."""
if key in self._sessions:
return self._sessions[key]
resp = await self._conn.new_session(cwd=self.cwd)
session_id = resp.session_id
self._sessions[key] = session_id
log.info("Created new Goose session for %s: %s", key, session_id[:8])
# Set the approval mode based on config
mode = "auto" if self._auto_approve_tools else "approve"
try:
await self._conn.set_session_mode(mode_id=mode, session_id=session_id)
log.info("Set session mode to '%s' for %s [%s]", mode, key, session_id[:8])
except Exception:
log.exception("Failed to set session mode for %s", session_id[:8])
return session_id
async def close_session(self, key: str) -> bool:
"""Close and remove the session for the given key. Returns True if found."""
session_id = self._sessions.pop(key, None)
if session_id is None:
return False
try:
await self._conn.close_session(session_id=session_id)
except Exception:
log.exception("Error closing session %s", session_id[:8])
log.info("Closed session for %s: %s", key, session_id[:8])
return True
async def cancel(self, key: str) -> bool:
"""Cancel the current prompt for the given key. Returns True if found."""
session_id = self._sessions.get(key)
if session_id is None:
return False
try:
await self._conn.cancel(session_id=session_id)
log.info("Cancelled prompt for %s [%s]", key, session_id[:8])
except Exception:
log.exception("Error cancelling session %s", session_id[:8])
return True
async def prompt(self, session_id: str, text: str) -> str:
"""Send a prompt and wait for the complete response."""
self.client._response_chunks.pop(session_id, None)
prompt_content = [TextContentBlock(type="text", text=text)]
response = await self._conn.prompt(prompt=prompt_content, session_id=session_id)
# Flush any remaining thoughts
await self.client._flush_thoughts(session_id)
log.info("Prompt complete [%s]: stop_reason=%s", session_id[:8], response.stop_reason)
text_response = self.client.take_response(session_id)
# Append stop reason context if not a normal end
stop_reason = response.stop_reason
if stop_reason == "max_tokens":
text_response += "\n\n⚠️ Response was cut short — context limit reached."
elif stop_reason == "max_turn_requests":
text_response += "\n\n⚠️ Response was cut short — turn limit reached."
elif stop_reason == "refusal":
text_response += "\n\n🚫 The agent refused this request."
elif stop_reason == "cancelled":
text_response += "\n\n🛑 Request was cancelled."
return text_response
async def prompt_with_image(self, session_id: str, text: str | None, image_data: str, mime_type: str) -> str:
"""Send a prompt with an image and wait for the complete response."""
from acp.schema import ImageContentBlock
self.client._response_chunks.pop(session_id, None)
prompt_content = []
if text:
prompt_content.append(TextContentBlock(type="text", text=text))
prompt_content.append(ImageContentBlock(type="image", data=image_data, mime_type=mime_type))
response = await self._conn.prompt(prompt=prompt_content, session_id=session_id)
# Flush any remaining thoughts
await self.client._flush_thoughts(session_id)
log.info("Prompt (with image) complete [%s]: stop_reason=%s", session_id[:8], response.stop_reason)
return self.client.take_response(session_id)
+65 -32
View File
@@ -1,18 +1,29 @@
import argparse import argparse
import asyncio
import logging import logging
import os
import signal import signal
import sys import sys
import time
from pathlib import Path from pathlib import Path
from honker.config import load_config from honker.bridge import Bridge
from honker.processes import start_goose, start_signal_cli from honker.config import DEFAULT_SIGNAL_SOCKET_PATH, load_config
from honker.goose import GooseConnection
from honker.processes import start_signal_cli
from honker.signal import SignalClient
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Honker Signal ↔ Goose ACP bridge") parser = argparse.ArgumentParser(description="Honker Signal ↔ Goose ACP bridge")
parser.add_argument(
"--config",
metavar="FILE",
type=Path,
default=None,
help="Path to config file (default: ~/.config/honker/config.yaml)",
)
parser.add_argument( parser.add_argument(
"--debug", "--debug",
metavar="DIR", metavar="DIR",
@@ -23,6 +34,50 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args() return parser.parse_args()
async def run(args: argparse.Namespace) -> None:
config = load_config(path=args.config)
log.info(
"Honker starting up (account=%s, %d trusted contacts)",
config.account,
len(config.trusted_contacts),
)
# Start signal-cli daemon
socket_path = DEFAULT_SIGNAL_SOCKET_PATH
signal_proc = start_signal_cli(socket_path=socket_path, account=config.account, debug_dir=args.debug)
# Connect to signal-cli
signal_client = SignalClient(socket_path, config.account)
await signal_client.connect()
# Start Goose via ACP
goose = GooseConnection(
cwd=config.working_directory,
forward_thoughts=config.forward_thoughts,
auto_approve_tools=config.auto_approve_tools,
)
await goose.start()
log.info("Goose ACP connection established (cwd=%s)", config.working_directory)
# Run the bridge; cancel on SIGINT/SIGTERM
bridge = Bridge(config, signal_client, goose)
bridge_task = asyncio.create_task(bridge.run())
loop = asyncio.get_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, bridge_task.cancel)
try:
await bridge_task
except asyncio.CancelledError:
log.info("Bridge cancelled, shutting down…")
finally:
await signal_client.close()
await goose.stop()
signal_proc.stop()
log.info("Honker stopped")
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
@@ -31,39 +86,17 @@ def main() -> None:
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
) )
config = load_config()
log.info("Honker starting up")
if args.debug: if args.debug:
log.debug("Debug mode enabled — process logs will be written to %s", args.debug) log.debug("Debug mode enabled — process logs will be written to %s", args.debug)
goose = start_goose(debug_dir=args.debug) if "DBUS_SESSION_BUS_ADDRESS" not in os.environ:
signal_cli = start_signal_cli(debug_dir=args.debug) log.warning(
"DBUS_SESSION_BUS_ADDRESS is not set — Goose may not be able to "
"access its keyring for API keys. If you see authentication errors, "
"ensure you're running in a session with D-Bus available."
)
processes = [goose, signal_cli] asyncio.run(run(args))
def shutdown(signum, frame):
log.info("Received signal %s, shutting down…", signum)
for proc in reversed(processes):
proc.stop()
sys.exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
# Keep running and watch for unexpected exits
try:
while True:
for proc in processes:
if not proc.is_running:
log.error("%s exited unexpectedly (rc=%s)", proc.name, proc.process.returncode)
# Shut everything down if a child dies
for p in reversed(processes):
p.stop()
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
shutdown(signal.SIGINT, None)
if __name__ == "__main__": if __name__ == "__main__":
+6 -8
View File
@@ -76,19 +76,17 @@ class ManagedProcess:
return self.process is not None and self.process.poll() is None return self.process is not None and self.process.poll() is None
def start_goose(debug_dir: Path | None = None) -> ManagedProcess: def start_signal_cli(socket_path: Path | None = None, account: str | None = None, debug_dir: Path | None = None) -> ManagedProcess:
proc = ManagedProcess("goose", ["goose", "serve"], debug_dir=debug_dir)
proc.start()
return proc
def start_signal_cli(socket_path: Path | None = None, debug_dir: Path | None = None) -> ManagedProcess:
from honker.config import DEFAULT_SIGNAL_SOCKET_PATH from honker.config import DEFAULT_SIGNAL_SOCKET_PATH
sock = socket_path or DEFAULT_SIGNAL_SOCKET_PATH sock = socket_path or DEFAULT_SIGNAL_SOCKET_PATH
sock.parent.mkdir(parents=True, exist_ok=True) sock.parent.mkdir(parents=True, exist_ok=True)
args = ["signal-cli"]
if account:
args.extend(["-a", account])
args.extend(["daemon", "--socket", str(sock), "--no-receive-stdout"])
proc = ManagedProcess( proc = ManagedProcess(
"signal-cli", "signal-cli",
["signal-cli", "daemon", "--socket", str(sock)], args,
debug_dir=debug_dir, debug_dir=debug_dir,
) )
proc.start() proc.start()
+163
View File
@@ -0,0 +1,163 @@
"""Signal-cli JSON-RPC client over UNIX socket."""
import asyncio
import json
import logging
from collections.abc import AsyncIterator
from pathlib import Path
log = logging.getLogger(__name__)
class SignalClient:
"""Communicates with signal-cli daemon over its UNIX socket JSON-RPC interface.
Uses a single socket connection with a centralized reader task that
dispatches JSON-RPC responses to waiting request futures and pushes
notifications into a queue for receive_messages() to consume.
"""
def __init__(self, socket_path: Path, account: str):
self.socket_path = socket_path
self.account = account
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._request_id = 0
self._pending_requests: dict[int, asyncio.Future[dict]] = {}
self._incoming_queue: asyncio.Queue[dict] = asyncio.Queue()
self._reader_task: asyncio.Task | None = None
async def connect(self) -> None:
"""Connect to the signal-cli UNIX socket, retrying until available."""
for attempt in range(30):
if self.socket_path.exists():
try:
self._reader, self._writer = await asyncio.open_unix_connection(
str(self.socket_path)
)
log.info("Connected to signal-cli socket at %s", self.socket_path)
self._reader_task = asyncio.create_task(
self._read_loop(), name="signal-reader"
)
return
except (ConnectionRefusedError, OSError) as e:
log.debug("Connection attempt %d failed: %s", attempt + 1, e)
else:
log.debug("Socket not yet available (attempt %d)", attempt + 1)
await asyncio.sleep(1)
raise ConnectionError(
f"Could not connect to signal-cli socket at {self.socket_path} after 30 attempts"
)
async def _read_loop(self) -> None:
"""Centralized reader: dispatch responses and queue notifications."""
assert self._reader is not None
try:
while True:
raw = await self._reader.readline()
if not raw:
log.warning("signal-cli socket closed")
break
log.debug("signal-cli <<< %s", raw.decode().strip())
try:
msg = json.loads(raw)
except json.JSONDecodeError:
log.warning("Unparseable line from signal-cli: %s", raw)
continue
# JSON-RPC response — resolve the waiting future
if "id" in msg and msg["id"] in self._pending_requests:
future = self._pending_requests.pop(msg["id"])
if not future.done():
future.set_result(msg)
continue
# JSON-RPC notification — parse and enqueue
params = msg.get("params", {})
envelope = params.get("envelope", {})
if not envelope:
continue
source = envelope.get("source")
data_message = envelope.get("dataMessage")
if source and data_message:
message_text = data_message.get("message", "")
attachments = data_message.get("attachments", [])
# Only enqueue if there's text or attachments
if message_text or attachments:
await self._incoming_queue.put({
"source": source,
"message": message_text,
"timestamp": data_message.get("timestamp"),
"attachments": attachments,
})
except asyncio.CancelledError:
pass
except Exception:
log.exception("signal-cli reader loop crashed")
finally:
# Fail any pending requests
for future in self._pending_requests.values():
if not future.done():
future.set_exception(
ConnectionError("signal-cli reader loop stopped")
)
self._pending_requests.clear()
async def close(self) -> None:
if self._reader_task is not None:
self._reader_task.cancel()
try:
await self._reader_task
except asyncio.CancelledError:
pass
self._reader_task = None
if self._writer is not None:
self._writer.close()
await self._writer.wait_closed()
self._writer = None
self._reader = None
def _next_id(self) -> int:
self._request_id += 1
return self._request_id
async def _send_request(self, method: str, params: dict) -> dict:
"""Send a JSON-RPC request and return the result."""
assert self._writer is not None
req_id = self._next_id()
request = {
"jsonrpc": "2.0",
"method": method,
"id": req_id,
"params": params,
}
line = json.dumps(request) + "\n"
log.debug("signal-cli >>> %s", line.strip())
future: asyncio.Future[dict] = asyncio.get_event_loop().create_future()
self._pending_requests[req_id] = future
self._writer.write(line.encode())
await self._writer.drain()
msg = await future
if "error" in msg:
raise RuntimeError(f"signal-cli error: {msg['error']}")
return msg.get("result", {})
async def send_message(self, recipient: str, message: str) -> dict:
"""Send a text message to a recipient."""
return await self._send_request("send", {
"account": self.account,
"recipient": [recipient],
"message": message,
})
async def receive_messages(self) -> AsyncIterator[dict]:
"""Yield incoming messages from the queue."""
while True:
msg = await self._incoming_queue.get()
yield msg
Generated
+137 -1
View File
@@ -2,16 +2,131 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[[package]]
name = "agent-client-protocol"
version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/5c/d60196c536c8f66bb6a238c8a6d0d6fa84a2e3436008c139cb4a79003a25/agent_client_protocol-0.10.0.tar.gz", hash = "sha256:f8a6041e27423131e42e4d0cdd850d5b094b1092f79cc29501ab2bd57b92ee88", size = 81502, upload-time = "2026-05-07T19:11:56.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/cc/139895c0c5cd2acefc365085da0735d93df0cf8427ff4ee351961090e1af/agent_client_protocol-0.10.0-py3-none-any.whl", hash = "sha256:97a1a69c5d094e2625b09c4dbc2d5cf19637c4943630ceed52ab0578ecd5e14c", size = 65400, upload-time = "2026-05-07T19:11:55.614Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]] [[package]]
name = "honker" name = "honker"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" },
{ name = "pyyaml" }, { name = "pyyaml" },
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }] requires-dist = [
{ name = "agent-client-protocol", specifier = ">=0.10.0" },
{ name = "pyyaml", specifier = ">=6.0" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
@@ -58,3 +173,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]