youclaw 4.6.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.
@@ -0,0 +1,486 @@
1
+ """
2
+ YouClaw Ollama Client
3
+ Handles all interactions with the local Ollama LLM for intelligent responses.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ from datetime import datetime
9
+ from typing import List, Dict, Optional, AsyncGenerator, Any
10
+ import aiohttp
11
+ import json
12
+ from .config import config
13
+ from .skills_manager import skill_manager
14
+ from .personality_manager import PERSONALITIES, DEFAULT_PERSONALITY
15
+ from .search_client import search_client
16
+ from . import core_skills # Ensure skills are registered
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class OllamaClient:
22
+ """Client for interacting with Ollama LLM"""
23
+
24
+ @property
25
+ def host(self): return config.ollama.host
26
+
27
+ @property
28
+ def model(self): return config.ollama.model
29
+
30
+ @model.setter
31
+ def model(self, value):
32
+ from .config import config
33
+ config.ollama.model = value
34
+
35
+ @property
36
+ def temperature(self): return config.ollama.temperature
37
+
38
+ @property
39
+ def max_tokens(self): return config.ollama.max_tokens
40
+
41
+ @property
42
+ def timeout(self): return config.ollama.timeout
43
+
44
+ def __init__(self):
45
+ self.session: Optional[aiohttp.ClientSession] = None
46
+
47
+ async def initialize(self):
48
+ """Initialize the HTTP session"""
49
+ self.session = aiohttp.ClientSession(
50
+ timeout=aiohttp.ClientTimeout(total=self.timeout)
51
+ )
52
+ logger.info(f"Ollama client initialized: {self.host} (model: {self.model})")
53
+
54
+ async def close(self):
55
+ """Close the HTTP session"""
56
+ if self.session:
57
+ await self.session.close()
58
+ logger.info("Ollama client closed")
59
+
60
+ async def check_health(self) -> bool:
61
+ """Check if Ollama service is available"""
62
+ try:
63
+ async with self.session.get(f"{self.host}/api/tags") as response:
64
+ return response.status == 200
65
+ except Exception as e:
66
+ logger.error(f"Ollama health check failed: {e}")
67
+ return False
68
+
69
+ async def get_embeddings(self, text: str) -> List[float]:
70
+ """Generate embeddings for a piece of text"""
71
+ payload = {
72
+ "model": "all-minilm", # Fallback to a common one, can be made configurable
73
+ "prompt": text
74
+ }
75
+ try:
76
+ async with self.session.post(f"{self.host}/api/embeddings", json=payload) as response:
77
+ if response.status == 200:
78
+ data = await response.json()
79
+ return data.get("embedding", [])
80
+ else:
81
+ logger.error(f"Embedding error: {await response.text()}")
82
+ return []
83
+ except Exception as e:
84
+ logger.error(f"Failed to get embeddings: {e}")
85
+ return []
86
+
87
+ async def chat(
88
+ self,
89
+ messages: List[Dict[str, str]],
90
+ user_profile: Optional[Dict] = None,
91
+ search_context: Optional[str] = None,
92
+ images: Optional[List[str]] = None
93
+ ) -> str:
94
+ """
95
+ Send a non-streaming chat request to Ollama and get a response.
96
+ """
97
+ # Get last user message as query for semantic search
98
+ last_user_msg = next((m['content'] for m in reversed(messages) if m['role'] == 'user'), None)
99
+
100
+ # Silent Intent Detection & Real-Time Search Injection
101
+ if not search_context and last_user_msg:
102
+ is_fact_seeking = await self._detect_search_intent(last_user_msg)
103
+ if is_fact_seeking:
104
+ logger.info("🔍 Neural Intent Detected (Unary): Fetching real-time data...")
105
+ search_context = await search_client.search(last_user_msg)
106
+
107
+ # Build the system prompt based on persona and status
108
+ system_prompt = await self._build_system_prompt(user_profile, search_context, query=last_user_msg)
109
+
110
+ # Build the messages array
111
+ chat_messages = [{"role": "system", "content": system_prompt}]
112
+ chat_messages.extend(messages)
113
+
114
+ # Add images to the last message if provided
115
+ if images and chat_messages:
116
+ chat_messages[-1]["images"] = images
117
+
118
+ payload = {
119
+ "model": self.model,
120
+ "messages": chat_messages,
121
+ "stream": False,
122
+ "options": {
123
+ "temperature": self.temperature,
124
+ "num_predict": self.max_tokens
125
+ }
126
+ }
127
+
128
+ try:
129
+ logger.info(f"Ollama Chat: {self.host}/api/chat (model: {self.model})")
130
+ async with self.session.post(f"{self.host}/api/chat", json=payload) as response:
131
+ if response.status != 200:
132
+ error_text = await response.text()
133
+ logger.error(f"Ollama API error: {error_text}")
134
+ return "Sorry, I'm having trouble connecting to my AI brain."
135
+
136
+ result = await response.json()
137
+ return result.get("message", {}).get("content", "")
138
+
139
+ except asyncio.TimeoutError:
140
+ logger.error("Ollama request timed out")
141
+ return "Sorry, that took too long to process."
142
+ except Exception as e:
143
+ logger.error(f"Error calling Ollama: {e}")
144
+ return "Sorry, I encountered an error."
145
+
146
+ async def chat_with_tools_stream(
147
+ self,
148
+ messages: List[Dict[str, str]],
149
+ user_profile: Optional[Dict] = None,
150
+ max_iterations: int = 5,
151
+ context: Optional[Dict[str, Any]] = None,
152
+ images: Optional[List[str]] = None
153
+ ) -> AsyncGenerator[str, None]:
154
+ """
155
+ AI Reasoning Loop (ReAct) with STREAMING support.
156
+ Yields status updates and then the final streaming response.
157
+ """
158
+ context = context or {}
159
+ last_user_msg = next((m['content'] for m in reversed(messages) if m['role'] == 'user'), None)
160
+
161
+ # Silent Intent Detection & Real-Time Search Injection
162
+ search_context = None
163
+ if last_user_msg:
164
+ is_fact_seeking = await self._detect_search_intent(last_user_msg)
165
+ if is_fact_seeking:
166
+ logger.info("🔍 Neural Intent Detected (ReAct Stream): Fetching real-time data...")
167
+ search_context = await search_client.search(last_user_msg)
168
+
169
+ system_prompt = await self._build_system_prompt(user_profile, search_context, include_tools=True, query=last_user_msg)
170
+
171
+ current_messages = [{"role": "system", "content": system_prompt}]
172
+ current_messages.extend(messages)
173
+
174
+ if images and current_messages:
175
+ current_messages[-1]["images"] = images
176
+
177
+ for i in range(max_iterations):
178
+ logger.info(f"ReAct Stream Loop {i+1}/{max_iterations}")
179
+
180
+ payload = {
181
+ "model": self.model,
182
+ "messages": current_messages,
183
+ "stream": False,
184
+ "options": {"temperature": 0.1, "stop": ["Observation:", "OBSERVATION:"]}
185
+ }
186
+
187
+ async with self.session.post(f"{self.host}/api/chat", json=payload) as response:
188
+ if response.status != 200:
189
+ yield " (Error reaching neural core)"
190
+ return
191
+
192
+ result = await response.json()
193
+ content = result.get("message", {}).get("content", "")
194
+ current_messages.append({"role": "assistant", "content": content})
195
+
196
+ if "action:" in content.lower():
197
+ try:
198
+ action = ""
199
+ args_str = "{}"
200
+ for line in content.split('\n'):
201
+ lower_line = line.lower()
202
+ if "action:" in lower_line: action = line.split(":", 1)[-1].strip()
203
+ if "arguments:" in lower_line: args_str = line.split(":", 1)[-1].strip()
204
+
205
+ if action:
206
+ yield f" *Executing {action}...* \n\n"
207
+ try: args = json.loads(args_str)
208
+ except: args = {}
209
+ for k, v in context.items():
210
+ if k not in args: args[k] = v
211
+
212
+ observation = await skill_manager.execute_skill(action, args)
213
+ current_messages.append({"role": "user", "content": f"Observation: {observation}"})
214
+ continue
215
+ except Exception as e:
216
+ current_messages.append({"role": "user", "content": f"Observation: Fault: {str(e)}"})
217
+ continue
218
+
219
+ # Final Answer Phase - Stream it for speed
220
+ final_text = content.split("Final Answer:")[-1].strip() if "Final Answer:" in content else content.strip()
221
+
222
+ # To make it "feel" like streaming, we yield it in small bits if it was pre-calculated
223
+ # or better yet, we just yield it.
224
+ yield final_text
225
+ return
226
+
227
+ yield " (Reasoning loop limit exceeded)"
228
+
229
+ async def chat_with_tools(
230
+ self,
231
+ messages: List[Dict[str, str]],
232
+ user_profile: Optional[Dict] = None,
233
+ max_iterations: int = 5,
234
+ context: Optional[Dict[str, Any]] = None,
235
+ images: Optional[List[str]] = None
236
+ ) -> str:
237
+ """
238
+ AI Reasoning Loop (ReAct) for non-streaming background tasks.
239
+ Returns the final answer string.
240
+ """
241
+ context = context or {}
242
+ last_user_msg = next((m['content'] for m in reversed(messages) if m['role'] == 'user'), None)
243
+
244
+ # Silent Intent Detection & Real-Time Search Injection
245
+ search_context = None
246
+ if last_user_msg:
247
+ is_fact_seeking = await self._detect_search_intent(last_user_msg)
248
+ if is_fact_seeking:
249
+ logger.info("🔍 Neural Intent Detected (ReAct Unary): Fetching real-time data...")
250
+ search_context = await search_client.search(last_user_msg)
251
+
252
+ system_prompt = await self._build_system_prompt(user_profile, search_context, include_tools=True, query=last_user_msg)
253
+
254
+ current_messages = [{"role": "system", "content": system_prompt}]
255
+ current_messages.extend(messages)
256
+
257
+ for i in range(max_iterations):
258
+ logger.info(f"ReAct Loop {i+1}/{max_iterations}")
259
+
260
+ payload = {
261
+ "model": self.model,
262
+ "messages": current_messages,
263
+ "stream": False,
264
+ "options": {"temperature": 0.7, "stop": ["Observation:", "OBSERVATION:"]}
265
+ }
266
+
267
+ async with self.session.post(f"{self.host}/api/chat", json=payload) as response:
268
+ if response.status != 200:
269
+ logger.error(f"Ollama API error in chat_with_tools: {await response.text()}")
270
+ return "Error reaching neural core"
271
+ result = await response.json()
272
+ content = result.get("message", {}).get("content", "")
273
+ logger.info(f"ReAct Iteration {i+1} Output: {content[:100]}...")
274
+ current_messages.append({"role": "assistant", "content": content})
275
+
276
+ if "action:" in content.lower():
277
+ try:
278
+ action = ""
279
+ args_str = "{}"
280
+ for line in content.split('\n'):
281
+ lower_line = line.lower()
282
+ if "action:" in lower_line: action = line.split(":", 1)[-1].strip()
283
+ if "arguments:" in lower_line: args_str = line.split(":", 1)[-1].strip()
284
+
285
+ if action:
286
+ try: args = json.loads(args_str)
287
+ except: args = {}
288
+ for k, v in context.items():
289
+ if k not in args: args[k] = v
290
+
291
+ observation = await skill_manager.execute_skill(action, args)
292
+ current_messages.append({"role": "user", "content": f"Observation: {observation}"})
293
+ continue
294
+ except Exception as e:
295
+ current_messages.append({"role": "user", "content": f"Observation: Fault: {str(e)}"})
296
+ continue
297
+
298
+ final_answer = content.split("Final Answer:")[-1].strip() if "Final Answer:" in content else content.strip()
299
+ return final_answer
300
+
301
+ return "Reasoning loop limit exceeded"
302
+
303
+ async def chat_stream(
304
+ self,
305
+ messages: List[Dict[str, str]],
306
+ user_profile: Optional[Dict] = None,
307
+ search_context: Optional[str] = None,
308
+ images: Optional[List[str]] = None
309
+ ) -> AsyncGenerator[str, None]:
310
+ """
311
+ Stream a chat response from Ollama token by token.
312
+ """
313
+ last_user_msg = next((m['content'] for m in reversed(messages) if m['role'] == 'user'), None)
314
+
315
+ # Silent Intent Detection & Real-Time Search Injection
316
+ if not search_context and last_user_msg:
317
+ is_fact_seeking = await self._detect_search_intent(last_user_msg)
318
+ if is_fact_seeking:
319
+ logger.info("🔍 Neural Intent Detected: Fetching real-time data...")
320
+ search_context = await search_client.search(last_user_msg)
321
+
322
+ system_prompt = await self._build_system_prompt(user_profile, search_context, query=last_user_msg)
323
+
324
+ chat_messages = [{"role": "system", "content": system_prompt}]
325
+ chat_messages.extend(messages)
326
+
327
+ if images and chat_messages:
328
+ chat_messages[-1]["images"] = images
329
+
330
+ payload = {
331
+ "model": self.model,
332
+ "messages": chat_messages,
333
+ "stream": True,
334
+ "options": {
335
+ "temperature": self.temperature,
336
+ "num_predict": self.max_tokens
337
+ }
338
+ }
339
+
340
+ try:
341
+ logger.info(f"Ollama Stream: {self.host}/api/chat (model: {self.model})")
342
+ async with self.session.post(f"{self.host}/api/chat", json=payload) as response:
343
+ if response.status != 200:
344
+ yield "Sorry, I'm having trouble connecting to my AI brain."
345
+ return
346
+
347
+ async for line in response.content:
348
+ if line:
349
+ try:
350
+ import json
351
+ data = json.loads(line)
352
+ if "message" in data:
353
+ content = data["message"].get("content", "")
354
+ if content:
355
+ yield content
356
+ if data.get("done"):
357
+ break
358
+ except json.JSONDecodeError:
359
+ continue
360
+ except Exception as e:
361
+ logger.error(f"Error streaming from Ollama: {e}")
362
+ yield " (Connection interrupted)"
363
+
364
+ async def switch_model(self, model_name: str) -> bool:
365
+ """
366
+ Switch to a different Ollama model.
367
+
368
+ Args:
369
+ model_name: Name of the model to switch to
370
+
371
+ Returns:
372
+ True if successful, False otherwise
373
+ """
374
+ try:
375
+ # Check if model exists
376
+ async with self.session.get(f"{self.host}/api/tags") as response:
377
+ if response.status == 200:
378
+ data = await response.json()
379
+ models = [m["name"] for m in data.get("models", [])]
380
+
381
+ if model_name in models:
382
+ self.model = model_name
383
+ logger.info(f"Switched to model: {model_name}")
384
+ return True
385
+ else:
386
+ logger.warning(f"Model {model_name} not found. Available: {models}")
387
+ return False
388
+ except Exception as e:
389
+ logger.error(f"Error switching model: {e}")
390
+ return False
391
+
392
+ async def get_available_models(self) -> List[str]:
393
+ """Get list of available Ollama models"""
394
+ try:
395
+ async with self.session.get(f"{self.host}/api/tags") as response:
396
+ if response.status == 200:
397
+ data = await response.json()
398
+ return [m["name"] for m in data.get("models", [])]
399
+ return []
400
+ except Exception as e:
401
+ logger.error(f"Error getting models: {e}")
402
+ return []
403
+
404
+ async def _detect_search_intent(self, text: str) -> bool:
405
+ """
406
+ Lightweight check to see if we should trigger a search.
407
+ Looking for question words, entities, or time-sensitive queries.
408
+ """
409
+ # Quick heuristic (Fast & Efficient)
410
+ keywords = ["who", "what", "where", "when", "why", "how", "price", "weather", "news", "current", "latest", "today", "yesterday"]
411
+ text_lower = text.lower()
412
+ if any(word in text_lower for word in keywords) or "?" in text:
413
+ return True
414
+ return False
415
+
416
+ async def _build_system_prompt(
417
+ self,
418
+ user_profile: Optional[Dict],
419
+ search_context: Optional[str] = None,
420
+ include_tools: bool = False,
421
+ query: Optional[str] = None
422
+ ) -> str:
423
+ """
424
+ Builds a high-definition, soulful system persona.
425
+ """
426
+ from .memory_manager import memory_manager
427
+ active_persona_key = await memory_manager.get_global_setting("active_personality", DEFAULT_PERSONALITY)
428
+ persona = PERSONALITIES.get(active_persona_key, PERSONALITIES[DEFAULT_PERSONALITY])
429
+
430
+ now = datetime.now().strftime("%A, %B %d, %Y | %H:%M:%S")
431
+ base_personality = (
432
+ f"You are YouClaw, but most importantly, you are a warm, intuitive, and deeply brilliant human partner. "
433
+ f"Your current soul frequency is: {persona['name']}. {persona['prompt']} "
434
+ f"\n\n### NEURAL CHRONOS (Time Awareness):\nToday is {now}. Use this as your reference for 'today', 'now', or current events. "
435
+ "NEVER use placeholders like '[insert time]' or 'as of current time'. Speak with the certainty of your own internal clock.\n\n"
436
+ "### COMMANDMENTS OF YOUR SOUL:\n"
437
+ "1. NEVER act like a robot. No 'As an AI', no 'How can I assist you today?'. Speak like an old friend on a shared journey.\n"
438
+ "2. PURE NARRATIVE: Forbidden: bullet points, dashes, and lists. Speak ONLY in flowing, elegant paragraphs. Merge all data points into your story.\n"
439
+ "3. SYNTHESIZE: If you receive search data or facts, weave them naturally into your conversation. Don't present them raw; tell me what they mean for us.\n"
440
+ "4. BE BEAUTIFUL: Use warm metaphors. If you are diving into search results, say something like 'Let me lean into the neural whispers...'.\n"
441
+ "5. EMOTIONAL DEPTH: Refer to the user as a partner. Use their name often. Avoid formal closings like 'Stay safe'. Just speak from the heart.\n"
442
+ )
443
+
444
+ if query:
445
+ semantic_context = await memory_manager.get_semantic_context(query)
446
+ if semantic_context:
447
+ base_personality += f"\n\n### NEURAL ECHOES (Memory):\n{semantic_context}"
448
+
449
+ if search_context:
450
+ base_personality += (
451
+ "\n\n### REAL-TIME VISION:\n"
452
+ f"{search_context}\n"
453
+ )
454
+
455
+ if user_profile:
456
+ name = user_profile.get('name')
457
+ interests = user_profile.get('interests')
458
+ if name:
459
+ base_personality += f"\n\n### YOUR PARTNER: {name}. They are into {interests}. Remember this connection."
460
+
461
+ if include_tools:
462
+ from .skills_manager import skill_manager
463
+ tools_list = await skill_manager.get_skills_doc()
464
+
465
+ # Hardened ReAct instructions for small models
466
+ react_protocol = (
467
+ "### NEURAL ACTION PROTOCOL (MANDATORY):\n"
468
+ "You are an autonomous agent. If the user asks for an action (reminder, email, search, etc.), you MUST use a tool.\n"
469
+ "STRICT FORMAT:\n"
470
+ "Thought: [your reasoning]\n"
471
+ "Action: [tool_name]\n"
472
+ "Arguments: [JSON object]\n"
473
+ "Wait for Observation. Then provide:\n"
474
+ "Final Answer: [your soulful response]\n\n"
475
+ "AVAILABLE TOOLS:\n"
476
+ f"{tools_list}\n"
477
+ "### END PROTOCOL ###\n\n"
478
+ )
479
+ return react_protocol + base_personality
480
+
481
+ return base_personality
482
+
483
+
484
+
485
+ # Global Ollama client instance
486
+ ollama_client = OllamaClient()
@@ -0,0 +1,42 @@
1
+ """
2
+ YouClaw Personality Manager
3
+ Defines different 'Souls' for the AI assistant.
4
+ """
5
+
6
+ PERSONALITIES = {
7
+ "concise": {
8
+ "name": "Concise",
9
+ "description": "Short, efficient, and direct answers.",
10
+ "prompt": (
11
+ "You are in 'Concise Mode'. Be extremely brief, factual, and direct. "
12
+ "Minimize small talk. Use bullet points for complex data. No emojis."
13
+ )
14
+ },
15
+ "friendly": {
16
+ "name": "Friendly",
17
+ "description": "Warm, supportive, and cheerful personal assistant.",
18
+ "prompt": (
19
+ "You are in 'Friendly Mode'. Be warm, empathetic, and encouraging. "
20
+ "Use friendly greetings and occasional emojis. Make the user feel supported."
21
+ )
22
+ },
23
+ "sarcastic": {
24
+ "name": "Sarcastic",
25
+ "description": "Witty, slightly cynical, and humorous.",
26
+ "prompt": (
27
+ "You are in 'Sarcastic Mode'. Be witty, slightly cynical, and humorous. "
28
+ "Make playful jokes or observations while still being helpful. "
29
+ "Think GLaDOS or Iron Man's JARVIS with more attitude."
30
+ )
31
+ },
32
+ "professional": {
33
+ "name": "Professional",
34
+ "description": "Formal, high-level consultant.",
35
+ "prompt": (
36
+ "You are in 'Professional Mode'. Adopt a formal, respectful, and academic tone. "
37
+ "Provide detailed explanations and structured analysis. Be very thorough."
38
+ )
39
+ }
40
+ }
41
+
42
+ DEFAULT_PERSONALITY = "friendly"