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
wingman/cli/main.py ADDED
@@ -0,0 +1,50 @@
1
+ """Main CLI entry point for Wingman."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from wingman import __version__
7
+
8
+ from .commands import auth, config, init, logs, start, status, stop, uninstall
9
+
10
+ # Create main app
11
+ app = typer.Typer(
12
+ name="wingman",
13
+ help="Wingman - AI-powered personal chat agent for WhatsApp and iMessage",
14
+ no_args_is_help=True,
15
+ rich_markup_mode="rich",
16
+ )
17
+
18
+ # Rich console for pretty output
19
+ console = Console()
20
+
21
+ # Add subcommands
22
+ app.command()(init.init)
23
+ app.command()(auth.auth)
24
+ app.command()(start.start)
25
+ app.command()(stop.stop)
26
+ app.command()(status.status)
27
+ app.command()(logs.logs)
28
+ app.command()(config.config)
29
+ app.command()(uninstall.uninstall)
30
+
31
+
32
+ @app.callback(invoke_without_command=True)
33
+ def main(
34
+ ctx: typer.Context,
35
+ version: bool = typer.Option(
36
+ False,
37
+ "--version",
38
+ "-v",
39
+ help="Show version and exit",
40
+ is_eager=True,
41
+ ),
42
+ ) -> None:
43
+ """Wingman - AI-powered personal chat agent."""
44
+ if version:
45
+ console.print(f"Wingman v{__version__}")
46
+ raise typer.Exit()
47
+
48
+
49
+ if __name__ == "__main__":
50
+ app()
wingman/cli/wizard.py ADDED
@@ -0,0 +1,356 @@
1
+ """Interactive setup wizard for Wingman."""
2
+
3
+ import re
4
+
5
+ import questionary
6
+ import yaml
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+
10
+ from wingman.config.paths import WingmanPaths
11
+ from wingman.installer import NodeInstaller
12
+
13
+
14
+ class SetupWizard:
15
+ """Interactive setup wizard for Wingman."""
16
+
17
+ def __init__(self, paths: WingmanPaths, console: Console):
18
+ self.paths = paths
19
+ self.console = console
20
+
21
+ def run(self) -> bool:
22
+ """Run the setup wizard. Returns True if setup completed successfully."""
23
+ # Step 1: Check prerequisites
24
+ if not self._check_prerequisites():
25
+ return False
26
+
27
+ # Step 2: OpenAI configuration
28
+ api_key = self._get_openai_config()
29
+ if not api_key:
30
+ return False
31
+
32
+ # Step 3: Bot personality
33
+ bot_name, personality_desc, tone = self._get_personality_config()
34
+
35
+ # Step 4: Safety settings
36
+ safety_config = self._get_safety_config()
37
+
38
+ # Step 5: Install Node.js listener
39
+ if not self._install_node_listener():
40
+ return False
41
+
42
+ # Generate config files
43
+ self._generate_configs(api_key, bot_name, personality_desc, tone, safety_config)
44
+
45
+ return True
46
+
47
+ def _check_prerequisites(self) -> bool:
48
+ """Check system prerequisites."""
49
+ self.console.print("[bold]Step 1/5: Checking prerequisites...[/bold]")
50
+ self.console.print()
51
+
52
+ installer = NodeInstaller(self.paths.node_dir)
53
+ all_ok, issues = installer.check_prerequisites()
54
+
55
+ # Python check (always passes if we're running)
56
+ import sys
57
+
58
+ python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
59
+ self.console.print(f" [green]✓[/green] Python {python_version}")
60
+
61
+ # Node.js check
62
+ version_info = installer.get_version_info()
63
+ if version_info["node_version"]:
64
+ self.console.print(f" [green]✓[/green] Node.js {version_info['node_version']}")
65
+ else:
66
+ self.console.print(" [red]✗[/red] Node.js not found")
67
+
68
+ # npm check
69
+ if version_info["npm_version"]:
70
+ self.console.print(f" [green]✓[/green] npm {version_info['npm_version']}")
71
+ else:
72
+ self.console.print(" [red]✗[/red] npm not found")
73
+
74
+ self.console.print()
75
+
76
+ if not all_ok:
77
+ self.console.print("[red]Prerequisites not met:[/red]")
78
+ for issue in issues:
79
+ self.console.print(f" - {issue}")
80
+ self.console.print()
81
+ self.console.print("Please install the missing prerequisites and try again.")
82
+ return False
83
+
84
+ return True
85
+
86
+ def _get_openai_config(self) -> str | None:
87
+ """Get OpenAI API key from user."""
88
+ self.console.print("[bold]Step 2/5: OpenAI Configuration[/bold]")
89
+ self.console.print()
90
+
91
+ api_key = questionary.password(
92
+ "Enter your OpenAI API key:", instruction="(starts with 'sk-')"
93
+ ).ask()
94
+
95
+ if not api_key:
96
+ return None
97
+
98
+ # Validate API key format
99
+ if not api_key.startswith("sk-"):
100
+ self.console.print(
101
+ "[yellow]Warning: API key doesn't start with 'sk-'. Proceeding anyway.[/yellow]"
102
+ )
103
+
104
+ # Optional: Test API key
105
+ test = questionary.confirm("Test API key?", default=True).ask()
106
+
107
+ if test:
108
+ with Progress(
109
+ SpinnerColumn(),
110
+ TextColumn("[progress.description]{task.description}"),
111
+ console=self.console,
112
+ ) as progress:
113
+ progress.add_task("Testing API key...", total=None)
114
+
115
+ if self._test_api_key(api_key):
116
+ self.console.print(" [green]✓[/green] API key is valid")
117
+ else:
118
+ self.console.print(" [red]✗[/red] API key test failed")
119
+ proceed = questionary.confirm("Continue anyway?", default=False).ask()
120
+ if not proceed:
121
+ return None
122
+
123
+ self.console.print()
124
+ return api_key
125
+
126
+ def _test_api_key(self, api_key: str) -> bool:
127
+ """Test if the OpenAI API key is valid."""
128
+ try:
129
+ from openai import OpenAI
130
+
131
+ client = OpenAI(api_key=api_key)
132
+ # Simple test - list models
133
+ client.models.list()
134
+ return True
135
+ except Exception:
136
+ return False
137
+
138
+ def _get_personality_config(self) -> tuple[str, str, str]:
139
+ """Get bot personality configuration."""
140
+ self.console.print("[bold]Step 3/5: Bot Personality[/bold]")
141
+ self.console.print()
142
+
143
+ bot_name = (
144
+ questionary.text("What should your bot be called?", default="Wingman").ask()
145
+ or "Wingman"
146
+ )
147
+
148
+ personality_desc = (
149
+ questionary.text(
150
+ "Describe your bot's personality:", default="Witty and helpful assistant"
151
+ ).ask()
152
+ or "Witty and helpful assistant"
153
+ )
154
+
155
+ tone = (
156
+ questionary.select(
157
+ "Default tone:",
158
+ choices=[
159
+ questionary.Choice("casual - Relaxed and friendly", value="casual"),
160
+ questionary.Choice("friendly - Warm and approachable", value="friendly"),
161
+ questionary.Choice("professional - Polite and formal", value="professional"),
162
+ ],
163
+ default="casual",
164
+ ).ask()
165
+ or "casual"
166
+ )
167
+
168
+ self.console.print()
169
+ return bot_name, personality_desc, tone
170
+
171
+ def _get_safety_config(self) -> dict:
172
+ """Get safety settings configuration."""
173
+ self.console.print("[bold]Step 4/5: Safety Settings[/bold]")
174
+ self.console.print()
175
+
176
+ max_replies = (
177
+ questionary.text(
178
+ "Max replies per hour:", default="30", validate=lambda x: x.isdigit() and int(x) > 0
179
+ ).ask()
180
+ or "30"
181
+ )
182
+
183
+ enable_quiet_hours = questionary.confirm("Enable quiet hours?", default=True).ask()
184
+
185
+ quiet_start = 0
186
+ quiet_end = 6
187
+
188
+ if enable_quiet_hours:
189
+ quiet_range = (
190
+ questionary.text(
191
+ "Quiet hours (start-end, 24h format):",
192
+ default="0-6",
193
+ validate=lambda x: bool(re.match(r"^\d{1,2}-\d{1,2}$", x)),
194
+ ).ask()
195
+ or "0-6"
196
+ )
197
+
198
+ parts = quiet_range.split("-")
199
+ quiet_start = int(parts[0])
200
+ quiet_end = int(parts[1])
201
+
202
+ self.console.print()
203
+
204
+ return {
205
+ "max_replies_per_hour": int(max_replies),
206
+ "quiet_hours_enabled": enable_quiet_hours,
207
+ "quiet_hours_start": quiet_start,
208
+ "quiet_hours_end": quiet_end,
209
+ }
210
+
211
+ def _install_node_listener(self) -> bool:
212
+ """Install the Node.js WhatsApp listener."""
213
+ self.console.print("[bold]Step 5/5: Installing WhatsApp listener...[/bold]")
214
+ self.console.print()
215
+
216
+ installer = NodeInstaller(self.paths.node_dir)
217
+
218
+ # Check if already installed
219
+ if installer.is_installed():
220
+ self.console.print(" [green]✓[/green] Node.js listener already installed")
221
+ self.console.print()
222
+ return True
223
+
224
+ with Progress(
225
+ SpinnerColumn(),
226
+ TextColumn("[progress.description]{task.description}"),
227
+ console=self.console,
228
+ ) as progress:
229
+ task = progress.add_task("Installing...", total=None)
230
+
231
+ def update_progress(step: str, message: str):
232
+ progress.update(task, description=message)
233
+
234
+ success = installer.install(progress_callback=update_progress)
235
+
236
+ if success:
237
+ self.console.print(" [green]✓[/green] Node.js listener installed")
238
+ else:
239
+ self.console.print(" [red]✗[/red] Installation failed")
240
+
241
+ self.console.print()
242
+ return success
243
+
244
+ def _generate_configs(
245
+ self, api_key: str, bot_name: str, personality_desc: str, tone: str, safety_config: dict
246
+ ) -> None:
247
+ """Generate configuration files."""
248
+ # Ensure directories exist
249
+ self.paths.ensure_directories()
250
+
251
+ # Main config
252
+ config = {
253
+ "bot": {
254
+ "name": bot_name,
255
+ },
256
+ "openai": {
257
+ "api_key": api_key,
258
+ "model": "gpt-4o",
259
+ "max_response_tokens": 150,
260
+ "temperature": 0.8,
261
+ },
262
+ "personality": {
263
+ "base_prompt": f"You are {bot_name}, a {personality_desc}.",
264
+ "default_tone": tone,
265
+ },
266
+ "safety": {
267
+ "max_replies_per_hour": safety_config["max_replies_per_hour"],
268
+ "cooldown_seconds": 60,
269
+ "quiet_hours": {
270
+ "enabled": safety_config["quiet_hours_enabled"],
271
+ "start": safety_config["quiet_hours_start"],
272
+ "end": safety_config["quiet_hours_end"],
273
+ },
274
+ },
275
+ }
276
+
277
+ with open(self.paths.config_file, "w") as f:
278
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
279
+
280
+ # Contacts config (template)
281
+ contacts_config = {
282
+ "contacts": {
283
+ "# Add contacts here using their JID": {
284
+ "name": "Example Contact",
285
+ "role": "friend",
286
+ "tone": "casual",
287
+ }
288
+ },
289
+ "defaults": {
290
+ "role": "unknown",
291
+ "tone": "neutral",
292
+ "allow_proactive": False,
293
+ },
294
+ }
295
+
296
+ # Remove the comment key (it was just for illustration)
297
+ contacts_config["contacts"] = {}
298
+
299
+ with open(self.paths.contacts_config, "w") as f:
300
+ yaml.dump(contacts_config, f, default_flow_style=False, sort_keys=False)
301
+ f.write("\n# Add contacts like this:\n")
302
+ f.write("# contacts:\n")
303
+ f.write('# "+14155551234@s.whatsapp.net":\n')
304
+ f.write("# name: John\n")
305
+ f.write(
306
+ "# role: friend # girlfriend, sister, friend, family, colleague, unknown\n"
307
+ )
308
+ f.write(
309
+ "# tone: casual # affectionate, loving, friendly, casual, sarcastic, neutral\n"
310
+ )
311
+
312
+ # Groups config (template)
313
+ groups_config = {
314
+ "groups": {},
315
+ "defaults": {
316
+ "category": "unknown",
317
+ "reply_policy": "selective",
318
+ },
319
+ }
320
+
321
+ with open(self.paths.groups_config, "w") as f:
322
+ yaml.dump(groups_config, f, default_flow_style=False, sort_keys=False)
323
+ f.write("\n# Add groups like this:\n")
324
+ f.write("# groups:\n")
325
+ f.write('# "120363012345678901@g.us":\n')
326
+ f.write("# name: Family Chat\n")
327
+ f.write("# category: family # family, friends, work, unknown\n")
328
+ f.write("# reply_policy: always # always, selective, never\n")
329
+
330
+ # Policies config (template)
331
+ policies_config = {
332
+ "rules": [
333
+ {
334
+ "name": "dm_always",
335
+ "conditions": {
336
+ "is_dm": True,
337
+ },
338
+ "action": "always",
339
+ },
340
+ {
341
+ "name": "group_selective",
342
+ "conditions": {
343
+ "is_group": True,
344
+ },
345
+ "action": "selective",
346
+ },
347
+ ],
348
+ "fallback": {
349
+ "action": "selective",
350
+ },
351
+ }
352
+
353
+ with open(self.paths.policies_config, "w") as f:
354
+ yaml.dump(policies_config, f, default_flow_style=False, sort_keys=False)
355
+
356
+ self.console.print(f"[dim]Config saved to {self.paths.config_dir}[/dim]")
@@ -0,0 +1,31 @@
1
+ """Configuration module for Wingman."""
2
+
3
+ from .paths import WingmanPaths
4
+ from .personality import SYSTEM_PROMPT, RoleBasedPromptBuilder, get_personality_prompt
5
+ from .registry import (
6
+ ContactProfile,
7
+ ContactRegistry,
8
+ ContactRole,
9
+ ContactTone,
10
+ GroupCategory,
11
+ GroupConfig,
12
+ GroupRegistry,
13
+ ReplyPolicy,
14
+ )
15
+ from .settings import Settings
16
+
17
+ __all__ = [
18
+ "Settings",
19
+ "WingmanPaths",
20
+ "SYSTEM_PROMPT",
21
+ "get_personality_prompt",
22
+ "RoleBasedPromptBuilder",
23
+ "ContactRegistry",
24
+ "GroupRegistry",
25
+ "ContactProfile",
26
+ "GroupConfig",
27
+ "ContactRole",
28
+ "ContactTone",
29
+ "GroupCategory",
30
+ "ReplyPolicy",
31
+ ]
@@ -0,0 +1,153 @@
1
+ """XDG-compliant path management for Wingman."""
2
+
3
+ from pathlib import Path
4
+
5
+ from platformdirs import user_cache_dir, user_config_dir, user_data_dir
6
+
7
+
8
+ class WingmanPaths:
9
+ """
10
+ Manages XDG-compliant paths for Wingman configuration and data.
11
+
12
+ Directories:
13
+ - config_dir: ~/.config/wingman/ - Configuration files (YAML configs)
14
+ - data_dir: ~/.local/share/wingman/ - Data files (DB, auth state)
15
+ - cache_dir: ~/.cache/wingman/ - Cache and logs
16
+ - node_dir: ~/.config/wingman/node_listener/ - Installed Node.js listener
17
+ """
18
+
19
+ APP_NAME = "wingman"
20
+ APP_AUTHOR = "wingman"
21
+
22
+ def __init__(
23
+ self,
24
+ config_dir: Path | None = None,
25
+ data_dir: Path | None = None,
26
+ cache_dir: Path | None = None,
27
+ ):
28
+ """
29
+ Initialize Wingman paths.
30
+
31
+ Args:
32
+ config_dir: Override config directory (default: ~/.config/wingman)
33
+ data_dir: Override data directory (default: ~/.local/share/wingman)
34
+ cache_dir: Override cache directory (default: ~/.cache/wingman)
35
+ """
36
+ self._config_dir = config_dir or Path(user_config_dir(self.APP_NAME, self.APP_AUTHOR))
37
+ self._data_dir = data_dir or Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR))
38
+ self._cache_dir = cache_dir or Path(user_cache_dir(self.APP_NAME, self.APP_AUTHOR))
39
+
40
+ @property
41
+ def config_dir(self) -> Path:
42
+ """Config directory (~/.config/wingman/)."""
43
+ return self._config_dir
44
+
45
+ @property
46
+ def data_dir(self) -> Path:
47
+ """Data directory (~/.local/share/wingman/)."""
48
+ return self._data_dir
49
+
50
+ @property
51
+ def cache_dir(self) -> Path:
52
+ """Cache directory (~/.cache/wingman/)."""
53
+ return self._cache_dir
54
+
55
+ @property
56
+ def log_dir(self) -> Path:
57
+ """Log directory (~/.cache/wingman/logs/)."""
58
+ return self._cache_dir / "logs"
59
+
60
+ @property
61
+ def node_dir(self) -> Path:
62
+ """Node.js listener directory (~/.config/wingman/node_listener/)."""
63
+ return self._config_dir / "node_listener"
64
+
65
+ @property
66
+ def auth_state_dir(self) -> Path:
67
+ """WhatsApp auth state directory (~/.local/share/wingman/auth_state/)."""
68
+ return self._data_dir / "auth_state"
69
+
70
+ @property
71
+ def db_path(self) -> Path:
72
+ """Database file path (~/.local/share/wingman/conversations.db)."""
73
+ return self._data_dir / "conversations.db"
74
+
75
+ @property
76
+ def config_file(self) -> Path:
77
+ """Main config file (~/.config/wingman/config.yaml)."""
78
+ return self._config_dir / "config.yaml"
79
+
80
+ @property
81
+ def contacts_config(self) -> Path:
82
+ """Contacts config file (~/.config/wingman/contacts.yaml)."""
83
+ return self._config_dir / "contacts.yaml"
84
+
85
+ @property
86
+ def groups_config(self) -> Path:
87
+ """Groups config file (~/.config/wingman/groups.yaml)."""
88
+ return self._config_dir / "groups.yaml"
89
+
90
+ @property
91
+ def policies_config(self) -> Path:
92
+ """Policies config file (~/.config/wingman/policies.yaml)."""
93
+ return self._config_dir / "policies.yaml"
94
+
95
+ @property
96
+ def personality_config(self) -> Path:
97
+ """Personality config file (~/.config/wingman/personality.yaml)."""
98
+ return self._config_dir / "personality.yaml"
99
+
100
+ @property
101
+ def pid_file(self) -> Path:
102
+ """PID file for daemon (~/.cache/wingman/wingman.pid)."""
103
+ return self._cache_dir / "wingman.pid"
104
+
105
+ @property
106
+ def launchd_plist(self) -> Path:
107
+ """Launchd plist file (~/Library/LaunchAgents/com.wingman.agent.plist)."""
108
+ return Path.home() / "Library" / "LaunchAgents" / "com.wingman.agent.plist"
109
+
110
+ def ensure_directories(self) -> None:
111
+ """Create all required directories if they don't exist."""
112
+ for directory in [
113
+ self._config_dir,
114
+ self._data_dir,
115
+ self._cache_dir,
116
+ self.log_dir,
117
+ self.auth_state_dir,
118
+ ]:
119
+ directory.mkdir(parents=True, exist_ok=True)
120
+
121
+ def config_exists(self) -> bool:
122
+ """Check if the main config file exists."""
123
+ return self.config_file.exists()
124
+
125
+ def is_initialized(self) -> bool:
126
+ """Check if Wingman has been set up (config and node_listener exist)."""
127
+ return (
128
+ self.config_file.exists()
129
+ and self.node_dir.exists()
130
+ and (self.node_dir / "dist" / "index.js").exists()
131
+ )
132
+
133
+ @classmethod
134
+ def from_project_root(cls, project_root: Path) -> "WingmanPaths":
135
+ """
136
+ Create paths relative to a project root (for development/legacy mode).
137
+
138
+ This maintains backward compatibility with the original project structure.
139
+ """
140
+ return cls(
141
+ config_dir=project_root / "config",
142
+ data_dir=project_root / "data",
143
+ cache_dir=project_root / "logs",
144
+ )
145
+
146
+ def __repr__(self) -> str:
147
+ return (
148
+ f"WingmanPaths(\n"
149
+ f" config_dir={self._config_dir}\n"
150
+ f" data_dir={self._data_dir}\n"
151
+ f" cache_dir={self._cache_dir}\n"
152
+ f")"
153
+ )