Compare commits

..

8 Commits

Author SHA1 Message Date
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
15 changed files with 1391 additions and 46 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
+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"
dependencies = [
"agent-client-protocol>=0.10.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
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"
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"""
config_path = path or DEFAULT_CONFIG_PATH
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:
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 asyncio
import logging
import os
import signal
import sys
import time
from pathlib import Path
from honker.config import load_config
from honker.processes import start_goose, start_signal_cli
from honker.bridge import Bridge
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__)
def parse_args() -> argparse.Namespace:
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(
"--debug",
metavar="DIR",
@@ -23,6 +34,50 @@ def parse_args() -> argparse.Namespace:
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:
args = parse_args()
@@ -31,39 +86,17 @@ def main() -> None:
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
config = load_config()
log.info("Honker starting up")
if args.debug:
log.debug("Debug mode enabled — process logs will be written to %s", args.debug)
goose = start_goose(debug_dir=args.debug)
signal_cli = start_signal_cli(debug_dir=args.debug)
if "DBUS_SESSION_BUS_ADDRESS" not in os.environ:
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]
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)
asyncio.run(run(args))
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
def start_goose(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:
def start_signal_cli(socket_path: Path | None = None, account: str | None = None, debug_dir: Path | None = None) -> ManagedProcess:
from honker.config import DEFAULT_SIGNAL_SOCKET_PATH
sock = socket_path or DEFAULT_SIGNAL_SOCKET_PATH
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(
"signal-cli",
["signal-cli", "daemon", "--socket", str(sock)],
args,
debug_dir=debug_dir,
)
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
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]]
name = "honker"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
{ name = "pyyaml" },
]
[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]]
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/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" },
]