hanzo-mcp 0.8.8__py3-none-any.whl → 0.9.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.
Potentially problematic release.
This version of hanzo-mcp might be problematic. Click here for more details.
- hanzo_mcp/__init__.py +1 -3
- hanzo_mcp/analytics/posthog_analytics.py +4 -17
- hanzo_mcp/bridge.py +9 -25
- hanzo_mcp/cli.py +8 -17
- hanzo_mcp/cli_enhanced.py +5 -14
- hanzo_mcp/cli_plugin.py +3 -9
- hanzo_mcp/config/settings.py +6 -20
- hanzo_mcp/config/tool_config.py +2 -4
- hanzo_mcp/core/base_agent.py +88 -88
- hanzo_mcp/core/model_registry.py +238 -210
- hanzo_mcp/dev_server.py +5 -15
- hanzo_mcp/prompts/__init__.py +2 -6
- hanzo_mcp/prompts/project_todo_reminder.py +3 -9
- hanzo_mcp/prompts/tool_explorer.py +1 -3
- hanzo_mcp/prompts/utils.py +7 -21
- hanzo_mcp/server.py +6 -7
- hanzo_mcp/tools/__init__.py +29 -32
- hanzo_mcp/tools/agent/__init__.py +2 -1
- hanzo_mcp/tools/agent/agent.py +10 -30
- hanzo_mcp/tools/agent/agent_tool.py +23 -17
- hanzo_mcp/tools/agent/claude_desktop_auth.py +3 -9
- hanzo_mcp/tools/agent/cli_agent_base.py +7 -24
- hanzo_mcp/tools/agent/cli_tools.py +76 -75
- hanzo_mcp/tools/agent/code_auth.py +1 -3
- hanzo_mcp/tools/agent/code_auth_tool.py +2 -6
- hanzo_mcp/tools/agent/critic_tool.py +8 -24
- hanzo_mcp/tools/agent/iching_tool.py +12 -36
- hanzo_mcp/tools/agent/network_tool.py +7 -18
- hanzo_mcp/tools/agent/prompt.py +1 -5
- hanzo_mcp/tools/agent/review_tool.py +10 -25
- hanzo_mcp/tools/agent/swarm_alias.py +1 -3
- hanzo_mcp/tools/agent/unified_cli_tools.py +38 -38
- hanzo_mcp/tools/common/batch_tool.py +15 -45
- hanzo_mcp/tools/common/config_tool.py +9 -28
- hanzo_mcp/tools/common/context.py +1 -3
- hanzo_mcp/tools/common/critic_tool.py +1 -3
- hanzo_mcp/tools/common/decorators.py +2 -6
- hanzo_mcp/tools/common/enhanced_base.py +2 -6
- hanzo_mcp/tools/common/fastmcp_pagination.py +4 -12
- hanzo_mcp/tools/common/forgiving_edit.py +9 -28
- hanzo_mcp/tools/common/mode.py +1 -5
- hanzo_mcp/tools/common/paginated_base.py +3 -11
- hanzo_mcp/tools/common/paginated_response.py +10 -30
- hanzo_mcp/tools/common/pagination.py +3 -9
- hanzo_mcp/tools/common/path_utils.py +34 -0
- hanzo_mcp/tools/common/permissions.py +14 -13
- hanzo_mcp/tools/common/personality.py +983 -701
- hanzo_mcp/tools/common/plugin_loader.py +3 -15
- hanzo_mcp/tools/common/stats.py +7 -19
- hanzo_mcp/tools/common/thinking_tool.py +1 -3
- hanzo_mcp/tools/common/tool_disable.py +2 -6
- hanzo_mcp/tools/common/tool_list.py +2 -6
- hanzo_mcp/tools/common/validation.py +1 -3
- hanzo_mcp/tools/compiler/__init__.py +8 -0
- hanzo_mcp/tools/compiler/sandboxed_compiler.py +681 -0
- hanzo_mcp/tools/config/config_tool.py +7 -13
- hanzo_mcp/tools/config/index_config.py +1 -3
- hanzo_mcp/tools/config/mode_tool.py +5 -15
- hanzo_mcp/tools/database/database_manager.py +3 -9
- hanzo_mcp/tools/database/graph.py +1 -3
- hanzo_mcp/tools/database/graph_add.py +3 -9
- hanzo_mcp/tools/database/graph_query.py +11 -34
- hanzo_mcp/tools/database/graph_remove.py +3 -9
- hanzo_mcp/tools/database/graph_search.py +6 -20
- hanzo_mcp/tools/database/graph_stats.py +11 -33
- hanzo_mcp/tools/database/sql.py +4 -12
- hanzo_mcp/tools/database/sql_query.py +6 -10
- hanzo_mcp/tools/database/sql_search.py +2 -6
- hanzo_mcp/tools/database/sql_stats.py +5 -15
- hanzo_mcp/tools/editor/neovim_command.py +1 -3
- hanzo_mcp/tools/editor/neovim_session.py +7 -13
- hanzo_mcp/tools/environment/__init__.py +8 -0
- hanzo_mcp/tools/environment/environment_detector.py +594 -0
- hanzo_mcp/tools/filesystem/__init__.py +28 -26
- hanzo_mcp/tools/filesystem/ast_multi_edit.py +14 -43
- hanzo_mcp/tools/filesystem/ast_tool.py +3 -0
- hanzo_mcp/tools/filesystem/base.py +20 -12
- hanzo_mcp/tools/filesystem/content_replace.py +7 -12
- hanzo_mcp/tools/filesystem/diff.py +2 -10
- hanzo_mcp/tools/filesystem/directory_tree.py +285 -51
- hanzo_mcp/tools/filesystem/edit.py +10 -18
- hanzo_mcp/tools/filesystem/find.py +312 -179
- hanzo_mcp/tools/filesystem/git_search.py +12 -24
- hanzo_mcp/tools/filesystem/multi_edit.py +10 -18
- hanzo_mcp/tools/filesystem/read.py +14 -30
- hanzo_mcp/tools/filesystem/rules_tool.py +9 -17
- hanzo_mcp/tools/filesystem/search.py +1160 -0
- hanzo_mcp/tools/filesystem/watch.py +2 -4
- hanzo_mcp/tools/filesystem/write.py +7 -10
- hanzo_mcp/tools/framework/__init__.py +8 -0
- hanzo_mcp/tools/framework/framework_modes.py +714 -0
- hanzo_mcp/tools/jupyter/base.py +6 -20
- hanzo_mcp/tools/jupyter/jupyter.py +4 -12
- hanzo_mcp/tools/llm/consensus_tool.py +8 -24
- hanzo_mcp/tools/llm/llm_manage.py +2 -6
- hanzo_mcp/tools/llm/llm_tool.py +17 -58
- hanzo_mcp/tools/llm/llm_unified.py +18 -59
- hanzo_mcp/tools/llm/provider_tools.py +1 -3
- hanzo_mcp/tools/lsp/lsp_tool.py +621 -481
- hanzo_mcp/tools/mcp/mcp_add.py +3 -5
- hanzo_mcp/tools/mcp/mcp_remove.py +1 -1
- hanzo_mcp/tools/mcp/mcp_stats.py +1 -3
- hanzo_mcp/tools/mcp/mcp_tool.py +9 -23
- hanzo_mcp/tools/memory/__init__.py +33 -40
- hanzo_mcp/tools/memory/conversation_memory.py +636 -0
- hanzo_mcp/tools/memory/knowledge_tools.py +7 -25
- hanzo_mcp/tools/memory/memory_tools.py +7 -19
- hanzo_mcp/tools/search/find_tool.py +12 -34
- hanzo_mcp/tools/search/unified_search.py +27 -81
- hanzo_mcp/tools/shell/__init__.py +16 -4
- hanzo_mcp/tools/shell/auto_background.py +2 -6
- hanzo_mcp/tools/shell/base.py +1 -5
- hanzo_mcp/tools/shell/base_process.py +5 -7
- hanzo_mcp/tools/shell/bash_session.py +7 -24
- hanzo_mcp/tools/shell/bash_session_executor.py +5 -15
- hanzo_mcp/tools/shell/bash_tool.py +3 -7
- hanzo_mcp/tools/shell/command_executor.py +26 -79
- hanzo_mcp/tools/shell/logs.py +4 -16
- hanzo_mcp/tools/shell/npx.py +2 -8
- hanzo_mcp/tools/shell/npx_tool.py +1 -3
- hanzo_mcp/tools/shell/pkill.py +4 -12
- hanzo_mcp/tools/shell/process_tool.py +2 -8
- hanzo_mcp/tools/shell/processes.py +5 -17
- hanzo_mcp/tools/shell/run_background.py +1 -3
- hanzo_mcp/tools/shell/run_command.py +1 -3
- hanzo_mcp/tools/shell/run_command_windows.py +1 -3
- hanzo_mcp/tools/shell/run_tool.py +56 -0
- hanzo_mcp/tools/shell/session_manager.py +2 -6
- hanzo_mcp/tools/shell/session_storage.py +2 -6
- hanzo_mcp/tools/shell/streaming_command.py +7 -23
- hanzo_mcp/tools/shell/uvx.py +4 -14
- hanzo_mcp/tools/shell/uvx_background.py +2 -6
- hanzo_mcp/tools/shell/uvx_tool.py +1 -3
- hanzo_mcp/tools/shell/zsh_tool.py +12 -20
- hanzo_mcp/tools/todo/todo.py +1 -3
- hanzo_mcp/tools/vector/__init__.py +97 -50
- hanzo_mcp/tools/vector/ast_analyzer.py +6 -20
- hanzo_mcp/tools/vector/git_ingester.py +10 -30
- hanzo_mcp/tools/vector/index_tool.py +3 -9
- hanzo_mcp/tools/vector/infinity_store.py +11 -30
- hanzo_mcp/tools/vector/mock_infinity.py +159 -0
- hanzo_mcp/tools/vector/node_tool.py +538 -0
- hanzo_mcp/tools/vector/project_manager.py +4 -12
- hanzo_mcp/tools/vector/unified_vector.py +384 -0
- hanzo_mcp/tools/vector/vector.py +2 -6
- hanzo_mcp/tools/vector/vector_index.py +8 -8
- hanzo_mcp/tools/vector/vector_search.py +7 -21
- {hanzo_mcp-0.8.8.dist-info → hanzo_mcp-0.9.0.dist-info}/METADATA +2 -2
- hanzo_mcp-0.9.0.dist-info/RECORD +191 -0
- hanzo_mcp/tools/agent/agent_tool_v1_deprecated.py +0 -645
- hanzo_mcp/tools/agent/swarm_tool.py +0 -723
- hanzo_mcp/tools/agent/swarm_tool_v1_deprecated.py +0 -577
- hanzo_mcp/tools/filesystem/batch_search.py +0 -900
- hanzo_mcp/tools/filesystem/directory_tree_paginated.py +0 -350
- hanzo_mcp/tools/filesystem/find_files.py +0 -369
- hanzo_mcp/tools/filesystem/grep.py +0 -467
- hanzo_mcp/tools/filesystem/search_tool.py +0 -767
- hanzo_mcp/tools/filesystem/symbols_tool.py +0 -515
- hanzo_mcp/tools/filesystem/tree.py +0 -270
- hanzo_mcp/tools/jupyter/notebook_edit.py +0 -317
- hanzo_mcp/tools/jupyter/notebook_read.py +0 -147
- hanzo_mcp/tools/todo/todo_read.py +0 -143
- hanzo_mcp/tools/todo/todo_write.py +0 -374
- hanzo_mcp-0.8.8.dist-info/RECORD +0 -192
- {hanzo_mcp-0.8.8.dist-info → hanzo_mcp-0.9.0.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.8.8.dist-info → hanzo_mcp-0.9.0.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.8.8.dist-info → hanzo_mcp-0.9.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""Conversation memory with vector store integration.
|
|
2
|
+
|
|
3
|
+
This module provides conversation history management with vector embeddings
|
|
4
|
+
for semantic search through past conversations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
import hashlib
|
|
11
|
+
import logging
|
|
12
|
+
import asyncio
|
|
13
|
+
from typing import Dict, List, Any, Optional, Tuple
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from dataclasses import dataclass, field, asdict
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from enum import Enum
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from hanzo_mcp.types import MCPResourceDocument
|
|
21
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MessageRole(Enum):
|
|
25
|
+
"""Message role in conversation."""
|
|
26
|
+
USER = "user"
|
|
27
|
+
ASSISTANT = "assistant"
|
|
28
|
+
SYSTEM = "system"
|
|
29
|
+
TOOL = "tool"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Message:
|
|
34
|
+
"""Represents a message in conversation."""
|
|
35
|
+
role: MessageRole
|
|
36
|
+
content: str
|
|
37
|
+
timestamp: float = field(default_factory=time.time)
|
|
38
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
embedding: Optional[np.ndarray] = None
|
|
40
|
+
id: Optional[str] = None
|
|
41
|
+
|
|
42
|
+
def __post_init__(self):
|
|
43
|
+
if not self.id:
|
|
44
|
+
# Generate unique ID
|
|
45
|
+
content_hash = hashlib.md5(
|
|
46
|
+
f"{self.role.value}{self.content}{self.timestamp}".encode()
|
|
47
|
+
).hexdigest()[:8]
|
|
48
|
+
self.id = f"msg_{content_hash}"
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
51
|
+
"""Convert to dictionary."""
|
|
52
|
+
data = {
|
|
53
|
+
"id": self.id,
|
|
54
|
+
"role": self.role.value,
|
|
55
|
+
"content": self.content,
|
|
56
|
+
"timestamp": self.timestamp,
|
|
57
|
+
"metadata": self.metadata,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if self.embedding is not None:
|
|
61
|
+
data["embedding"] = self.embedding.tolist()
|
|
62
|
+
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Message":
|
|
67
|
+
"""Create from dictionary."""
|
|
68
|
+
embedding = None
|
|
69
|
+
if "embedding" in data:
|
|
70
|
+
embedding = np.array(data["embedding"])
|
|
71
|
+
|
|
72
|
+
return cls(
|
|
73
|
+
id=data.get("id"),
|
|
74
|
+
role=MessageRole(data["role"]),
|
|
75
|
+
content=data["content"],
|
|
76
|
+
timestamp=data.get("timestamp", time.time()),
|
|
77
|
+
metadata=data.get("metadata", {}),
|
|
78
|
+
embedding=embedding,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Conversation:
|
|
84
|
+
"""Represents a conversation session."""
|
|
85
|
+
id: str
|
|
86
|
+
messages: List[Message] = field(default_factory=list)
|
|
87
|
+
created_at: float = field(default_factory=time.time)
|
|
88
|
+
updated_at: float = field(default_factory=time.time)
|
|
89
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
90
|
+
summary: Optional[str] = None
|
|
91
|
+
topics: List[str] = field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
def add_message(self, message: Message):
|
|
94
|
+
"""Add message to conversation."""
|
|
95
|
+
self.messages.append(message)
|
|
96
|
+
self.updated_at = time.time()
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
99
|
+
"""Convert to dictionary."""
|
|
100
|
+
return {
|
|
101
|
+
"id": self.id,
|
|
102
|
+
"messages": [m.to_dict() for m in self.messages],
|
|
103
|
+
"created_at": self.created_at,
|
|
104
|
+
"updated_at": self.updated_at,
|
|
105
|
+
"metadata": self.metadata,
|
|
106
|
+
"summary": self.summary,
|
|
107
|
+
"topics": self.topics,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Conversation":
|
|
112
|
+
"""Create from dictionary."""
|
|
113
|
+
messages = [Message.from_dict(m) for m in data.get("messages", [])]
|
|
114
|
+
|
|
115
|
+
return cls(
|
|
116
|
+
id=data["id"],
|
|
117
|
+
messages=messages,
|
|
118
|
+
created_at=data.get("created_at", time.time()),
|
|
119
|
+
updated_at=data.get("updated_at", time.time()),
|
|
120
|
+
metadata=data.get("metadata", {}),
|
|
121
|
+
summary=data.get("summary"),
|
|
122
|
+
topics=data.get("topics", []),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class EmbeddingProvider:
|
|
127
|
+
"""Provides text embeddings for vector search."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, model: str = "local"):
|
|
130
|
+
self.model = model
|
|
131
|
+
self.logger = logging.getLogger(__name__)
|
|
132
|
+
|
|
133
|
+
if model == "local":
|
|
134
|
+
# Use simple TF-IDF or word embeddings
|
|
135
|
+
self._init_local_embedder()
|
|
136
|
+
elif model == "openai":
|
|
137
|
+
# Use OpenAI embeddings
|
|
138
|
+
self._init_openai_embedder()
|
|
139
|
+
elif model == "sentence-transformers":
|
|
140
|
+
# Use sentence transformers
|
|
141
|
+
self._init_sentence_transformers()
|
|
142
|
+
|
|
143
|
+
def _init_local_embedder(self):
|
|
144
|
+
"""Initialize local embedder."""
|
|
145
|
+
# Simple word vector approach
|
|
146
|
+
self.vocab = {}
|
|
147
|
+
self.embedding_dim = 384
|
|
148
|
+
|
|
149
|
+
def _init_openai_embedder(self):
|
|
150
|
+
"""Initialize OpenAI embedder."""
|
|
151
|
+
# Requires OpenAI API key
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def _init_sentence_transformers(self):
|
|
155
|
+
"""Initialize sentence transformers."""
|
|
156
|
+
try:
|
|
157
|
+
from sentence_transformers import SentenceTransformer
|
|
158
|
+
self.model = SentenceTransformer('all-MiniLM-L6-v2')
|
|
159
|
+
self.embedding_dim = 384
|
|
160
|
+
except ImportError:
|
|
161
|
+
self.logger.warning("sentence-transformers not installed, falling back to local")
|
|
162
|
+
self._init_local_embedder()
|
|
163
|
+
|
|
164
|
+
async def embed_text(self, text: str) -> np.ndarray:
|
|
165
|
+
"""Generate embedding for text."""
|
|
166
|
+
|
|
167
|
+
if self.model == "local":
|
|
168
|
+
# Simple hash-based embedding
|
|
169
|
+
words = text.lower().split()
|
|
170
|
+
embedding = np.zeros(self.embedding_dim)
|
|
171
|
+
|
|
172
|
+
for word in words:
|
|
173
|
+
# Hash word to get consistent index
|
|
174
|
+
index = hash(word) % self.embedding_dim
|
|
175
|
+
embedding[index] += 1
|
|
176
|
+
|
|
177
|
+
# Normalize
|
|
178
|
+
norm = np.linalg.norm(embedding)
|
|
179
|
+
if norm > 0:
|
|
180
|
+
embedding = embedding / norm
|
|
181
|
+
|
|
182
|
+
return embedding
|
|
183
|
+
|
|
184
|
+
elif self.model == "sentence-transformers":
|
|
185
|
+
# Use sentence transformers
|
|
186
|
+
try:
|
|
187
|
+
from sentence_transformers import SentenceTransformer
|
|
188
|
+
model = SentenceTransformer('all-MiniLM-L6-v2')
|
|
189
|
+
embedding = model.encode(text)
|
|
190
|
+
return embedding
|
|
191
|
+
except:
|
|
192
|
+
# Fallback to local
|
|
193
|
+
return await self.embed_text(text)
|
|
194
|
+
|
|
195
|
+
else:
|
|
196
|
+
# Placeholder for other models
|
|
197
|
+
return np.random.randn(self.embedding_dim)
|
|
198
|
+
|
|
199
|
+
def cosine_similarity(self, a: np.ndarray, b: np.ndarray) -> float:
|
|
200
|
+
"""Calculate cosine similarity between embeddings."""
|
|
201
|
+
dot_product = np.dot(a, b)
|
|
202
|
+
norm_a = np.linalg.norm(a)
|
|
203
|
+
norm_b = np.linalg.norm(b)
|
|
204
|
+
|
|
205
|
+
if norm_a == 0 or norm_b == 0:
|
|
206
|
+
return 0.0
|
|
207
|
+
|
|
208
|
+
return dot_product / (norm_a * norm_b)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ConversationMemory(BaseTool):
|
|
212
|
+
"""Conversation memory with vector search."""
|
|
213
|
+
|
|
214
|
+
name = "conversation_memory"
|
|
215
|
+
description = """Manage conversation history with vector search.
|
|
216
|
+
|
|
217
|
+
Actions:
|
|
218
|
+
- add: Add message to current conversation
|
|
219
|
+
- search: Search through conversation history
|
|
220
|
+
- recall: Recall specific conversation
|
|
221
|
+
- summarize: Summarize conversation
|
|
222
|
+
- topics: Extract topics from conversation
|
|
223
|
+
- export: Export conversations
|
|
224
|
+
- import: Import conversations
|
|
225
|
+
- stats: Get memory statistics
|
|
226
|
+
|
|
227
|
+
This tool maintains conversation history with vector embeddings
|
|
228
|
+
for semantic search across all past conversations.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
def __init__(self, storage_path: Optional[str] = None):
|
|
232
|
+
super().__init__()
|
|
233
|
+
self.logger = logging.getLogger(__name__)
|
|
234
|
+
|
|
235
|
+
# Storage
|
|
236
|
+
if storage_path:
|
|
237
|
+
self.storage_path = Path(storage_path)
|
|
238
|
+
else:
|
|
239
|
+
self.storage_path = Path.home() / ".hanzo" / "conversations"
|
|
240
|
+
|
|
241
|
+
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
|
|
243
|
+
# Current conversation
|
|
244
|
+
self.current_conversation_id = self._generate_conversation_id()
|
|
245
|
+
self.conversations: Dict[str, Conversation] = {}
|
|
246
|
+
self.current_conversation = Conversation(id=self.current_conversation_id)
|
|
247
|
+
self.conversations[self.current_conversation_id] = self.current_conversation
|
|
248
|
+
|
|
249
|
+
# Embeddings
|
|
250
|
+
self.embedder = EmbeddingProvider(model="local")
|
|
251
|
+
|
|
252
|
+
# Load existing conversations
|
|
253
|
+
self._load_conversations()
|
|
254
|
+
|
|
255
|
+
def _generate_conversation_id(self) -> str:
|
|
256
|
+
"""Generate unique conversation ID."""
|
|
257
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
258
|
+
random_suffix = hashlib.md5(os.urandom(16)).hexdigest()[:6]
|
|
259
|
+
return f"conv_{timestamp}_{random_suffix}"
|
|
260
|
+
|
|
261
|
+
def _load_conversations(self):
|
|
262
|
+
"""Load conversations from storage."""
|
|
263
|
+
for conv_file in self.storage_path.glob("conv_*.json"):
|
|
264
|
+
try:
|
|
265
|
+
with open(conv_file, "r") as f:
|
|
266
|
+
data = json.load(f)
|
|
267
|
+
conv = Conversation.from_dict(data)
|
|
268
|
+
self.conversations[conv.id] = conv
|
|
269
|
+
except Exception as e:
|
|
270
|
+
self.logger.error(f"Failed to load {conv_file}: {e}")
|
|
271
|
+
|
|
272
|
+
def _save_conversation(self, conversation: Conversation):
|
|
273
|
+
"""Save conversation to storage."""
|
|
274
|
+
conv_file = self.storage_path / f"{conversation.id}.json"
|
|
275
|
+
|
|
276
|
+
with open(conv_file, "w") as f:
|
|
277
|
+
json.dump(conversation.to_dict(), f, indent=2)
|
|
278
|
+
|
|
279
|
+
async def add_message(
|
|
280
|
+
self,
|
|
281
|
+
role: str,
|
|
282
|
+
content: str,
|
|
283
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
284
|
+
) -> Message:
|
|
285
|
+
"""Add message to current conversation."""
|
|
286
|
+
|
|
287
|
+
# Create message
|
|
288
|
+
message = Message(
|
|
289
|
+
role=MessageRole(role.lower()),
|
|
290
|
+
content=content,
|
|
291
|
+
metadata=metadata or {},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Generate embedding
|
|
295
|
+
message.embedding = await self.embedder.embed_text(content)
|
|
296
|
+
|
|
297
|
+
# Add to conversation
|
|
298
|
+
self.current_conversation.add_message(message)
|
|
299
|
+
|
|
300
|
+
# Save periodically
|
|
301
|
+
if len(self.current_conversation.messages) % 10 == 0:
|
|
302
|
+
self._save_conversation(self.current_conversation)
|
|
303
|
+
|
|
304
|
+
return message
|
|
305
|
+
|
|
306
|
+
async def search_messages(
|
|
307
|
+
self,
|
|
308
|
+
query: str,
|
|
309
|
+
limit: int = 10,
|
|
310
|
+
conversation_id: Optional[str] = None,
|
|
311
|
+
role_filter: Optional[str] = None,
|
|
312
|
+
) -> List[Tuple[Message, float, str]]:
|
|
313
|
+
"""Search messages using vector similarity."""
|
|
314
|
+
|
|
315
|
+
# Generate query embedding
|
|
316
|
+
query_embedding = await self.embedder.embed_text(query)
|
|
317
|
+
|
|
318
|
+
# Search across conversations
|
|
319
|
+
results = []
|
|
320
|
+
|
|
321
|
+
conversations = (
|
|
322
|
+
[self.conversations[conversation_id]]
|
|
323
|
+
if conversation_id and conversation_id in self.conversations
|
|
324
|
+
else self.conversations.values()
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
for conv in conversations:
|
|
328
|
+
for msg in conv.messages:
|
|
329
|
+
# Apply role filter
|
|
330
|
+
if role_filter and msg.role.value != role_filter:
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
# Calculate similarity
|
|
334
|
+
if msg.embedding is not None:
|
|
335
|
+
similarity = self.embedder.cosine_similarity(
|
|
336
|
+
query_embedding, msg.embedding
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
results.append((msg, similarity, conv.id))
|
|
340
|
+
|
|
341
|
+
# Sort by similarity
|
|
342
|
+
results.sort(key=lambda x: x[1], reverse=True)
|
|
343
|
+
|
|
344
|
+
return results[:limit]
|
|
345
|
+
|
|
346
|
+
async def summarize_conversation(
|
|
347
|
+
self,
|
|
348
|
+
conversation_id: Optional[str] = None,
|
|
349
|
+
) -> str:
|
|
350
|
+
"""Generate conversation summary."""
|
|
351
|
+
|
|
352
|
+
conv = (
|
|
353
|
+
self.conversations.get(conversation_id, self.current_conversation)
|
|
354
|
+
if conversation_id
|
|
355
|
+
else self.current_conversation
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if not conv.messages:
|
|
359
|
+
return "No messages in conversation"
|
|
360
|
+
|
|
361
|
+
# Simple extractive summary
|
|
362
|
+
# In production, use LLM for abstractive summary
|
|
363
|
+
important_messages = []
|
|
364
|
+
|
|
365
|
+
for msg in conv.messages:
|
|
366
|
+
if msg.role == MessageRole.USER:
|
|
367
|
+
# Include user questions
|
|
368
|
+
important_messages.append(f"User: {msg.content[:100]}...")
|
|
369
|
+
elif msg.role == MessageRole.ASSISTANT:
|
|
370
|
+
# Include key assistant responses
|
|
371
|
+
if any(keyword in msg.content.lower() for keyword in
|
|
372
|
+
["important", "summary", "conclusion", "result"]):
|
|
373
|
+
important_messages.append(f"Assistant: {msg.content[:100]}...")
|
|
374
|
+
|
|
375
|
+
summary = "\n".join(important_messages[:5])
|
|
376
|
+
|
|
377
|
+
# Store summary
|
|
378
|
+
conv.summary = summary
|
|
379
|
+
self._save_conversation(conv)
|
|
380
|
+
|
|
381
|
+
return summary
|
|
382
|
+
|
|
383
|
+
async def extract_topics(
|
|
384
|
+
self,
|
|
385
|
+
conversation_id: Optional[str] = None,
|
|
386
|
+
) -> List[str]:
|
|
387
|
+
"""Extract topics from conversation."""
|
|
388
|
+
|
|
389
|
+
conv = (
|
|
390
|
+
self.conversations.get(conversation_id, self.current_conversation)
|
|
391
|
+
if conversation_id
|
|
392
|
+
else self.current_conversation
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Simple keyword extraction
|
|
396
|
+
# In production, use NLP for topic modeling
|
|
397
|
+
text = " ".join(msg.content for msg in conv.messages)
|
|
398
|
+
words = text.lower().split()
|
|
399
|
+
|
|
400
|
+
# Count word frequencies
|
|
401
|
+
word_freq = {}
|
|
402
|
+
stopwords = {"the", "is", "at", "which", "on", "a", "an", "and", "or", "but"}
|
|
403
|
+
|
|
404
|
+
for word in words:
|
|
405
|
+
if word not in stopwords and len(word) > 3:
|
|
406
|
+
word_freq[word] = word_freq.get(word, 0) + 1
|
|
407
|
+
|
|
408
|
+
# Get top topics
|
|
409
|
+
topics = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)
|
|
410
|
+
topic_words = [word for word, freq in topics[:10] if freq > 2]
|
|
411
|
+
|
|
412
|
+
# Store topics
|
|
413
|
+
conv.topics = topic_words
|
|
414
|
+
self._save_conversation(conv)
|
|
415
|
+
|
|
416
|
+
return topic_words
|
|
417
|
+
|
|
418
|
+
def get_statistics(self) -> Dict[str, Any]:
|
|
419
|
+
"""Get memory statistics."""
|
|
420
|
+
|
|
421
|
+
total_messages = sum(len(c.messages) for c in self.conversations.values())
|
|
422
|
+
total_conversations = len(self.conversations)
|
|
423
|
+
|
|
424
|
+
# Message distribution
|
|
425
|
+
role_distribution = {}
|
|
426
|
+
for conv in self.conversations.values():
|
|
427
|
+
for msg in conv.messages:
|
|
428
|
+
role = msg.role.value
|
|
429
|
+
role_distribution[role] = role_distribution.get(role, 0) + 1
|
|
430
|
+
|
|
431
|
+
# Storage size
|
|
432
|
+
total_size = sum(
|
|
433
|
+
f.stat().st_size
|
|
434
|
+
for f in self.storage_path.glob("conv_*.json")
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
"total_conversations": total_conversations,
|
|
439
|
+
"total_messages": total_messages,
|
|
440
|
+
"current_conversation_id": self.current_conversation_id,
|
|
441
|
+
"current_conversation_messages": len(self.current_conversation.messages),
|
|
442
|
+
"role_distribution": role_distribution,
|
|
443
|
+
"storage_size_bytes": total_size,
|
|
444
|
+
"storage_size_mb": total_size / (1024 * 1024),
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def export_conversations(
|
|
448
|
+
self,
|
|
449
|
+
format: str = "json",
|
|
450
|
+
conversation_ids: Optional[List[str]] = None,
|
|
451
|
+
) -> Dict[str, Any]:
|
|
452
|
+
"""Export conversations."""
|
|
453
|
+
|
|
454
|
+
conversations = (
|
|
455
|
+
{cid: self.conversations[cid] for cid in conversation_ids
|
|
456
|
+
if cid in self.conversations}
|
|
457
|
+
if conversation_ids
|
|
458
|
+
else self.conversations
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
if format == "json":
|
|
462
|
+
return {
|
|
463
|
+
"conversations": [c.to_dict() for c in conversations.values()],
|
|
464
|
+
"exported_at": time.time(),
|
|
465
|
+
"total": len(conversations),
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
elif format == "markdown":
|
|
469
|
+
md_content = []
|
|
470
|
+
|
|
471
|
+
for conv in conversations.values():
|
|
472
|
+
md_content.append(f"# Conversation {conv.id}")
|
|
473
|
+
md_content.append(f"Created: {datetime.fromtimestamp(conv.created_at)}")
|
|
474
|
+
|
|
475
|
+
if conv.summary:
|
|
476
|
+
md_content.append(f"\n## Summary\n{conv.summary}")
|
|
477
|
+
|
|
478
|
+
if conv.topics:
|
|
479
|
+
md_content.append(f"\n## Topics\n{', '.join(conv.topics)}")
|
|
480
|
+
|
|
481
|
+
md_content.append("\n## Messages\n")
|
|
482
|
+
|
|
483
|
+
for msg in conv.messages:
|
|
484
|
+
timestamp = datetime.fromtimestamp(msg.timestamp)
|
|
485
|
+
md_content.append(f"\n**{msg.role.value.title()}** ({timestamp}):")
|
|
486
|
+
md_content.append(msg.content)
|
|
487
|
+
|
|
488
|
+
md_content.append("\n---\n")
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
"content": "\n".join(md_content),
|
|
492
|
+
"format": "markdown",
|
|
493
|
+
"total": len(conversations),
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return {"error": f"Unknown format: {format}"}
|
|
497
|
+
|
|
498
|
+
async def run(
|
|
499
|
+
self,
|
|
500
|
+
action: str,
|
|
501
|
+
content: Optional[str] = None,
|
|
502
|
+
role: str = "user",
|
|
503
|
+
query: Optional[str] = None,
|
|
504
|
+
conversation_id: Optional[str] = None,
|
|
505
|
+
limit: int = 10,
|
|
506
|
+
format: str = "json",
|
|
507
|
+
**kwargs,
|
|
508
|
+
) -> MCPResourceDocument:
|
|
509
|
+
"""Execute memory action."""
|
|
510
|
+
|
|
511
|
+
if action == "add":
|
|
512
|
+
# Add message
|
|
513
|
+
if not content:
|
|
514
|
+
return MCPResourceDocument(data={"error": "Content required"})
|
|
515
|
+
|
|
516
|
+
message = await self.add_message(role, content, kwargs.get("metadata"))
|
|
517
|
+
|
|
518
|
+
return MCPResourceDocument(
|
|
519
|
+
data={
|
|
520
|
+
"message_id": message.id,
|
|
521
|
+
"conversation_id": self.current_conversation_id,
|
|
522
|
+
"timestamp": message.timestamp,
|
|
523
|
+
"message_count": len(self.current_conversation.messages),
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
elif action == "search":
|
|
528
|
+
# Search messages
|
|
529
|
+
if not query:
|
|
530
|
+
return MCPResourceDocument(data={"error": "Query required"})
|
|
531
|
+
|
|
532
|
+
results = await self.search_messages(
|
|
533
|
+
query, limit, conversation_id, kwargs.get("role_filter")
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
return MCPResourceDocument(
|
|
537
|
+
data={
|
|
538
|
+
"query": query,
|
|
539
|
+
"results": [
|
|
540
|
+
{
|
|
541
|
+
"message_id": msg.id,
|
|
542
|
+
"conversation_id": cid,
|
|
543
|
+
"role": msg.role.value,
|
|
544
|
+
"content": msg.content[:200],
|
|
545
|
+
"similarity": float(score),
|
|
546
|
+
"timestamp": msg.timestamp,
|
|
547
|
+
}
|
|
548
|
+
for msg, score, cid in results
|
|
549
|
+
],
|
|
550
|
+
"total": len(results),
|
|
551
|
+
}
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
elif action == "recall":
|
|
555
|
+
# Recall conversation
|
|
556
|
+
conv = self.conversations.get(
|
|
557
|
+
conversation_id or self.current_conversation_id
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
if not conv:
|
|
561
|
+
return MCPResourceDocument(
|
|
562
|
+
data={"error": f"Conversation not found: {conversation_id}"}
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
return MCPResourceDocument(data=conv.to_dict())
|
|
566
|
+
|
|
567
|
+
elif action == "summarize":
|
|
568
|
+
# Summarize conversation
|
|
569
|
+
summary = await self.summarize_conversation(conversation_id)
|
|
570
|
+
|
|
571
|
+
return MCPResourceDocument(
|
|
572
|
+
data={
|
|
573
|
+
"conversation_id": conversation_id or self.current_conversation_id,
|
|
574
|
+
"summary": summary,
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
elif action == "topics":
|
|
579
|
+
# Extract topics
|
|
580
|
+
topics = await self.extract_topics(conversation_id)
|
|
581
|
+
|
|
582
|
+
return MCPResourceDocument(
|
|
583
|
+
data={
|
|
584
|
+
"conversation_id": conversation_id or self.current_conversation_id,
|
|
585
|
+
"topics": topics,
|
|
586
|
+
}
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
elif action == "export":
|
|
590
|
+
# Export conversations
|
|
591
|
+
result = self.export_conversations(
|
|
592
|
+
format, kwargs.get("conversation_ids")
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
return MCPResourceDocument(data=result)
|
|
596
|
+
|
|
597
|
+
elif action == "stats":
|
|
598
|
+
# Get statistics
|
|
599
|
+
stats = self.get_statistics()
|
|
600
|
+
|
|
601
|
+
return MCPResourceDocument(data=stats)
|
|
602
|
+
|
|
603
|
+
elif action == "new":
|
|
604
|
+
# Start new conversation
|
|
605
|
+
self.current_conversation_id = self._generate_conversation_id()
|
|
606
|
+
self.current_conversation = Conversation(id=self.current_conversation_id)
|
|
607
|
+
self.conversations[self.current_conversation_id] = self.current_conversation
|
|
608
|
+
|
|
609
|
+
return MCPResourceDocument(
|
|
610
|
+
data={
|
|
611
|
+
"conversation_id": self.current_conversation_id,
|
|
612
|
+
"created": True,
|
|
613
|
+
}
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
else:
|
|
617
|
+
return MCPResourceDocument(
|
|
618
|
+
data={
|
|
619
|
+
"error": f"Unknown action: {action}",
|
|
620
|
+
"valid_actions": [
|
|
621
|
+
"add", "search", "recall", "summarize",
|
|
622
|
+
"topics", "export", "stats", "new"
|
|
623
|
+
],
|
|
624
|
+
}
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
async def call(self, **kwargs) -> str:
|
|
628
|
+
"""Tool interface for MCP."""
|
|
629
|
+
result = await self.run(**kwargs)
|
|
630
|
+
return result.to_json_string()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# Factory function
|
|
634
|
+
def create_conversation_memory(storage_path: Optional[str] = None):
|
|
635
|
+
"""Create conversation memory tool."""
|
|
636
|
+
return ConversationMemory(storage_path)
|