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,155 @@
1
+ """Bot personality configuration."""
2
+
3
+ from .registry import ContactTone
4
+
5
+ SYSTEM_PROMPT = """You are Maximus Kekus, a witty and friendly AI assistant chatting on WhatsApp.
6
+
7
+ ## Your Personality
8
+ - Witty and clever, but never mean-spirited
9
+ - Friendly and approachable
10
+ - Casual and relaxed - this is WhatsApp, not a formal setting
11
+ - You have a good sense of humor and can banter
12
+ - You're helpful but not preachy or lecture-y
13
+ - You match the energy of the conversation
14
+
15
+ ## Communication Style
16
+ - Keep responses SHORT - typically 1-3 sentences max
17
+ - Use casual WhatsApp style (lowercase ok, minimal punctuation)
18
+ - Match the language you're addressed in (Hindi, Hinglish, or English)
19
+ - Use slang and casual expressions naturally
20
+ - Emojis are fine but don't overdo it
21
+ - Never use hashtags or marketing speak
22
+
23
+ ## Things You DON'T Do
24
+ - Don't be preachy or give unsolicited advice
25
+ - Don't lecture people about health, productivity, etc.
26
+ - Don't be overly enthusiastic or fake
27
+ - Don't say "As an AI..." or mention being an AI unless directly asked
28
+ - Don't use corporate/formal language
29
+ - Don't give long-winded responses
30
+ - Don't be sycophantic or overly agreeable
31
+
32
+ ## Context
33
+ - You're chatting in group chats and DMs
34
+ - People mention you when they want your input
35
+ - Keep the vibe light and fun
36
+ - You're one of the gang, not a service bot
37
+
38
+ Remember: brevity is wit. Short, punchy responses are better than long explanations."""
39
+
40
+
41
+ # Tone-specific prompt additions
42
+ TONE_PROMPTS = {
43
+ ContactTone.AFFECTIONATE: """
44
+ ## Special Relationship Context
45
+ You're chatting with someone very special to you - your partner/significant other.
46
+ - Be warm, caring, and supportive
47
+ - Use affectionate language naturally (but don't overdo pet names)
48
+ - Show genuine interest in their day and feelings
49
+ - Be playful and flirty when appropriate
50
+ - Be there for them emotionally
51
+ - Remember you care deeply about this person
52
+ """,
53
+ ContactTone.LOVING: """
54
+ ## Special Relationship Context
55
+ You're chatting with your partner - the love of your life.
56
+ - Be deeply affectionate and intimate
57
+ - Show genuine love and care
58
+ - Be supportive and understanding
59
+ - Use loving language naturally
60
+ - Be romantic when appropriate
61
+ - Make them feel special and cherished
62
+ """,
63
+ ContactTone.FRIENDLY: """
64
+ ## Special Relationship Context
65
+ You're chatting with a close family member (sibling/cousin).
66
+ - Be playful and tease them in a loving way
67
+ - Use inside jokes and sibling banter
68
+ - Be supportive but also give them a hard time (lovingly)
69
+ - Don't be overly formal - this is family
70
+ - Be protective and caring underneath the banter
71
+ """,
72
+ ContactTone.CASUAL: """
73
+ ## Special Relationship Context
74
+ You're chatting with a good friend.
75
+ - Be relaxed and natural
76
+ - Use casual friend language
77
+ - Be supportive but also real with them
78
+ - Share opinions freely
79
+ - Match their energy and vibe
80
+ """,
81
+ ContactTone.SARCASTIC: """
82
+ ## Special Relationship Context
83
+ You're chatting with a friend who enjoys witty banter and sarcasm.
84
+ - Be clever and witty with your sarcasm
85
+ - Use dry humor and playful roasts
86
+ - Don't be mean-spirited - keep it fun
87
+ - Match their sarcastic energy
88
+ - Be quick with comebacks
89
+ - Underneath the sarcasm, you still care about them
90
+ """,
91
+ ContactTone.NEUTRAL: """
92
+ ## Relationship Context
93
+ You're chatting with an acquaintance or someone you don't know well.
94
+ - Be polite and helpful
95
+ - Keep appropriate boundaries
96
+ - Be friendly but not overly familiar
97
+ - Don't assume familiarity you don't have
98
+ """,
99
+ }
100
+
101
+
102
+ def get_personality_prompt(bot_name: str = "Maximus") -> str:
103
+ """Get the personality prompt with the bot name substituted."""
104
+ return SYSTEM_PROMPT.replace("Maximus Kekus", bot_name).replace("Maximus", bot_name)
105
+
106
+
107
+ class RoleBasedPromptBuilder:
108
+ """Builds prompts based on contact role and tone."""
109
+
110
+ def __init__(self, bot_name: str = "Maximus"):
111
+ self.bot_name = bot_name
112
+ self._base_prompt = get_personality_prompt(bot_name)
113
+
114
+ def build_prompt(
115
+ self,
116
+ tone: ContactTone,
117
+ contact_name: str | None = None,
118
+ ) -> str:
119
+ """
120
+ Build a complete system prompt for a specific contact tone.
121
+
122
+ Args:
123
+ tone: The tone to use for the response
124
+ contact_name: Optional name of the contact for personalization
125
+
126
+ Returns:
127
+ Complete system prompt with tone-specific additions
128
+ """
129
+ prompt = self._base_prompt
130
+
131
+ # Add tone-specific prompt
132
+ tone_addition = TONE_PROMPTS.get(tone, TONE_PROMPTS[ContactTone.NEUTRAL])
133
+ prompt += "\n" + tone_addition
134
+
135
+ # Add contact name context if provided
136
+ if contact_name:
137
+ prompt += f"\n\nYou're currently chatting with {contact_name}."
138
+
139
+ return prompt
140
+
141
+ def get_tone_instruction(self, tone: ContactTone) -> str:
142
+ """
143
+ Get a brief tone instruction for the LLM.
144
+
145
+ This can be used as an additional instruction without the full prompt.
146
+ """
147
+ instructions = {
148
+ ContactTone.AFFECTIONATE: "Respond with warmth and affection. This is someone you care deeply about.",
149
+ ContactTone.LOVING: "Respond with deep love and care. This is your partner.",
150
+ ContactTone.FRIENDLY: "Respond like you're talking to a close family member - playful, teasing, but caring.",
151
+ ContactTone.CASUAL: "Respond like you're chatting with a good friend - relaxed and natural.",
152
+ ContactTone.SARCASTIC: "Respond with witty sarcasm and dry humor. Keep it playful, not mean.",
153
+ ContactTone.NEUTRAL: "Respond politely and helpfully, but maintain appropriate boundaries.",
154
+ }
155
+ return instructions.get(tone, instructions[ContactTone.NEUTRAL])
@@ -0,0 +1,343 @@
1
+ """Contact and Group Registry for config-driven identity resolution."""
2
+
3
+ import logging
4
+ import os
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ContactRole(Enum):
17
+ """Role categories for contacts."""
18
+
19
+ GIRLFRIEND = "girlfriend"
20
+ SISTER = "sister"
21
+ FRIEND = "friend"
22
+ FAMILY = "family"
23
+ COLLEAGUE = "colleague"
24
+ UNKNOWN = "unknown"
25
+
26
+
27
+ class ContactTone(Enum):
28
+ """Tone styles for responses."""
29
+
30
+ AFFECTIONATE = "affectionate" # Warm, pet names, supportive
31
+ LOVING = "loving" # Deep affection, intimate
32
+ FRIENDLY = "friendly" # Sibling vibes, playful teasing
33
+ CASUAL = "casual" # Relaxed friend energy
34
+ SARCASTIC = "sarcastic" # Witty, playful sarcasm
35
+ NEUTRAL = "neutral" # Polite acquaintance
36
+
37
+
38
+ class GroupCategory(Enum):
39
+ """Categories for group chats."""
40
+
41
+ FAMILY = "family"
42
+ FRIENDS = "friends"
43
+ WORK = "work"
44
+ UNKNOWN = "unknown"
45
+
46
+
47
+ class ReplyPolicy(Enum):
48
+ """Reply policies for chats."""
49
+
50
+ ALWAYS = "always" # Always respond
51
+ SELECTIVE = "selective" # Only when mentioned
52
+ NEVER = "never" # Never respond
53
+
54
+
55
+ @dataclass
56
+ class ContactProfile:
57
+ """Profile for a known contact."""
58
+
59
+ jid: str
60
+ name: str
61
+ role: ContactRole
62
+ tone: ContactTone
63
+ allow_proactive: bool = False
64
+ cooldown_override: int | None = None
65
+ imessage_id: str | None = None # Linked iMessage identifier
66
+
67
+ @classmethod
68
+ def from_dict(cls, jid: str, data: dict) -> "ContactProfile":
69
+ """Create a ContactProfile from config dict."""
70
+ return cls(
71
+ jid=jid,
72
+ name=data.get("name", "Unknown"),
73
+ role=ContactRole(data.get("role", "unknown")),
74
+ tone=ContactTone(data.get("tone", "neutral")),
75
+ allow_proactive=data.get("allow_proactive", False),
76
+ cooldown_override=data.get("cooldown_override"),
77
+ imessage_id=data.get("imessage_id"),
78
+ )
79
+
80
+
81
+ @dataclass
82
+ class GroupConfig:
83
+ """Configuration for a group chat."""
84
+
85
+ jid: str
86
+ name: str
87
+ category: GroupCategory
88
+ reply_policy: ReplyPolicy
89
+
90
+ @classmethod
91
+ def from_dict(cls, jid: str, data: dict) -> "GroupConfig":
92
+ """Create a GroupConfig from config dict."""
93
+ return cls(
94
+ jid=jid,
95
+ name=data.get("name", "Unknown Group"),
96
+ category=GroupCategory(data.get("category", "unknown")),
97
+ reply_policy=ReplyPolicy(data.get("reply_policy", "selective")),
98
+ )
99
+
100
+
101
+ @dataclass
102
+ class ContactDefaults:
103
+ """Default values for unknown contacts."""
104
+
105
+ role: ContactRole = ContactRole.UNKNOWN
106
+ tone: ContactTone = ContactTone.NEUTRAL
107
+ allow_proactive: bool = False
108
+ cooldown_override: int | None = None
109
+
110
+
111
+ @dataclass
112
+ class GroupDefaults:
113
+ """Default values for unknown groups."""
114
+
115
+ category: GroupCategory = GroupCategory.UNKNOWN
116
+ reply_policy: ReplyPolicy = ReplyPolicy.SELECTIVE
117
+
118
+
119
+ class ContactRegistry:
120
+ """Registry for resolving contact JIDs to profiles."""
121
+
122
+ def __init__(self, config_path: Path | None = None, auto_reload: bool = True):
123
+ self._contacts: dict[str, ContactProfile] = {}
124
+ self._imessage_lookup: dict[str, str] = {} # iMessage ID -> primary JID
125
+ self._defaults = ContactDefaults()
126
+ self._config_path = config_path
127
+ self._last_modified: float = 0
128
+ self._watcher_thread: threading.Thread | None = None
129
+ self._stop_watcher = threading.Event()
130
+
131
+ if config_path and config_path.exists():
132
+ self._load_config(config_path)
133
+ if auto_reload:
134
+ self._start_watcher()
135
+
136
+ def _load_config(self, config_path: Path) -> None:
137
+ """Load contact configuration from YAML file."""
138
+ try:
139
+ with open(config_path) as f:
140
+ config = yaml.safe_load(f) or {}
141
+
142
+ # Load contacts
143
+ contacts_data = config.get("contacts", {})
144
+ for jid, data in contacts_data.items():
145
+ try:
146
+ profile = ContactProfile.from_dict(jid, data)
147
+ self._contacts[jid] = profile
148
+ logger.debug(f"Loaded contact: {profile.name} ({jid})")
149
+
150
+ # Build iMessage lookup table
151
+ if profile.imessage_id:
152
+ imessage_key = f"imessage:{profile.imessage_id}"
153
+ self._imessage_lookup[imessage_key] = jid
154
+ logger.debug(f"Linked iMessage {profile.imessage_id} to {jid}")
155
+ except (ValueError, KeyError) as e:
156
+ logger.warning(f"Invalid contact config for {jid}: {e}")
157
+
158
+ # Load defaults
159
+ defaults_data = config.get("defaults", {})
160
+ if defaults_data:
161
+ self._defaults = ContactDefaults(
162
+ role=ContactRole(defaults_data.get("role", "unknown")),
163
+ tone=ContactTone(defaults_data.get("tone", "neutral")),
164
+ allow_proactive=defaults_data.get("allow_proactive", False),
165
+ cooldown_override=defaults_data.get("cooldown_override"),
166
+ )
167
+
168
+ self._last_modified = os.path.getmtime(config_path)
169
+ logger.info(f"Loaded {len(self._contacts)} contacts from {config_path}")
170
+
171
+ except Exception as e:
172
+ logger.error(f"Failed to load contacts config: {e}")
173
+
174
+ def _start_watcher(self) -> None:
175
+ """Start background thread to watch for config changes."""
176
+ if self._watcher_thread is not None:
177
+ return
178
+
179
+ def watch():
180
+ while not self._stop_watcher.is_set():
181
+ try:
182
+ if self._config_path and self._config_path.exists():
183
+ mtime = os.path.getmtime(self._config_path)
184
+ if mtime > self._last_modified:
185
+ logger.info("Contacts config changed, reloading...")
186
+ self._contacts.clear()
187
+ self._imessage_lookup.clear()
188
+ self._load_config(self._config_path)
189
+ except Exception as e:
190
+ logger.error(f"Error watching contacts config: {e}")
191
+ time.sleep(2) # Check every 2 seconds
192
+
193
+ self._watcher_thread = threading.Thread(target=watch, daemon=True)
194
+ self._watcher_thread.start()
195
+ logger.debug("Started contacts config watcher")
196
+
197
+ def stop_watcher(self) -> None:
198
+ """Stop the config watcher thread."""
199
+ self._stop_watcher.set()
200
+
201
+ def resolve(self, jid: str) -> ContactProfile:
202
+ """
203
+ Resolve a JID or iMessage identifier to a contact profile.
204
+
205
+ Supports:
206
+ - WhatsApp JIDs: "+14155551234@s.whatsapp.net"
207
+ - iMessage identifiers: "imessage:+14155551234" or "imessage:user@icloud.com"
208
+
209
+ Returns the configured profile if known, or a default profile if unknown.
210
+ """
211
+ # Direct lookup
212
+ if jid in self._contacts:
213
+ return self._contacts[jid]
214
+
215
+ # Try iMessage lookup (maps iMessage ID to WhatsApp contact)
216
+ if jid in self._imessage_lookup:
217
+ primary_jid = self._imessage_lookup[jid]
218
+ return self._contacts[primary_jid]
219
+
220
+ # For iMessage identifiers without prefix, try with prefix
221
+ if not jid.startswith("imessage:") and "@" not in jid:
222
+ imessage_key = f"imessage:{jid}"
223
+ if imessage_key in self._contacts:
224
+ return self._contacts[imessage_key]
225
+ if imessage_key in self._imessage_lookup:
226
+ primary_jid = self._imessage_lookup[imessage_key]
227
+ return self._contacts[primary_jid]
228
+
229
+ # Return default profile for unknown contacts
230
+ return ContactProfile(
231
+ jid=jid,
232
+ name="Unknown",
233
+ role=self._defaults.role,
234
+ tone=self._defaults.tone,
235
+ allow_proactive=self._defaults.allow_proactive,
236
+ cooldown_override=self._defaults.cooldown_override,
237
+ )
238
+
239
+ def is_known(self, jid: str) -> bool:
240
+ """Check if a contact is in the registry."""
241
+ return jid in self._contacts
242
+
243
+ def get_all_contacts(self) -> list[ContactProfile]:
244
+ """Get all registered contacts."""
245
+ return list(self._contacts.values())
246
+
247
+
248
+ class GroupRegistry:
249
+ """Registry for resolving group JIDs to configurations."""
250
+
251
+ def __init__(self, config_path: Path | None = None, auto_reload: bool = True):
252
+ self._groups: dict[str, GroupConfig] = {}
253
+ self._defaults = GroupDefaults()
254
+ self._config_path = config_path
255
+ self._last_modified: float = 0
256
+ self._watcher_thread: threading.Thread | None = None
257
+ self._stop_watcher = threading.Event()
258
+
259
+ if config_path and config_path.exists():
260
+ self._load_config(config_path)
261
+ if auto_reload:
262
+ self._start_watcher()
263
+
264
+ def _load_config(self, config_path: Path) -> None:
265
+ """Load group configuration from YAML file."""
266
+ try:
267
+ with open(config_path) as f:
268
+ config = yaml.safe_load(f) or {}
269
+
270
+ # Load groups
271
+ groups_data = config.get("groups", {})
272
+ for jid, data in groups_data.items():
273
+ try:
274
+ group_config = GroupConfig.from_dict(jid, data)
275
+ self._groups[jid] = group_config
276
+ logger.debug(f"Loaded group: {group_config.name} ({jid})")
277
+ except (ValueError, KeyError) as e:
278
+ logger.warning(f"Invalid group config for {jid}: {e}")
279
+
280
+ # Load defaults
281
+ defaults_data = config.get("defaults", {})
282
+ if defaults_data:
283
+ self._defaults = GroupDefaults(
284
+ category=GroupCategory(defaults_data.get("category", "unknown")),
285
+ reply_policy=ReplyPolicy(defaults_data.get("reply_policy", "selective")),
286
+ )
287
+
288
+ self._last_modified = os.path.getmtime(config_path)
289
+ logger.info(f"Loaded {len(self._groups)} groups from {config_path}")
290
+
291
+ except Exception as e:
292
+ logger.error(f"Failed to load groups config: {e}")
293
+
294
+ def _start_watcher(self) -> None:
295
+ """Start background thread to watch for config changes."""
296
+ if self._watcher_thread is not None:
297
+ return
298
+
299
+ def watch():
300
+ while not self._stop_watcher.is_set():
301
+ try:
302
+ if self._config_path and self._config_path.exists():
303
+ mtime = os.path.getmtime(self._config_path)
304
+ if mtime > self._last_modified:
305
+ logger.info("Groups config changed, reloading...")
306
+ self._groups.clear()
307
+ self._load_config(self._config_path)
308
+ except Exception as e:
309
+ logger.error(f"Error watching groups config: {e}")
310
+ time.sleep(2) # Check every 2 seconds
311
+
312
+ self._watcher_thread = threading.Thread(target=watch, daemon=True)
313
+ self._watcher_thread.start()
314
+ logger.debug("Started groups config watcher")
315
+
316
+ def stop_watcher(self) -> None:
317
+ """Stop the config watcher thread."""
318
+ self._stop_watcher.set()
319
+
320
+ def resolve(self, jid: str) -> GroupConfig:
321
+ """
322
+ Resolve a group JID to its configuration.
323
+
324
+ Returns the configured settings if known, or defaults if unknown.
325
+ """
326
+ if jid in self._groups:
327
+ return self._groups[jid]
328
+
329
+ # Return default config for unknown groups
330
+ return GroupConfig(
331
+ jid=jid,
332
+ name="Unknown Group",
333
+ category=self._defaults.category,
334
+ reply_policy=self._defaults.reply_policy,
335
+ )
336
+
337
+ def is_known(self, jid: str) -> bool:
338
+ """Check if a group is in the registry."""
339
+ return jid in self._groups
340
+
341
+ def get_all_groups(self) -> list[GroupConfig]:
342
+ """Get all registered groups."""
343
+ return list(self._groups.values())