ask-human 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ask_human/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Ask Human MCP server."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ from .server import main
6
+
7
+ __all__ = ["main"]
ask_human/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for running Ask Human as a module."""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
Binary file
Binary file
Binary file
@@ -0,0 +1,215 @@
1
+ """Persistent state helpers for the local Telegram broker."""
2
+
3
+ import contextlib
4
+ import os
5
+ import platform
6
+ import socket
7
+ import sqlite3
8
+ import time
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Iterator, Optional
13
+
14
+ from .telegram_models import TelegramConfig, resolve_telegram_target_key
15
+
16
+ DEFAULT_BROKER_STATE_DIR_NAME = "ask-human"
17
+ BROKER_STATE_DB_FILENAME = "telegram-broker.sqlite3"
18
+ BROKER_STARTUP_LOCK_FILENAME = "broker-startup.lock"
19
+ BROKER_TARGETS_DIRNAME = "targets"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class TelegramBrokerIdentity:
24
+ """Stable identity and human-facing label for one broker installation."""
25
+
26
+ broker_id: str
27
+ broker_label: str
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TelegramBrokerState:
32
+ """Persisted state that local clients can use for broker discovery."""
33
+
34
+ identity: TelegramBrokerIdentity
35
+ listen_url: Optional[str]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TelegramBrokerHealth:
40
+ """Health data returned by a running broker."""
41
+
42
+ broker_id: str
43
+ broker_label: str
44
+ listen_url: str
45
+ target_key: str
46
+
47
+
48
+ def resolve_broker_state_dir(state_dir: Optional[str] = None) -> Path:
49
+ """Resolve the broker state directory from CLI input or platform defaults."""
50
+ if state_dir is not None and state_dir.strip():
51
+ expanded = state_dir.replace("{cwd}", os.getcwd())
52
+ expanded = os.path.expandvars(expanded)
53
+ expanded = os.path.expanduser(expanded)
54
+ return Path(expanded).resolve()
55
+
56
+ system_name = platform.system()
57
+ if system_name == "Windows":
58
+ root = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
59
+ if root:
60
+ return (Path(root) / DEFAULT_BROKER_STATE_DIR_NAME).resolve()
61
+ return (Path.home() / "AppData" / "Local" / DEFAULT_BROKER_STATE_DIR_NAME).resolve()
62
+
63
+ if system_name == "Darwin":
64
+ return (
65
+ Path.home() / "Library" / "Application Support" / DEFAULT_BROKER_STATE_DIR_NAME
66
+ ).resolve()
67
+
68
+ root = os.environ.get("XDG_STATE_HOME")
69
+ if root:
70
+ return (Path(root) / DEFAULT_BROKER_STATE_DIR_NAME).resolve()
71
+ return (Path.home() / ".local" / "state" / DEFAULT_BROKER_STATE_DIR_NAME).resolve()
72
+
73
+
74
+ def resolve_default_broker_label() -> str:
75
+ """Choose a human-friendly default label for the local broker."""
76
+ hostname = socket.gethostname().strip()
77
+ return hostname or "local-broker"
78
+
79
+
80
+ def resolve_target_broker_state_dir(base_state_dir: Path, target: TelegramConfig) -> Path:
81
+ """Map one Telegram target to its dedicated local broker state directory."""
82
+ target_key = resolve_telegram_target_key(target)
83
+ return (base_state_dir / BROKER_TARGETS_DIRNAME / target_key).resolve()
84
+
85
+
86
+ def _ensure_state_db(state_dir: Path) -> sqlite3.Connection:
87
+ """Open the broker state database and initialize its schema if needed."""
88
+ state_dir.mkdir(parents=True, exist_ok=True)
89
+ db_path = state_dir / BROKER_STATE_DB_FILENAME
90
+ connection = sqlite3.connect(db_path)
91
+ connection.execute("""
92
+ CREATE TABLE IF NOT EXISTS broker_state (
93
+ key TEXT PRIMARY KEY,
94
+ value TEXT NOT NULL
95
+ )
96
+ """)
97
+ connection.commit()
98
+ return connection
99
+
100
+
101
+ def _get_state_value(connection: sqlite3.Connection, key: str) -> Optional[str]:
102
+ """Read a single broker state value by key."""
103
+ row = connection.execute(
104
+ "SELECT value FROM broker_state WHERE key = ?",
105
+ (key,),
106
+ ).fetchone()
107
+ if row is None:
108
+ return None
109
+ return str(row[0])
110
+
111
+
112
+ def _set_state_value(connection: sqlite3.Connection, key: str, value: str) -> None:
113
+ """Persist a single broker state value."""
114
+ connection.execute(
115
+ """
116
+ INSERT INTO broker_state (key, value)
117
+ VALUES (?, ?)
118
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
119
+ """,
120
+ (key, value),
121
+ )
122
+ connection.commit()
123
+
124
+
125
+ def load_or_create_broker_identity(
126
+ state_dir: Path,
127
+ broker_label: Optional[str] = None,
128
+ ) -> TelegramBrokerIdentity:
129
+ """Load a stable broker identity, creating and persisting one if absent."""
130
+ connection = _ensure_state_db(state_dir)
131
+ try:
132
+ broker_id = _get_state_value(connection, "broker_id")
133
+ stored_label = _get_state_value(connection, "broker_label")
134
+
135
+ if broker_id is None:
136
+ broker_id = uuid.uuid4().hex
137
+ _set_state_value(connection, "broker_id", broker_id)
138
+
139
+ resolved_label = broker_label.strip() if broker_label is not None else None
140
+ if not resolved_label:
141
+ resolved_label = stored_label or resolve_default_broker_label()
142
+
143
+ if stored_label != resolved_label:
144
+ _set_state_value(connection, "broker_label", resolved_label)
145
+
146
+ return TelegramBrokerIdentity(broker_id=broker_id, broker_label=resolved_label)
147
+ finally:
148
+ connection.close()
149
+
150
+
151
+ def persist_broker_listen_url(state_dir: Path, listen_url: str) -> None:
152
+ """Store the broker's current listening URL for local discovery."""
153
+ connection = _ensure_state_db(state_dir)
154
+ try:
155
+ _set_state_value(connection, "listen_url", listen_url)
156
+ finally:
157
+ connection.close()
158
+
159
+
160
+ def load_broker_state(state_dir: Path) -> Optional[TelegramBrokerState]:
161
+ """Read the persisted broker identity and listening URL, if available."""
162
+ connection = _ensure_state_db(state_dir)
163
+ try:
164
+ broker_id = _get_state_value(connection, "broker_id")
165
+ broker_label = _get_state_value(connection, "broker_label")
166
+ listen_url = _get_state_value(connection, "listen_url")
167
+ finally:
168
+ connection.close()
169
+
170
+ if broker_id is None or broker_label is None:
171
+ return None
172
+
173
+ identity = TelegramBrokerIdentity(broker_id=broker_id, broker_label=broker_label)
174
+ return TelegramBrokerState(identity=identity, listen_url=listen_url)
175
+
176
+
177
+ @contextlib.contextmanager
178
+ def acquire_startup_lock(
179
+ state_dir: Path,
180
+ *,
181
+ timeout_seconds: float = 15.0,
182
+ stale_after_seconds: float = 60.0,
183
+ ) -> Iterator[None]:
184
+ """Serialize local broker startup for one target with a simple lock file."""
185
+ state_dir.mkdir(parents=True, exist_ok=True)
186
+ lock_path = state_dir / BROKER_STARTUP_LOCK_FILENAME
187
+ deadline = time.monotonic() + timeout_seconds
188
+
189
+ while True:
190
+ try:
191
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
192
+ break
193
+ except FileExistsError:
194
+ try:
195
+ age_seconds = time.time() - lock_path.stat().st_mtime
196
+ except FileNotFoundError:
197
+ continue
198
+
199
+ if age_seconds >= stale_after_seconds:
200
+ with contextlib.suppress(FileNotFoundError):
201
+ lock_path.unlink()
202
+ continue
203
+
204
+ if time.monotonic() >= deadline:
205
+ raise TimeoutError("Timed out waiting for the broker startup lock.")
206
+
207
+ time.sleep(0.2)
208
+
209
+ try:
210
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
211
+ handle.write(str(os.getpid()))
212
+ yield
213
+ finally:
214
+ with contextlib.suppress(FileNotFoundError):
215
+ lock_path.unlink()
ask_human/dialogs.py ADDED
@@ -0,0 +1,311 @@
1
+ """Cross-platform GUI dialog handling for Ask Human prompts."""
2
+
3
+ import asyncio
4
+ import platform
5
+ from contextlib import suppress
6
+ from pathlib import Path
7
+ from typing import Any, Optional, cast
8
+
9
+ from .prompt_formatting import resolve_dialog_title
10
+
11
+ DEFAULT_DIALOG_TIMEOUT_SECONDS = 120
12
+ PACKAGE_ASSETS_DIR = Path(__file__).resolve().parent / "assets"
13
+
14
+
15
+ class UserPromptCancelled(Exception):
16
+ """Raised when user cancels the prompt or interrupts the process."""
17
+
18
+ pass
19
+
20
+
21
+ class UserPromptError(Exception):
22
+ """Generic error for user prompt operations."""
23
+
24
+ pass
25
+
26
+
27
+ class GUIDialogHandler:
28
+ """Cross-platform GUI dialog handler for asking humans for input.
29
+
30
+ Provides native GUI dialogs on macOS (osascript), Linux (zenity), and Windows (tkinter).
31
+ Falls back to terminal input if GUI is unavailable.
32
+ """
33
+
34
+ def __init__(self, dialog_title: Optional[str] = None) -> None:
35
+ """Initialize the dialog handler with platform detection."""
36
+ self.platform = platform.system()
37
+ self.dialog_title = resolve_dialog_title(dialog_title)
38
+
39
+ async def get_user_input(
40
+ self,
41
+ question: str,
42
+ timeout: int = DEFAULT_DIALOG_TIMEOUT_SECONDS,
43
+ *,
44
+ cancel_event: Optional[asyncio.Event] = None,
45
+ run_in_thread: bool = False,
46
+ ) -> Optional[str]:
47
+ """Get user input via native GUI dialog with timeout."""
48
+ try:
49
+ if self.platform == "Darwin":
50
+ return await self._macos_dialog(question, timeout, cancel_event=cancel_event)
51
+ if self.platform == "Linux":
52
+ return await self._linux_dialog(question, timeout, cancel_event=cancel_event)
53
+ return await self._windows_dialog(
54
+ question,
55
+ timeout,
56
+ run_in_thread=run_in_thread,
57
+ )
58
+ except KeyboardInterrupt:
59
+ raise UserPromptCancelled("User interrupted the dialog with Ctrl+C")
60
+ except Exception as exc:
61
+ raise UserPromptError(
62
+ f"GUI dialog failed: {exc}. Ensure osascript (macOS), zenity (Linux), or "
63
+ "tkinter (Windows) is available."
64
+ ) from exc
65
+
66
+ async def _communicate_or_cancel(
67
+ self,
68
+ process: asyncio.subprocess.Process,
69
+ cancel_event: Optional[asyncio.Event],
70
+ ) -> tuple[bytes, bytes, bool]:
71
+ """Wait for a dialog subprocess or cancel it if another channel wins."""
72
+ communicate_task = asyncio.create_task(process.communicate())
73
+ cancel_task: Optional[asyncio.Task[bool]] = None
74
+ if cancel_event is not None:
75
+ cancel_task = asyncio.create_task(cancel_event.wait())
76
+
77
+ tasks: set[asyncio.Task[Any]] = {communicate_task}
78
+ if cancel_task is not None:
79
+ tasks.add(cancel_task)
80
+
81
+ done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
82
+
83
+ for pending_task in pending:
84
+ pending_task.cancel()
85
+ with suppress(asyncio.CancelledError):
86
+ await pending_task
87
+
88
+ if cancel_task is not None and cancel_task in done and cancel_event is not None:
89
+ if process.returncode is None:
90
+ process.terminate()
91
+ try:
92
+ await asyncio.wait_for(process.wait(), timeout=2)
93
+ except asyncio.TimeoutError:
94
+ process.kill()
95
+ await process.wait()
96
+
97
+ communicate_task.cancel()
98
+ with suppress(asyncio.CancelledError):
99
+ await communicate_task
100
+ return b"", b"", True
101
+
102
+ stdout, stderr = await communicate_task
103
+ return stdout, stderr, False
104
+
105
+ def _enable_windows_dpi_awareness(self) -> None:
106
+ """Enable crisp rendering for Windows dialogs on scaled displays."""
107
+ try:
108
+ import ctypes
109
+
110
+ windll = getattr(ctypes, "windll", None)
111
+ if windll is None:
112
+ return
113
+
114
+ try:
115
+ windll.shcore.SetProcessDpiAwareness(2)
116
+ return
117
+ except Exception:
118
+ pass
119
+
120
+ try:
121
+ windll.user32.SetProcessDPIAware()
122
+ except Exception:
123
+ pass
124
+ except Exception:
125
+ pass
126
+
127
+ def _configure_windows_tk_scaling(self, root: Any) -> None:
128
+ """Match Tk scaling to the current monitor DPI when available."""
129
+ try:
130
+ import ctypes
131
+
132
+ windll = getattr(ctypes, "windll", None)
133
+ dpi = 0
134
+ try:
135
+ if windll is not None:
136
+ dpi = windll.user32.GetDpiForWindow(root.winfo_id())
137
+ except Exception:
138
+ try:
139
+ dpi = root.winfo_fpixels("1i")
140
+ except Exception:
141
+ dpi = 0
142
+
143
+ if dpi:
144
+ root.tk.call("tk", "scaling", float(dpi) / 72.0)
145
+ except Exception:
146
+ pass
147
+
148
+ async def _macos_dialog(
149
+ self, question: str, timeout: int, *, cancel_event: Optional[asyncio.Event] = None
150
+ ) -> Optional[str]:
151
+ """macOS dialog using osascript."""
152
+ icon_path = PACKAGE_ASSETS_DIR / "agent-asks.icns"
153
+
154
+ if icon_path.exists():
155
+ icon_clause = f'with icon file (POSIX file "{icon_path}")'
156
+ else:
157
+ icon_clause = "with icon caution"
158
+
159
+ script = f"""
160
+ display dialog "{self._escape_for_applescript(question)}" ¬
161
+ default answer "" ¬
162
+ with title "{self._escape_for_applescript(self.dialog_title)}" ¬
163
+ {icon_clause} ¬
164
+ giving up after {timeout}
165
+ """
166
+
167
+ try:
168
+ process = await asyncio.create_subprocess_exec(
169
+ "osascript",
170
+ "-e",
171
+ script,
172
+ stdout=asyncio.subprocess.PIPE,
173
+ stderr=asyncio.subprocess.PIPE,
174
+ )
175
+
176
+ stdout, _stderr, was_cancelled = await self._communicate_or_cancel(
177
+ process, cancel_event
178
+ )
179
+ if was_cancelled:
180
+ return None
181
+
182
+ if process.returncode == 0:
183
+ output = stdout.decode().strip()
184
+ if "text returned:" in output:
185
+ text_part = output.split("text returned:")[1]
186
+ if ", " in text_part:
187
+ return text_part.split(", ")[0].strip()
188
+ return text_part.strip()
189
+ if "gave up:true" in output:
190
+ return None
191
+ if "button returned:" in output and "text returned:" not in output:
192
+ return ""
193
+ return None
194
+ except Exception:
195
+ return None
196
+
197
+ async def _linux_dialog(
198
+ self, question: str, timeout: int, *, cancel_event: Optional[asyncio.Event] = None
199
+ ) -> Optional[str]:
200
+ """Linux dialog using zenity."""
201
+ icon_args = self._get_linux_icon_args()
202
+
203
+ cmd = [
204
+ "zenity",
205
+ "--entry",
206
+ f"--title={self.dialog_title}",
207
+ f"--text={question}",
208
+ f"--timeout={timeout}",
209
+ ] + icon_args
210
+
211
+ try:
212
+ process = await asyncio.create_subprocess_exec(
213
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
214
+ )
215
+
216
+ stdout, _stderr, was_cancelled = await self._communicate_or_cancel(
217
+ process, cancel_event
218
+ )
219
+ if was_cancelled:
220
+ return None
221
+
222
+ if process.returncode == 0:
223
+ return stdout.decode().strip()
224
+ return None
225
+ except Exception:
226
+ return None
227
+
228
+ async def _windows_dialog(
229
+ self,
230
+ question: str,
231
+ timeout: int,
232
+ *,
233
+ run_in_thread: bool = False,
234
+ ) -> Optional[str]:
235
+ """Windows dialog using tkinter."""
236
+ if run_in_thread:
237
+ return await asyncio.to_thread(self._windows_dialog_sync, question, timeout)
238
+
239
+ return self._windows_dialog_sync(question, timeout)
240
+
241
+ def _windows_dialog_sync(self, question: str, timeout: int) -> Optional[str]:
242
+ """Blocking Windows dialog implementation for the current Tk/simpledialog UI."""
243
+ root = None
244
+ try:
245
+ import tkinter as tk
246
+ from tkinter import simpledialog
247
+
248
+ self._enable_windows_dpi_awareness()
249
+ root = tk.Tk()
250
+ self._configure_windows_tk_scaling(root)
251
+ root.withdraw()
252
+ self._set_windows_icon(root)
253
+
254
+ return self._ask_windows_string(
255
+ root,
256
+ simpledialog,
257
+ self.dialog_title,
258
+ question,
259
+ timeout,
260
+ )
261
+ except Exception:
262
+ return None
263
+ finally:
264
+ if root is not None:
265
+ try:
266
+ root.destroy()
267
+ except Exception:
268
+ pass
269
+
270
+ def _ask_windows_string(
271
+ self,
272
+ root: Any,
273
+ simpledialog: Any,
274
+ title: str,
275
+ question: str,
276
+ timeout: int,
277
+ ) -> Optional[str]:
278
+ """Ask for a Windows string response and close it when timeout expires."""
279
+ timeout_id = root.after(timeout * 1000, root.destroy)
280
+ try:
281
+ return cast(Optional[str], simpledialog.askstring(title, question, parent=root))
282
+ finally:
283
+ try:
284
+ root.after_cancel(timeout_id)
285
+ except Exception:
286
+ pass
287
+
288
+ def _escape_for_applescript(self, text: str) -> str:
289
+ """Escape text for AppleScript."""
290
+ return text.replace('"', '\\"').replace("\\", "\\\\")
291
+
292
+ def _get_linux_icon_args(self) -> list[str]:
293
+ """Get icon arguments for Linux zenity dialog."""
294
+ icon_path = PACKAGE_ASSETS_DIR / "agent-asks.png"
295
+ if icon_path.exists():
296
+ print(f"✅ Using Ask Human icon for Linux: {icon_path}")
297
+ return [f"--window-icon={icon_path}"]
298
+
299
+ return ["--question"]
300
+
301
+ def _set_windows_icon(self, root: Any) -> None:
302
+ """Set icon for Windows tkinter dialog."""
303
+ icon_path = PACKAGE_ASSETS_DIR / "agent-asks.ico"
304
+ if not icon_path.exists():
305
+ return
306
+
307
+ try:
308
+ print(f"✅ Using Ask Human icon for Windows: {icon_path}")
309
+ root.iconbitmap(icon_path)
310
+ except Exception:
311
+ pass