wingman-ai 1.0.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.
Files changed (60) hide show
  1. share/wingman/node_listener/package-lock.json +1785 -0
  2. share/wingman/node_listener/package.json +50 -0
  3. share/wingman/node_listener/src/index.ts +108 -0
  4. share/wingman/node_listener/src/ipc.ts +70 -0
  5. share/wingman/node_listener/src/messageHandler.ts +135 -0
  6. share/wingman/node_listener/src/socket.ts +244 -0
  7. share/wingman/node_listener/src/types.d.ts +13 -0
  8. share/wingman/node_listener/tsconfig.json +19 -0
  9. wingman/__init__.py +4 -0
  10. wingman/__main__.py +6 -0
  11. wingman/cli/__init__.py +5 -0
  12. wingman/cli/commands/__init__.py +1 -0
  13. wingman/cli/commands/auth.py +90 -0
  14. wingman/cli/commands/config.py +109 -0
  15. wingman/cli/commands/init.py +71 -0
  16. wingman/cli/commands/logs.py +84 -0
  17. wingman/cli/commands/start.py +111 -0
  18. wingman/cli/commands/status.py +84 -0
  19. wingman/cli/commands/stop.py +33 -0
  20. wingman/cli/commands/uninstall.py +113 -0
  21. wingman/cli/main.py +50 -0
  22. wingman/cli/wizard.py +356 -0
  23. wingman/config/__init__.py +31 -0
  24. wingman/config/paths.py +153 -0
  25. wingman/config/personality.py +155 -0
  26. wingman/config/registry.py +343 -0
  27. wingman/config/settings.py +294 -0
  28. wingman/core/__init__.py +16 -0
  29. wingman/core/agent.py +257 -0
  30. wingman/core/ipc_handler.py +124 -0
  31. wingman/core/llm/__init__.py +5 -0
  32. wingman/core/llm/client.py +77 -0
  33. wingman/core/memory/__init__.py +6 -0
  34. wingman/core/memory/context.py +109 -0
  35. wingman/core/memory/models.py +213 -0
  36. wingman/core/message_processor.py +277 -0
  37. wingman/core/policy/__init__.py +5 -0
  38. wingman/core/policy/evaluator.py +265 -0
  39. wingman/core/process_manager.py +135 -0
  40. wingman/core/safety/__init__.py +8 -0
  41. wingman/core/safety/cooldown.py +63 -0
  42. wingman/core/safety/quiet_hours.py +75 -0
  43. wingman/core/safety/rate_limiter.py +58 -0
  44. wingman/core/safety/triggers.py +117 -0
  45. wingman/core/transports/__init__.py +14 -0
  46. wingman/core/transports/base.py +106 -0
  47. wingman/core/transports/imessage/__init__.py +5 -0
  48. wingman/core/transports/imessage/db_listener.py +280 -0
  49. wingman/core/transports/imessage/sender.py +162 -0
  50. wingman/core/transports/imessage/transport.py +140 -0
  51. wingman/core/transports/whatsapp.py +180 -0
  52. wingman/daemon/__init__.py +5 -0
  53. wingman/daemon/manager.py +303 -0
  54. wingman/installer/__init__.py +5 -0
  55. wingman/installer/node_installer.py +253 -0
  56. wingman_ai-1.0.0.dist-info/METADATA +553 -0
  57. wingman_ai-1.0.0.dist-info/RECORD +60 -0
  58. wingman_ai-1.0.0.dist-info/WHEEL +4 -0
  59. wingman_ai-1.0.0.dist-info/entry_points.txt +2 -0
  60. wingman_ai-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,265 @@
