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,226 @@
1
+ """
2
+ YouClaw Scheduler Manager
3
+ Handles background tasks, heartbeats, and proactive notifications.
4
+ """
5
+
6
+ import logging
7
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
8
+ from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
9
+ from typing import Dict, Any, Callable, Optional
10
+ from datetime import datetime
11
+ import asyncio
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ class SchedulerManager:
16
+ """Manages proactive bot tasks and cron jobs"""
17
+
18
+ def __init__(self):
19
+ self.scheduler = AsyncIOScheduler()
20
+ self.bot_instance = None
21
+
22
+ def initialize(self, bot_instance, db_path: str):
23
+ """Initialize the scheduler with persistence"""
24
+ self.bot_instance = bot_instance
25
+
26
+ # Configure job store
27
+ job_stores = {
28
+ 'default': SQLAlchemyJobStore(url=f'sqlite:///{db_path}')
29
+ }
30
+
31
+ self.scheduler.configure(jobstores=job_stores)
32
+ self.scheduler.start()
33
+ logger.info("Scheduler initialized and started")
34
+
35
+ async def add_notification_job(
36
+ self,
37
+ platform: str,
38
+ user_id: str,
39
+ message: str,
40
+ run_date: datetime,
41
+ job_id: Optional[str] = None
42
+ ):
43
+ """Schedule a one-time notification message"""
44
+ self.scheduler.add_job(
45
+ self.send_notification,
46
+ 'date',
47
+ run_date=run_date,
48
+ args=[platform, user_id, message],
49
+ id=job_id,
50
+ replace_existing=True
51
+ )
52
+ logger.info(f"Scheduled notification for {user_id} on {platform} at {run_date}")
53
+
54
+ async def add_cron_job(
55
+ self,
56
+ platform: str,
57
+ user_id: str,
58
+ message_func: Callable,
59
+ cron_expression: str,
60
+ job_id: str
61
+ ):
62
+ """Schedule a recurring tasks using cron expression"""
63
+ # message_func should be an async function that returns a string to send
64
+ self.scheduler.add_job(
65
+ self.run_cron_task,
66
+ 'cron',
67
+ args=[platform, user_id, message_func],
68
+ id=job_id,
69
+ replace_existing=True,
70
+ **self._parse_cron(cron_expression)
71
+ )
72
+ logger.info(f"Scheduled cron job {job_id} for {user_id}")
73
+
74
+ def _parse_cron(self, expr: str) -> Dict:
75
+ """Simple parser for cron expressions (placeholder for robust parser)"""
76
+ # For now, expect a simple format or use default
77
+ # Format: "minute hour day month day_of_week"
78
+ parts = expr.split()
79
+ if len(parts) == 5:
80
+ return {
81
+ 'minute': parts[0],
82
+ 'hour': parts[1],
83
+ 'day': parts[2],
84
+ 'month': parts[3],
85
+ 'day_of_week': parts[4]
86
+ }
87
+ return {'hour': 8} # Default 8 AM daily
88
+
89
+ async def send_notification(self, platform: str, user_id: str, message: str):
90
+ """Actual delivery of the message via the bot handlers"""
91
+ await send_notification_task(platform, user_id, message)
92
+
93
+ async def add_ai_cron_job(self, platform: str, user_id: str, prompt: str, cron_expr: str, job_id: str):
94
+ """Schedule a recurring AI reasoning task"""
95
+ self.scheduler.add_job(
96
+ run_ai_job_task,
97
+ 'cron',
98
+ args=[platform, user_id, prompt],
99
+ id=job_id,
100
+ replace_existing=True,
101
+ **self._parse_cron(cron_expr)
102
+ )
103
+ logger.info(f"Scheduled AI Pulse job {job_id} for {user_id}: {prompt[:30]}...")
104
+
105
+ async def add_watcher_job(self, platform: str, user_id: str, target_url: str, interval_minutes: int, job_id: str):
106
+ """Schedule a background URL monitoring task"""
107
+ self.scheduler.add_job(
108
+ run_watcher_task,
109
+ 'interval',
110
+ minutes=interval_minutes,
111
+ args=[platform, user_id, target_url],
112
+ id=job_id,
113
+ replace_existing=True
114
+ )
115
+ logger.info(f"Scheduled Watcher job {job_id} for {user_id}: {target_url}")
116
+
117
+ # --- Top-Level Job Tasks (Moved outside class for serialization) ---
118
+
119
+ async def send_notification_task(platform: str, user_id: str, message: str):
120
+ """Actual delivery of the message via the bot handlers or history"""
121
+ from .memory_manager import memory_manager
122
+
123
+ try:
124
+ if platform == "telegram":
125
+ from .telegram_handler import telegram_handler
126
+ if telegram_handler.app:
127
+ await telegram_handler.app.bot.send_message(chat_id=user_id, text=message)
128
+ else:
129
+ logger.error("Telegram App not initialized")
130
+ elif platform == "discord":
131
+ from .discord_handler import discord_handler
132
+ if discord_handler.bot:
133
+ user = await discord_handler.bot.fetch_user(int(user_id))
134
+ if user:
135
+ await user.send(message)
136
+ else:
137
+ logger.error(f"Discord user {user_id} not found")
138
+ if platform == "dashboard" or True: # Always save to DB for history
139
+ await memory_manager.add_message(
140
+ platform=platform,
141
+ user_id=user_id,
142
+ role="assistant",
143
+ content=message,
144
+ metadata={"source": "scheduler"}
145
+ )
146
+ logger.info(f"Proactive message sent to {user_id} on {platform}")
147
+ except Exception as e:
148
+ logger.error(f"Failed to send proactive message: {e}")
149
+
150
+ async def run_cron_task_worker(platform: str, user_id: str, task_func: Callable):
151
+ """Run a recurring task and send its output"""
152
+ try:
153
+ message = await task_func()
154
+ if message:
155
+ await send_notification_task(platform, user_id, message)
156
+ except Exception as e:
157
+ logger.error(f"Error in cron task execution: {e}")
158
+
159
+ async def run_ai_job_task(platform: str, user_id: str, prompt: str):
160
+ """Executes a full AI reasoning loop and sends the result to the user"""
161
+ from .memory_manager import memory_manager
162
+ from .ollama_client import ollama_client
163
+ from datetime import datetime
164
+
165
+ logger.info(f"Running AI Cron Job for {user_id} on {platform}: {prompt[:50]}")
166
+
167
+ # Fetch recent history to avoid repetition
168
+ history = await memory_manager.get_conversation_history(platform, user_id, limit=5)
169
+
170
+ # Build context for the AI
171
+ context = {
172
+ "platform": platform,
173
+ "user_id": user_id,
174
+ "current_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
175
+ }
176
+
177
+ # Enhance prompt with variation instructions
178
+ enhanced_prompt = (
179
+ f"USER REQUEST: {prompt}\n\n"
180
+ f"CRITICAL: Do not repeat yourself. If you see in the history that you already said something, "
181
+ f"try a completely different approach or topic. Be creative!\n"
182
+ f"Current system time: {context['current_time']}"
183
+ )
184
+
185
+ try:
186
+ # Trigger full reasoning loop with history
187
+ response = await ollama_client.chat_with_tools(
188
+ messages=history + [{"role": "user", "content": enhanced_prompt}],
189
+ context=context
190
+ )
191
+
192
+ if response:
193
+ logger.info(f"AI Cron Job for {user_id} produced response (length: {len(response)}). Sending...")
194
+ await send_notification_task(platform, user_id, f"### ⚡ **Cron Job Update**\n\n{response}")
195
+ logger.info(f"AI Cron Job for {user_id} delivered.")
196
+ else:
197
+ logger.warning(f"AI Cron Job for {user_id} produced empty response.")
198
+ except Exception as e:
199
+ logger.error(f"Error in AI Cron Job execution: {e}", exc_info=True)
200
+
201
+ async def run_watcher_task(platform: str, user_id: str, target_url: str):
202
+ """Monitors a URL and notifies the user of status changes"""
203
+ import aiohttp
204
+
205
+ logger.info(f"Running Watcher for {user_id} on {platform}: {target_url}")
206
+
207
+ try:
208
+ async with aiohttp.ClientSession() as session:
209
+ async with session.get(target_url, timeout=10) as response:
210
+ status = response.status
211
+ if status != 200:
212
+ await send_notification_task(
213
+ platform,
214
+ user_id,
215
+ f"⚠️ **Watchdog Alert!**\n\nTarget `{target_url}` is reporting status: `{status}`."
216
+ )
217
+ except Exception as e:
218
+ logger.error(f"Watcher failed for {target_url}: {e}")
219
+ await send_notification_task(
220
+ platform,
221
+ user_id,
222
+ f"🚨 **Watchdog Failure!**\n\nI couldn't reach `{target_url}`. Error: `{str(e)}`"
223
+ )
224
+
225
+ # Global scheduler instance
226
+ scheduler_manager = SchedulerManager()
@@ -0,0 +1,66 @@
1
+ import aiohttp
2
+ import logging
3
+ import asyncio
4
+ from typing import List, Dict, Optional
5
+ from bs4 import BeautifulSoup
6
+ from .config import config
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class SearchClient:
11
+ """Client for performing searches via the self-hosted SearXNG engine"""
12
+
13
+ def __init__(self):
14
+ self.headers = {
15
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
16
+ }
17
+
18
+ async def search(self, query: str) -> str:
19
+ """
20
+ Perform a search on SearXNG and return a synthesized summary of findings.
21
+ """
22
+ url = config.search_url
23
+ logger.info(f"🛰️ Neural Search Initiated on {url}: {query}")
24
+
25
+ try:
26
+ async with aiohttp.ClientSession(headers=self.headers) as session:
27
+ async with session.get(url, params={"q": query}) as response:
28
+ if response.status != 200:
29
+ logger.error(f"Search engine returned status {response.status}")
30
+ return f"Search engine offline (Status {response.status})"
31
+
32
+ html = await response.text()
33
+ soup = BeautifulSoup(html, 'html.parser')
34
+
35
+ results = []
36
+ # SearXNG default theme (simple) result container
37
+ articles = soup.select('article.result') or soup.select('.result')
38
+
39
+ for i, article in enumerate(articles[:4]):
40
+ title_tag = article.select_one('h3 a, .title a')
41
+ snippet_tag = article.select_one('.content, .snippet')
42
+
43
+ if title_tag:
44
+ title = title_tag.get_text(strip=True)
45
+ link = title_tag.get('href', '')
46
+ snippet = snippet_tag.get_text(strip=True) if snippet_tag else "No details available."
47
+
48
+ # Clean up links (SearXNG sometimes wraps them)
49
+ if link.startswith('/'):
50
+ link = f"{url.split('/search')[0]}{link}"
51
+
52
+ results.append(f"SOURCE [{i+1}]: {title}\nURL: {link}\nSUMMARY: {snippet}")
53
+
54
+ if not results:
55
+ logger.warning("Search returned 0 results.")
56
+ return "No real-time data found in the neural streams."
57
+
58
+ logger.info(f"✅ Search complete. Found {len(results)} sources.")
59
+ return "\n\n".join(results)
60
+
61
+ except Exception as e:
62
+ logger.error(f"Search Execution Fault: {e}")
63
+ return f"Neural Search Error: {str(e)}"
64
+
65
+ # Global search client instance
66
+ search_client = SearchClient()
@@ -0,0 +1,127 @@
1
+ """
2
+ YouClaw Skill Manager
3
+ A system to register and execute bot capabilities (skills).
4
+ """
5
+
6
+ import logging
7
+ import inspect
8
+ import functools
9
+ import json
10
+ import os
11
+ import importlib.util
12
+ from typing import Dict, Any, Callable, List, Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class SkillManager:
17
+ """Manages bot skills and tool registration"""
18
+
19
+ def __init__(self):
20
+ self.skills: Dict[str, Dict[str, Any]] = {}
21
+ self.dynamic_dir = "dynamic_skills"
22
+ if not os.path.exists(self.dynamic_dir):
23
+ os.makedirs(self.dynamic_dir)
24
+
25
+ def skill(self, name: str = None, description: str = None, admin_only: bool = False):
26
+ """
27
+ Decorator to register a function as a skill.
28
+
29
+ Args:
30
+ name: Optional custom name for the skill (defaults to function name)
31
+ description: Optional description (defaults to function docstring)
32
+ """
33
+ def decorator(func: Callable):
34
+ skill_name = name or func.__name__
35
+ skill_description = description or (func.__doc__.strip() if func.__doc__ else "No description available")
36
+
37
+ # Extract parameters from signature
38
+ sig = inspect.signature(func)
39
+ parameters = {}
40
+ for param_name, param in sig.parameters.items():
41
+ parameters[param_name] = {
42
+ "type": str(param.annotation) if param.annotation != inspect.Parameter.empty else "string",
43
+ "default": param.default if param.default != inspect.Parameter.empty else None,
44
+ "required": param.default == inspect.Parameter.empty
45
+ }
46
+
47
+ self.skills[skill_name] = {
48
+ "name": skill_name,
49
+ "description": skill_description,
50
+ "func": func,
51
+ "parameters": parameters,
52
+ "is_async": inspect.iscoroutinefunction(func),
53
+ "admin_only": admin_only # Added admin_only flag
54
+ }
55
+
56
+ logger.info(f"registered skill: {skill_name} (admin_only: {admin_only})")
57
+
58
+ @functools.wraps(func)
59
+ def wrapper(*args, **kwargs):
60
+ return func(*args, **kwargs)
61
+ return wrapper
62
+
63
+ return decorator
64
+
65
+ async def get_skills_doc(self) -> str:
66
+ """Get tool definitions as a string for LLM prompting"""
67
+ tools_list = []
68
+ for name, info in self.skills.items():
69
+ params_str = ", ".join([f"{k} ({v['type']}{', required' if v['required'] else ''})" for k, v in info['parameters'].items()])
70
+ tools_list.append(f"- {name}: {info['description']}\n Params: {params_str}")
71
+
72
+ return "\n".join(tools_list)
73
+
74
+ def get_tool_definitions(self) -> str:
75
+ # Legacy support
76
+ return self.get_skills_doc()
77
+
78
+ async def execute_skill(self, name: str, arguments: Dict[str, Any]) -> Any:
79
+ """Execute a registered skill by name"""
80
+ if name not in self.skills:
81
+ return f"Error: Skill '{name}' not found"
82
+
83
+ skill = self.skills[name]
84
+
85
+ # Security: Admin-only skill gating
86
+ if skill.get('admin_only'):
87
+ platform = arguments.get('platform')
88
+ user_id = arguments.get('user_id')
89
+
90
+ # For now, we'll use a simple check. We could also check the DB users table.
91
+ from .config import config
92
+ admin_id = config.bot.admin_user_identity # e.g. "telegram:123456"
93
+ current_id = f"{platform}:{user_id}"
94
+
95
+ if current_id != admin_id:
96
+ logger.warning(f"🛑 Security Alert: Unauthorized access attempt to '{name}' by {current_id}")
97
+ return f"Permission Denied: Skill '{name}' is reserved for the bot administrator."
98
+
99
+ try:
100
+ logger.info(f"Executing skill '{name}' with args: {arguments}")
101
+ if skill['is_async']:
102
+ result = await skill['func'](**arguments)
103
+ else:
104
+ result = skill['func'](**arguments)
105
+ return result
106
+ except Exception as e:
107
+ logger.error(f"Error executing skill '{name}': {e}", exc_info=True)
108
+ return f"Error: {str(e)}"
109
+
110
+ def load_dynamic_skills(self):
111
+ """Scan dynamic_skills folder and import them"""
112
+ logger.info(f"Scanning for dynamic skills in {self.dynamic_dir}...")
113
+ for filename in os.listdir(self.dynamic_dir):
114
+ if filename.endswith(".py") and not filename.startswith("__"):
115
+ skill_name = filename[:-3]
116
+ path = os.path.join(self.dynamic_dir, filename)
117
+
118
+ try:
119
+ spec = importlib.util.spec_from_file_location(skill_name, path)
120
+ module = importlib.util.module_from_spec(spec)
121
+ spec.loader.exec_module(module)
122
+ logger.info(f"Loaded dynamic skill: {skill_name}")
123
+ except Exception as e:
124
+ logger.error(f"Failed to load dynamic skill {skill_name}: {e}")
125
+
126
+ # Global skill manager instance
127
+ skill_manager = SkillManager()
@@ -0,0 +1,181 @@
1
+ """
2
+ YouClaw Telegram Handler
3
+ Handles Telegram-specific message processing and bot interactions.
4
+ """
5
+
6
+ from telegram import Update
7
+ from telegram.ext import Application, MessageHandler, filters, ContextTypes
8
+ import logging
9
+ import asyncio
10
+ from typing import Optional
11
+ from .config import config
12
+ from .ollama_client import ollama_client
13
+ from .memory_manager import memory_manager
14
+ from .search_client import search_client
15
+ from .commands import command_handler
16
+ import base64
17
+ import io
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class TelegramHandler:
23
+ """Handles Telegram platform integration"""
24
+
25
+ def __init__(self):
26
+ self.app: Optional[Application] = None
27
+
28
+ async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
29
+ """Handle incoming Telegram messages"""
30
+ if not update.message or not update.message.text:
31
+ return
32
+
33
+ # Get user info
34
+ user_id = str(update.effective_user.id)
35
+ chat_id = str(update.effective_chat.id)
36
+ content = update.message.text
37
+ username = update.effective_user.username or update.effective_user.first_name
38
+
39
+ # Send typing action
40
+ await update.message.chat.send_action("typing")
41
+
42
+ # Check if it's a command
43
+ command_response = await command_handler.handle_command(
44
+ platform="telegram",
45
+ user_id=user_id,
46
+ message=content,
47
+ channel_id=chat_id
48
+ )
49
+
50
+ if command_response:
51
+ await self.send_message(update, command_response)
52
+ return
53
+
54
+ # Get user profile and onboarding status
55
+ profile = await memory_manager.get_user_profile(platform="telegram", user_id=user_id)
56
+
57
+ # Get conversation history
58
+ history = await memory_manager.get_conversation_history(
59
+ platform="telegram",
60
+ user_id=user_id,
61
+ channel_id=chat_id
62
+ )
63
+
64
+ # Add current message to history
65
+ await memory_manager.add_message(
66
+ platform="telegram",
67
+ user_id=user_id,
68
+ role="user",
69
+ content=content,
70
+ channel_id=chat_id,
71
+ metadata={"username": username}
72
+ )
73
+
74
+ # Build messages for LLM
75
+ messages = history + [{"role": "user", "content": content}]
76
+
77
+ # Get AI response
78
+ logger.info(f"Starting generic chat for user {user_id}...")
79
+
80
+ try:
81
+ # Use autonomous reasoning loop with tools (Reminders, etc.)
82
+ response = await ollama_client.chat_with_tools(
83
+ messages=messages,
84
+ user_profile=profile,
85
+ context={"platform": "telegram", "user_id": user_id}
86
+ )
87
+
88
+ if response and response.strip():
89
+ await self.send_message(update, response)
90
+ else:
91
+ await update.message.reply_text("Hmm, I'm not sure what to say.")
92
+
93
+ logger.info(f"Chat complete for user {user_id}")
94
+
95
+ except Exception as e:
96
+ logger.error(f"Error during reasoning: {e}")
97
+ error_msg = "I'm sorry, I hit a snag while thinking. Can you try again?"
98
+ await update.message.reply_text(error_msg)
99
+ response = error_msg
100
+
101
+ # Simple heuristic to 'complete' onboarding if they gave personal info
102
+ if not profile['onboarding_completed']:
103
+ if len(history) > 2:
104
+ await memory_manager.update_user_profile(
105
+ platform="telegram",
106
+ user_id=user_id,
107
+ onboarding_completed=True
108
+ )
109
+
110
+ # Save AI response to memory
111
+ await memory_manager.add_message(
112
+ platform="telegram",
113
+ user_id=user_id,
114
+ role="assistant",
115
+ content=response,
116
+ channel_id=chat_id
117
+ )
118
+
119
+ async def send_message(self, update: Update, content: str):
120
+ """Send a message, handling Telegram's 4096 char limit"""
121
+ if len(content) <= 4096:
122
+ await update.message.reply_text(content)
123
+ else:
124
+ # Split into chunks
125
+ chunks = [content[i:i+4096] for i in range(0, len(content), 4096)]
126
+ for chunk in chunks:
127
+ await update.message.reply_text(chunk)
128
+
129
+ async def start(self):
130
+ """Start the Telegram bot"""
131
+ if not config.telegram.enabled:
132
+ logger.info("Telegram is disabled in config")
133
+ return
134
+
135
+ logger.info("Starting Telegram bot...")
136
+
137
+ # Create application
138
+ self.app = Application.builder().token(config.telegram.token).build()
139
+
140
+ # Add message handler
141
+ self.app.add_handler(
142
+ MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_message)
143
+ )
144
+
145
+ # Add command handler for /start
146
+ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
147
+ await update.message.reply_text(
148
+ "🦞 **YouClaw - Your Personal AI Assistant**\n\n"
149
+ "Hello! I'm YouClaw, powered by local AI (Ollama).\n\n"
150
+ "Just send me a message and I'll respond intelligently. "
151
+ "I remember our conversations!\n\n"
152
+ "Type /help to see available commands."
153
+ )
154
+
155
+ from telegram.ext import CommandHandler
156
+ self.app.add_handler(CommandHandler("start", start_command))
157
+
158
+ # Start polling
159
+ await self.app.initialize()
160
+ await self.app.start()
161
+ await self.app.updater.start_polling()
162
+
163
+ logger.info("Telegram bot started")
164
+
165
+ # Keep alive until cancelled
166
+ try:
167
+ await asyncio.Event().wait()
168
+ except asyncio.CancelledError:
169
+ logger.info("Telegram handler task cancelled")
170
+
171
+ async def stop(self):
172
+ """Stop the Telegram bot"""
173
+ if self.app:
174
+ await self.app.updater.stop()
175
+ await self.app.stop()
176
+ await self.app.shutdown()
177
+ logger.info("Telegram bot stopped")
178
+
179
+
180
+ # Global Telegram handler instance
181
+ telegram_handler = TelegramHandler()
@@ -0,0 +1,94 @@
1
+ """
2
+ YouClaw Vector Manager
3
+ Handles semantic search and embedding storage for long-term memory.
4
+ """
5
+
6
+ import logging
7
+ import json
8
+ import numpy as np
9
+ from typing import List, Dict, Any, Optional
10
+ import aiosqlite
11
+ import base64
12
+ from ollama_client import ollama_client
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class VectorManager:
17
+ """Manages semantic memory using vector embeddings"""
18
+
19
+ def __init__(self, db_path: str):
20
+ self.db_path = db_path
21
+ self.db: Optional[aiosqlite.Connection] = None
22
+
23
+ async def initialize(self):
24
+ """Initialize the vector database table"""
25
+ self.db = await aiosqlite.connect(self.db_path)
26
+ await self.db.execute("""
27
+ CREATE TABLE IF NOT EXISTS vector_memory (
28
+ message_id INTEGER PRIMARY KEY,
29
+ embedding BLOB NOT NULL,
30
+ FOREIGN KEY (message_id) REFERENCES conversations (id)
31
+ )
32
+ """)
33
+ await self.db.commit()
34
+ logger.info("Vector manager initialized")
35
+
36
+ @staticmethod
37
+ def _encode_vector(vec: List[float]) -> bytes:
38
+ """Encode list of floats to binary for storage"""
39
+ return np.array(vec, dtype=np.float32).tobytes()
40
+
41
+ @staticmethod
42
+ def _decode_vector(bin_vec: bytes) -> np.ndarray:
43
+ """Decode binary back to numpy array"""
44
+ return np.frombuffer(bin_vec, dtype=np.float32)
45
+
46
+ async def save_embedding(self, message_id: int, text: str):
47
+ """Generate and save embedding for a message"""
48
+ embedding = await ollama_client.get_embeddings(text)
49
+ if not embedding:
50
+ logger.warning(f"Failed to generate embedding for message {message_id}")
51
+ return
52
+
53
+ bin_vec = self._encode_vector(embedding)
54
+ await self.db.execute(
55
+ "INSERT OR REPLACE INTO vector_memory (message_id, embedding) VALUES (?, ?)",
56
+ (message_id, bin_vec)
57
+ )
58
+ await self.db.commit()
59
+
60
+ async def search_semantic(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
61
+ """Search for semantically similar messages"""
62
+ query_vec = await ollama_client.get_embeddings(query)
63
+ if not query_vec:
64
+ return []
65
+
66
+ query_np = np.array(query_vec, dtype=np.float32)
67
+
68
+ # This is a brute-force search (fine for small local DBs)
69
+ # For larger DBs, we'd use HNSW or Faiss
70
+ results = []
71
+ async with self.db.execute("""
72
+ SELECT vm.message_id, vm.embedding, c.content, c.role, c.timestamp
73
+ FROM vector_memory vm
74
+ JOIN conversations c ON vm.message_id = c.id
75
+ """) as cursor:
76
+ async for msg_id, bin_vec, content, role, ts in cursor:
77
+ msg_vec = self._decode_vector(bin_vec)
78
+
79
+ # Cosine similarity
80
+ similarity = np.dot(query_np, msg_vec) / (np.linalg.norm(query_np) * np.linalg.norm(msg_vec))
81
+
82
+ results.append({
83
+ "id": msg_id,
84
+ "content": content,
85
+ "role": role,
86
+ "timestamp": ts,
87
+ "similarity": float(similarity)
88
+ })
89
+
90
+ # Sort by similarity and return top N
91
+ results.sort(key=lambda x: x['similarity'], reverse=True)
92
+ return results[:limit]
93
+
94
+ # The instance will be managed by memory_manager to avoid circular imports