1
+ """Policy evaluation engine for determining response behavior."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ from wingman.config.registry import (
10
+ ContactProfile,
11
+ ContactRole,
12
+ GroupCategory,
13
+ GroupConfig,
14
+ ReplyPolicy,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class MessageContext:
22
+ """Context information for a message being evaluated."""
23
+
24
+ # Message details
25
+ chat_id: str
26
+ sender_id: str
27
+ text: str
28
+
29
+ # Chat type
30
+ is_dm: bool
31
+ is_group: bool
32
+
33
+ # Resolved profiles
34
+ contact: ContactProfile
35
+
36
+ # Optional fields with defaults
37
+ group: GroupConfig | None = None
38
+ platform: str = "whatsapp"
39
+ is_reply_to_bot: bool = False
40
+ is_mentioned: bool = False
41
+
42
+ @property
43
+ def role(self) -> ContactRole:
44
+ """Get the sender's role."""
45
+ return self.contact.role
46
+
47
+ @property
48
+ def group_category(self) -> GroupCategory | None:
49
+ """Get the group category if in a group."""
50
+ return self.group.category if self.group else None
51
+
52
+
53
+ @dataclass
54
+ class PolicyDecision:
55
+ """Result of policy evaluation."""
56
+
57
+ should_respond: bool
58
+ reason: str
59
+ rule_name: str | None = None
60
+ action: ReplyPolicy = ReplyPolicy.SELECTIVE
61
+
62
+
63
+ @dataclass
64
+ class PolicyRule:
65
+ """A single policy rule."""
66
+
67
+ name: str
68
+ conditions: dict
69
+ action: ReplyPolicy
70
+
71
+ @classmethod
72
+ def from_dict(cls, data: dict) -> "PolicyRule":
73
+ """Create a PolicyRule from config dict."""
74
+ return cls(
75
+ name=data.get("name", "unnamed"),
76
+ conditions=data.get("conditions", {}),
77
+ action=ReplyPolicy(data.get("action", "selective")),
78
+ )
79
+
80
+
81
+ class PolicyEvaluator:
82
+ """
83
+ Evaluates policy rules against message context to determine
84
+ whether the bot should respond.
85
+ """
86
+
87
+ def __init__(self, config_path: Path | None = None, bot_name: str = "Maximus"):
88
+ self._rules: list[PolicyRule] = []
89
+ self._fallback_action = ReplyPolicy.SELECTIVE
90
+ self._bot_name = bot_name.lower()
91
+
92
+ if config_path and config_path.exists():
93
+ self._load_config(config_path)
94
+
95
+ def _load_config(self, config_path: Path) -> None:
96
+ """Load policy configuration from YAML file."""
97
+ try:
98
+ with open(config_path) as f:
99
+ config = yaml.safe_load(f) or {}
100
+
101
+ # Load rules
102
+ rules_data = config.get("rules", [])
103
+ for rule_data in rules_data:
104
+ try:
105
+ rule = PolicyRule.from_dict(rule_data)
106
+ self._rules.append(rule)
107
+ logger.debug(f"Loaded policy rule: {rule.name}")
108
+ except (ValueError, KeyError) as e:
109
+ logger.warning(f"Invalid policy rule: {e}")
110
+
111
+ # Load fallback
112
+ fallback_data = config.get("fallback", {})
113
+ if fallback_data:
114
+ self._fallback_action = ReplyPolicy(fallback_data.get("action", "selective"))
115
+
116
+ logger.info(f"Loaded {len(self._rules)} policy rules from {config_path}")
117
+
118
+ except Exception as e:
119
+ logger.error(f"Failed to load policies config: {e}")
120
+
121
+ def _check_mentioned(self, text: str) -> bool:
122
+ """Check if the bot is mentioned in the text."""
123
+ if not text:
124
+ return False
125
+
126
+ text_lower = text.lower()
127
+
128
+ # Check for @mention or name mention
129
+ if f"@{self._bot_name}" in text_lower:
130
+ return True
131
+ if self._bot_name in text_lower:
132
+ return True
133
+
134
+ return False
135
+
136
+ def _matches_conditions(self, rule: PolicyRule, context: MessageContext) -> bool:
137
+ """Check if a rule's conditions match the message context."""
138
+ conditions = rule.conditions
139
+
140
+ # Check platform
141
+ if "platform" in conditions:
142
+ if conditions["platform"] != context.platform:
143
+ return False
144
+
145
+ # Check is_dm
146
+ if "is_dm" in conditions:
147
+ if conditions["is_dm"] != context.is_dm:
148
+ return False
149
+
150
+ # Check is_group
151
+ if "is_group" in conditions:
152
+ if conditions["is_group"] != context.is_group:
153
+ return False
154
+
155
+ # Check role
156
+ if "role" in conditions:
157
+ if conditions["role"] != context.role.value:
158
+ return False
159
+
160
+ # Check group_category
161
+ if "group_category" in conditions:
162
+ if context.group_category is None:
163
+ return False
164
+ if conditions["group_category"] != context.group_category.value:
165
+ return False
166
+
167
+ # Check is_reply_to_bot
168
+ if "is_reply_to_bot" in conditions:
169
+ if conditions["is_reply_to_bot"] != context.is_reply_to_bot:
170
+ return False
171
+
172
+ # Check is_mentioned
173
+ if "is_mentioned" in conditions:
174
+ if conditions["is_mentioned"] != context.is_mentioned:
175
+ return False
176
+
177
+ return True
178
+
179
+ def evaluate(self, context: MessageContext) -> PolicyDecision:
180
+ """
181
+ Evaluate policy rules against the message context.
182
+
183
+ Args:
184
+ context: The message context to evaluate
185
+
186
+ Returns:
187
+ PolicyDecision with should_respond and reason
188
+ """
189
+ # Check if bot is mentioned (for selective policies)
190
+ is_mentioned = self._check_mentioned(context.text)
191
+ # Update context with mention status
192
+ context.is_mentioned = is_mentioned
193
+
194
+ logger.debug(
195
+ f"Evaluating policy: platform={context.platform}, is_dm={context.is_dm}, "
196
+ f"role={context.role.value}, "
197
+ f"group_category={context.group_category.value if context.group_category else 'N/A'}, "
198
+ f"is_mentioned={is_mentioned}"
199
+ )
200
+
201
+ # Evaluate rules in order
202
+ for rule in self._rules:
203
+ if self._matches_conditions(rule, context):
204
+ logger.debug(f"Rule matched: {rule.name} -> {rule.action.value}")
205
+
206
+ should_respond = self._should_respond_for_action(
207
+ rule.action, is_mentioned, context.is_reply_to_bot
208
+ )
209
+
210
+ return PolicyDecision(
211
+ should_respond=should_respond,
212
+ reason=f"rule:{rule.name}",
213
+ rule_name=rule.name,
214
+ action=rule.action,
215
+ )
216
+
217
+ # No rule matched, use fallback
218
+ logger.debug(f"No rule matched, using fallback: {self._fallback_action.value}")
219
+ should_respond = self._should_respond_for_action(
220
+ self._fallback_action, is_mentioned, context.is_reply_to_bot
221
+ )
222
+
223
+ return PolicyDecision(
224
+ should_respond=should_respond,
225
+ reason="fallback",
226
+ rule_name=None,
227
+ action=self._fallback_action,
228
+ )
229
+
230
+ def _should_respond_for_action(
231
+ self, action: ReplyPolicy, is_mentioned: bool, is_reply_to_bot: bool
232
+ ) -> bool:
233
+ """Determine if should respond based on action type."""
234
+ if action == ReplyPolicy.ALWAYS:
235
+ return True
236
+ elif action == ReplyPolicy.NEVER:
237
+ return False
238
+ elif action == ReplyPolicy.SELECTIVE:
239
+ # Respond if mentioned or replying to bot
240
+ return is_mentioned or is_reply_to_bot
241
+ return False
242
+
243
+ def create_context(
244
+ self,
245
+ chat_id: str,
246
+ sender_id: str,
247
+ text: str,
248
+ is_group: bool,
249
+ contact: ContactProfile,
250
+ group: GroupConfig | None = None,
251
+ is_reply_to_bot: bool = False,
252
+ platform: str = "whatsapp",
253
+ ) -> MessageContext:
254
+ """Helper to create a MessageContext."""
255
+ return MessageContext(
256
+ chat_id=chat_id,
257
+ sender_id=sender_id,
258
+ text=text,
259
+ is_dm=not is_group,
260
+ is_group=is_group,
261
+ platform=platform,
262
+ contact=contact,
263
+ group=group,
264
+ is_reply_to_bot=is_reply_to_bot,
265
+ )
@@ -0,0 +1,135 @@
1
+ """Node.js subprocess management."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from .ipc_handler import IPCCommand, IPCHandler
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class NodeProcessManager:
13
+ """Manages the Node.js listener subprocess."""
14
+
15
+ def __init__(self, node_dir: Path, auth_state_dir: Path | None = None):
16
+ self.node_dir = node_dir
17
+ self.auth_state_dir = auth_state_dir
18
+ self.process: asyncio.subprocess.Process | None = None
19
+ self.ipc: IPCHandler | None = None
20
+ self._stderr_task: asyncio.Task | None = None
21
+
22
+ async def start(self) -> IPCHandler:
23
+ """Start the Node.js subprocess and return IPC handler."""
24
+ node_script = self.node_dir / "dist" / "index.js"
25
+
26
+ if not node_script.exists():
27
+ raise FileNotFoundError(
28
+ f"Node.js script not found: {node_script}\n"
29
+ "Run 'npm run build' in node_listener directory first."
30
+ )
31
+
32
+ logger.info(f"Starting Node.js subprocess: {node_script}")
33
+
34
+ # Build environment with optional auth state directory
35
+ env = None
36
+ if self.auth_state_dir:
37
+ import os
38
+
39
+ env = os.environ.copy()
40
+ env["AUTH_STATE_DIR"] = str(self.auth_state_dir)
41
+
42
+ self.process = await asyncio.create_subprocess_exec(
43
+ "node",
44
+ str(node_script),
45
+ stdin=asyncio.subprocess.PIPE,
46
+ stdout=asyncio.subprocess.PIPE,
47
+ stderr=asyncio.subprocess.PIPE,
48
+ cwd=str(self.node_dir),
49
+ env=env,
50
+ )
51
+
52
+ if not self.process.stdin or not self.process.stdout:
53
+ raise RuntimeError("Failed to create subprocess pipes")
54
+
55
+ # Create IPC handler
56
+ self.ipc = IPCHandler(stdin=self.process.stdin, stdout=self.process.stdout)
57
+
58
+ # Start stderr reader for logging
59
+ self._stderr_task = asyncio.create_task(self._read_stderr())
60
+
61
+ logger.info(f"Node.js subprocess started (PID: {self.process.pid})")
62
+ return self.ipc
63
+
64
+ async def _read_stderr(self) -> None:
65
+ """Read and log Node.js stderr output."""
66
+ if not self.process or not self.process.stderr:
67
+ return
68
+
69
+ while True:
70
+ try:
71
+ line = await self.process.stderr.readline()
72
+ if not line:
73
+ break
74
+
75
+ # Node logs JSON to stderr
76
+ log_line = line.decode("utf-8").strip()
77
+ if log_line:
78
+ # Check if it's a QR code line (contains block characters)
79
+ if any(c in log_line for c in ["▄", "█", "▀", "="]):
80
+ # Print QR directly to stderr for user to see
81
+ print(log_line, flush=True)
82
+ else:
83
+ logger.debug(f"[Node] {log_line}")
84
+
85
+ except asyncio.CancelledError:
86
+ break
87
+ except Exception as e:
88
+ logger.error(f"Error reading stderr: {e}")
89
+ break
90
+
91
+ async def stop(self) -> None:
92
+ """Stop the Node.js subprocess gracefully."""
93
+ if not self.process:
94
+ return
95
+
96
+ logger.info("Stopping Node.js subprocess...")
97
+
98
+ # Cancel stderr reader
99
+ if self._stderr_task:
100
+ self._stderr_task.cancel()
101
+ try:
102
+ await self._stderr_task
103
+ except asyncio.CancelledError:
104
+ pass
105
+
106
+ # Try graceful shutdown first
107
+ if self.ipc:
108
+ try:
109
+ await asyncio.wait_for(
110
+ self.ipc.send_command(IPCCommand(action="shutdown")), timeout=2.0
111
+ )
112
+ except Exception as e:
113
+ logger.warning(f"Failed to send shutdown command: {e}")
114
+
115
+ # Wait for process to exit
116
+ try:
117
+ await asyncio.wait_for(self.process.wait(), timeout=5.0)
118
+ logger.info("Node.js subprocess exited gracefully")
119
+ except asyncio.TimeoutError:
120
+ logger.warning("Node.js subprocess did not exit, terminating...")
121
+ self.process.terminate()
122
+ try:
123
+ await asyncio.wait_for(self.process.wait(), timeout=2.0)
124
+ except asyncio.TimeoutError:
125
+ logger.error("Node.js subprocess did not terminate, killing...")
126
+ self.process.kill()
127
+ await self.process.wait()
128
+
129
+ self.process = None
130
+ self.ipc = None
131
+
132
+ @property
133
+ def is_running(self) -> bool:
134
+ """Check if the subprocess is running."""
135
+ return self.process is not None and self.process.returncode is None
@@ -0,0 +1,8 @@
1
+ """Safety features for Wingman."""
2
+
3
+ from .cooldown import CooldownManager
4
+ from .quiet_hours import QuietHoursChecker
5
+ from .rate_limiter import RateLimiter
6
+ from .triggers import TriggerDetector
7
+
8
+ __all__ = ["RateLimiter", "CooldownManager", "QuietHoursChecker", "TriggerDetector"]
@@ -0,0 +1,63 @@
1
+ """Per-chat cooldown management."""
2
+
3
+ import logging
4
+ import time
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class CooldownManager:
10
+ """
11
+ Manages per-chat cooldowns to prevent rapid-fire responses.
12
+ """
13
+
14
+ def __init__(self, default_cooldown_seconds: int = 60):
15
+ self.default_cooldown = default_cooldown_seconds
16
+ self._last_reply: dict[str, float] = {}
17
+ self._custom_cooldowns: dict[str, int] = {}
18
+
19
+ def set_cooldown(self, chat_id: str, seconds: int) -> None:
20
+ """Set a custom cooldown for a specific chat."""
21
+ self._custom_cooldowns[chat_id] = seconds
22
+ logger.debug(f"Set custom cooldown for {chat_id}: {seconds}s")
23
+
24
+ def get_cooldown(self, chat_id: str) -> int:
25
+ """Get the cooldown duration for a chat."""
26
+ return self._custom_cooldowns.get(chat_id, self.default_cooldown)
27
+
28
+ def is_on_cooldown(self, chat_id: str) -> bool:
29
+ """Check if a chat is currently on cooldown."""
30
+ last_reply = self._last_reply.get(chat_id)
31
+ if last_reply is None:
32
+ return False
33
+
34
+ cooldown = self.get_cooldown(chat_id)
35
+ elapsed = time.time() - last_reply
36
+ on_cooldown = elapsed < cooldown
37
+
38
+ if on_cooldown:
39
+ remaining = cooldown - elapsed
40
+ logger.debug(f"Chat {chat_id} on cooldown: {remaining:.1f}s remaining")
41
+
42
+ return on_cooldown
43
+
44
+ def record_reply(self, chat_id: str) -> None:
45
+ """Record that a reply was sent to a chat."""
46
+ self._last_reply[chat_id] = time.time()
47
+ logger.debug(f"Recorded reply to {chat_id}")
48
+
49
+ def get_remaining_cooldown(self, chat_id: str) -> float:
50
+ """Get seconds remaining in cooldown, or 0 if not on cooldown."""
51
+ last_reply = self._last_reply.get(chat_id)
52
+ if last_reply is None:
53
+ return 0
54
+
55
+ cooldown = self.get_cooldown(chat_id)
56
+ elapsed = time.time() - last_reply
57
+ return max(0, cooldown - elapsed)
58
+
59
+ def clear_cooldown(self, chat_id: str) -> None:
60
+ """Manually clear cooldown for a chat."""
61
+ if chat_id in self._last_reply:
62
+ del self._last_reply[chat_id]
63
+ logger.debug(f"Cleared cooldown for {chat_id}")
@@ -0,0 +1,75 @@
1
+ """Quiet hours enforcement."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from datetime import time as dt_time
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class QuietHoursChecker:
11
+ """
12
+ Enforces quiet hours during which the bot will not respond.
13
+ Default: midnight (0:00) to 6:00 AM.
14
+ """
15
+
16
+ def __init__(self, start_hour: int = 0, end_hour: int = 6, enabled: bool = True):
17
+ self.start_hour = start_hour
18
+ self.end_hour = end_hour
19
+ self.enabled = enabled
20
+
21
+ def is_quiet_time(self, check_time: datetime | None = None) -> bool:
22
+ """
23
+ Check if the given time falls within quiet hours.
24
+
25
+ Args:
26
+ check_time: Time to check (defaults to current time)
27
+
28
+ Returns:
29
+ True if currently in quiet hours
30
+ """
31
+ if not self.enabled:
32
+ return False
33
+
34
+ if check_time is None:
35
+ check_time = datetime.now()
36
+
37
+ current_hour = check_time.hour
38
+
39
+ # Handle both same-day and overnight ranges
40
+ if self.start_hour <= self.end_hour:
41
+ # Same day range (e.g., 9 AM to 5 PM)
42
+ is_quiet = self.start_hour <= current_hour < self.end_hour
43
+ else:
44
+ # Overnight range (e.g., 10 PM to 6 AM)
45
+ is_quiet = current_hour >= self.start_hour or current_hour < self.end_hour
46
+
47
+ if is_quiet:
48
+ logger.debug(
49
+ f"Quiet hours active: {self.start_hour}:00 - {self.end_hour}:00, "
50
+ f"current hour: {current_hour}"
51
+ )
52
+
53
+ return is_quiet
54
+
55
+ def get_end_time(self) -> dt_time:
56
+ """Get the time when quiet hours end."""
57
+ return dt_time(hour=self.end_hour)
58
+
59
+ def set_hours(self, start: int, end: int) -> None:
60
+ """Update quiet hours range."""
61
+ if not (0 <= start <= 23 and 0 <= end <= 23):
62
+ raise ValueError("Hours must be between 0 and 23")
63
+ self.start_hour = start
64
+ self.end_hour = end
65
+ logger.info(f"Quiet hours updated: {start}:00 - {end}:00")
66
+
67
+ def disable(self) -> None:
68
+ """Disable quiet hours."""
69
+ self.enabled = False
70
+ logger.info("Quiet hours disabled")
71
+
72
+ def enable(self) -> None:
73
+ """Enable quiet hours."""
74
+ self.enabled = True
75
+ logger.info("Quiet hours enabled")
@@ -0,0 +1,58 @@
1
+ """Global rate limiting for bot replies."""
2
+
3
+ import logging
4
+ import time
5
+ from collections import deque
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class RateLimiter:
11
+ """
12
+ Implements a sliding window rate limiter.
13
+ Tracks replies in a 1-hour window and enforces max replies limit.
14
+ """
15
+
16
+ def __init__(self, max_replies_per_hour: int = 30):
17
+ self.max_replies = max_replies_per_hour
18
+ self.window_seconds = 3600 # 1 hour
19
+ self._timestamps: deque[float] = deque()
20
+
21
+ def _cleanup_old_entries(self) -> None:
22
+ """Remove timestamps older than the window."""
23
+ cutoff = time.time() - self.window_seconds
24
+ while self._timestamps and self._timestamps[0] < cutoff:
25
+ self._timestamps.popleft()
26
+
27
+ def can_reply(self) -> bool:
28
+ """Check if a reply is allowed within rate limits."""
29
+ self._cleanup_old_entries()
30
+ allowed = len(self._timestamps) < self.max_replies
31
+
32
+ if not allowed:
33
+ logger.warning(
34
+ f"Rate limit reached: {len(self._timestamps)}/{self.max_replies} "
35
+ f"replies in the last hour"
36
+ )
37
+
38
+ return allowed
39
+
40
+ def record_reply(self) -> None:
41
+ """Record that a reply was sent."""
42
+ self._cleanup_old_entries()
43
+ self._timestamps.append(time.time())
44
+ logger.debug(
45
+ f"Reply recorded: {len(self._timestamps)}/{self.max_replies} " f"in current window"
46
+ )
47
+
48
+ def get_remaining(self) -> int:
49
+ """Get number of remaining allowed replies."""
50
+ self._cleanup_old_entries()
51
+ return max(0, self.max_replies - len(self._timestamps))
52
+
53
+ def get_reset_time(self) -> float:
54
+ """Get seconds until oldest reply expires from window."""
55
+ if not self._timestamps:
56
+ return 0
57
+ oldest = self._timestamps[0]
58
+ return max(0, (oldest + self.window_seconds) - time.time())