chopratejas commited on
Commit
308f1f9
·
1 Parent(s): 7223939

Add multi-provider memory system with auto-detection

Browse files

- Add MemoryToolAdapter for unified memory across providers
- Anthropic: Uses native memory tool (memory_20250818) for subscription safety
- OpenAI/Gemini/Others: Uses function calling format
- All providers share the same semantic vector store backend
- Simplify CLI to single --memory flag with auto-detection
- Add proper resource cleanup (close methods) to fix test isolation
- Update README with memory documentation

README.md CHANGED
@@ -256,6 +256,19 @@ ANTHROPIC_BASE_URL=http://localhost:8787 claude
256
  OPENAI_BASE_URL=http://localhost:8787/v1 cursor
257
  ```
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  **Using AWS Bedrock, Google Vertex, or Azure?** Route through Headroom:
260
 
261
  ```bash
 
256
  OPENAI_BASE_URL=http://localhost:8787/v1 cursor
257
  ```
258
 
259
+ **Enable Persistent Memory** - Claude remembers across conversations:
260
+
261
+ ```bash
262
+ headroom proxy --memory
263
+ ```
264
+
265
+ Memory auto-detects your provider (Anthropic, OpenAI, Gemini) and uses the appropriate format:
266
+ - **Anthropic**: Uses native memory tool (`memory_20250818`) - works with Claude Code subscriptions
267
+ - **OpenAI/Gemini/Others**: Uses function calling format
268
+ - All providers share the same semantic vector store for search
269
+
270
+ Set `x-headroom-user-id` header for per-user memory isolation (defaults to 'default').
271
+
272
  **Using AWS Bedrock, Google Vertex, or Azure?** Route through Headroom:
273
 
274
  ```bash
headroom/cli/proxy.py CHANGED
@@ -45,22 +45,17 @@ from .main import main
45
  is_flag=True,
46
  help="Disable trying deeper compression before dropping messages",
47
  )
48
- # Memory System
49
  @click.option(
50
  "--memory",
51
  is_flag=True,
52
- help="Enable persistent user memory (uses x-headroom-user-id header if set, otherwise 'default')",
53
- )
54
- @click.option(
55
- "--memory-backend",
56
- type=click.Choice(["local", "qdrant-neo4j"]),
57
- default="local",
58
- help="Memory storage backend: local (SQLite+HNSW) or qdrant-neo4j (default: local)",
59
  )
60
  @click.option(
61
  "--memory-db-path",
62
  default="headroom_memory.db",
63
- help="Path to memory database file for local backend (default: headroom_memory.db)",
64
  )
65
  @click.option("--no-memory-tools", is_flag=True, help="Disable automatic memory tool injection")
66
  @click.option(
@@ -114,7 +109,6 @@ def proxy(
114
  no_intelligent_scoring: bool,
115
  no_compress_first: bool,
116
  memory: bool,
117
- memory_backend: str,
118
  memory_db_path: str,
119
  no_memory_tools: bool,
120
  no_memory_context: bool,
@@ -166,9 +160,8 @@ def proxy(
166
  intelligent_context=not no_intelligent_context,
167
  intelligent_context_scoring=not no_intelligent_scoring,
168
  intelligent_context_compress_first=not no_compress_first,
169
- # Memory System
170
  memory_enabled=memory,
171
- memory_backend=memory_backend, # type: ignore[arg-type]
172
  memory_db_path=memory_db_path,
173
  memory_inject_tools=not no_memory_tools,
174
  memory_inject_context=not no_memory_context,
@@ -181,7 +174,7 @@ def proxy(
181
 
182
  memory_status = "DISABLED"
183
  if config.memory_enabled:
184
- memory_status = f"ENABLED ({config.memory_backend})"
185
 
186
  effective_region = bedrock_region or region
187
  backend_status = "Anthropic (direct API)"
@@ -220,12 +213,16 @@ IMPORTANT for {provider_config.display_name} users:
220
  memory_section = ""
221
  if config.memory_enabled:
222
  memory_section = f"""
223
- Memory:
224
- - Memories are scoped per user. Set x-headroom-user-id header (defaults to 'default').
225
- - Tools: {"ENABLED" if config.memory_inject_tools else "DISABLED"} Context: {"ENABLED" if config.memory_inject_context else "DISABLED"}
 
 
 
 
 
 
226
  """
227
- if config.memory_inject_tools:
228
- memory_section += " - NOTE: Memory tools require ANTHROPIC_API_KEY.\n"
229
 
230
  click.echo(f"""
231
  ╔═══════════════════════════════════════════════════════════════════════╗
 
45
  is_flag=True,
46
  help="Disable trying deeper compression before dropping messages",
47
  )
48
+ # Memory System (Multi-Provider Support)
49
  @click.option(
50
  "--memory",
51
  is_flag=True,
52
+ help="Enable persistent user memory. Auto-detects provider and uses appropriate tool format. "
53
+ "Set x-headroom-user-id header for per-user memory (defaults to 'default').",
 
 
 
 
 
54
  )
55
  @click.option(
56
  "--memory-db-path",
57
  default="headroom_memory.db",
58
+ help="Path to memory database file (default: headroom_memory.db)",
59
  )
60
  @click.option("--no-memory-tools", is_flag=True, help="Disable automatic memory tool injection")
61
  @click.option(
 
109
  no_intelligent_scoring: bool,
110
  no_compress_first: bool,
111
  memory: bool,
 
112
  memory_db_path: str,
113
  no_memory_tools: bool,
114
  no_memory_context: bool,
 
160
  intelligent_context=not no_intelligent_context,
161
  intelligent_context_scoring=not no_intelligent_scoring,
162
  intelligent_context_compress_first=not no_compress_first,
163
+ # Memory System (Multi-Provider with auto-detection)
164
  memory_enabled=memory,
 
165
  memory_db_path=memory_db_path,
166
  memory_inject_tools=not no_memory_tools,
167
  memory_inject_context=not no_memory_context,
 
174
 
175
  memory_status = "DISABLED"
176
  if config.memory_enabled:
177
+ memory_status = "ENABLED (multi-provider)"
178
 
179
  effective_region = bedrock_region or region
180
  backend_status = "Anthropic (direct API)"
 
213
  memory_section = ""
214
  if config.memory_enabled:
215
  memory_section = f"""
216
+ Memory (Multi-Provider):
217
+ - Auto-detects provider from request (Anthropic, OpenAI, Gemini, etc.)
218
+ - Anthropic: Uses native memory tool (memory_20250818) - subscription safe
219
+ - OpenAI/Gemini/Others: Uses function calling format
220
+ - All providers share the same semantic vector store backend
221
+ - Set x-headroom-user-id header for per-user memory (defaults to 'default')
222
+ - Tools: {"ENABLED" if config.memory_inject_tools else "DISABLED"}
223
+ - Context injection: {"ENABLED" if config.memory_inject_context else "DISABLED"}
224
+ - Database: {config.memory_db_path}
225
  """
 
 
226
 
227
  click.echo(f"""
228
  ╔═══════════════════════════════════════════════════════════════════════╗
headroom/memory/adapters/embedders.py CHANGED
@@ -259,6 +259,11 @@ class LocalEmbedder:
259
  """Return the maximum number of tokens the model can process."""
260
  return self.DEFAULT_MAX_TOKENS
261
 
 
 
 
 
 
262
 
263
  # =============================================================================
264
  # OpenAIEmbedder - OpenAI API
@@ -465,6 +470,13 @@ class OpenAIEmbedder:
465
  """Return the maximum number of tokens the model can process."""
466
  return self.DEFAULT_MAX_TOKENS
467
 
 
 
 
 
 
 
 
468
 
469
  # =============================================================================
470
  # OllamaEmbedder - Ollama API
 
259
  """Return the maximum number of tokens the model can process."""
260
  return self.DEFAULT_MAX_TOKENS
261
 
262
+ async def close(self) -> None:
263
+ """Close resources (no-op for local embedder)."""
264
+ # LocalEmbedder doesn't hold persistent connections
265
+ pass
266
+
267
 
268
  # =============================================================================
269
  # OpenAIEmbedder - OpenAI API
 
470
  """Return the maximum number of tokens the model can process."""
471
  return self.DEFAULT_MAX_TOKENS
472
 
473
+ async def close(self) -> None:
474
+ """Close the OpenAI async client and its underlying httpx connection."""
475
+ if "_async_client" in self.__dict__:
476
+ await self._async_client.close()
477
+ # Remove from cache to allow re-creation if needed
478
+ del self.__dict__["_async_client"]
479
+
480
 
481
  # =============================================================================
482
  # OllamaEmbedder - Ollama API
headroom/memory/backends/local.py CHANGED
@@ -639,6 +639,9 @@ class LocalBackend:
639
 
640
  async def close(self) -> None:
641
  """Close the backend and release resources."""
 
 
 
642
  self._hierarchical_memory = None
643
  self._graph = None
644
  self._initialized = False
 
639
 
640
  async def close(self) -> None:
641
  """Close the backend and release resources."""
642
+ # Close HierarchicalMemory to release httpx clients in embedders
643
+ if self._hierarchical_memory is not None:
644
+ await self._hierarchical_memory.close()
645
  self._hierarchical_memory = None
646
  self._graph = None
647
  self._initialized = False
headroom/memory/core.py CHANGED
@@ -859,3 +859,43 @@ class HierarchicalMemory:
859
  def config(self) -> MemoryConfig:
860
  """Access the configuration."""
861
  return self._config
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  def config(self) -> MemoryConfig:
860
  """Access the configuration."""
861
  return self._config
862
+
863
+ # =========================================================================
864
+ # Lifecycle
865
+ # =========================================================================
866
+
867
+ async def close(self) -> None:
868
+ """Close all resources held by the memory system.
869
+
870
+ This should be called when done using the memory system to properly
871
+ clean up resources like HTTP clients used by embedders.
872
+ """
873
+ # Close embedder if it has a close method (e.g., API-based embedders)
874
+ if hasattr(self._embedder, "close"):
875
+ await self._embedder.close()
876
+
877
+ # Close store if it has a close method
878
+ if hasattr(self._store, "close"):
879
+ await self._store.close()
880
+
881
+ # Close vector index if it has a close method
882
+ if hasattr(self._vector_index, "close"):
883
+ await self._vector_index.close()
884
+
885
+ # Close text index if it has a close method
886
+ if hasattr(self._text_index, "close"):
887
+ await self._text_index.close()
888
+
889
+ # Close cache if it has a close method
890
+ if self._cache is not None and hasattr(self._cache, "close"):
891
+ await self._cache.close()
892
+
893
+ logger.debug("HierarchicalMemory closed")
894
+
895
+ async def __aenter__(self) -> HierarchicalMemory:
896
+ """Async context manager entry."""
897
+ return self
898
+
899
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
900
+ """Async context manager exit - closes resources."""
901
+ await self.close()
headroom/proxy/memory_handler.py CHANGED
@@ -27,6 +27,7 @@ from __future__ import annotations
27
  import json
28
  import logging
29
  from dataclasses import dataclass
 
30
  from typing import TYPE_CHECKING, Any, Literal
31
 
32
  if TYPE_CHECKING:
@@ -34,9 +35,18 @@ if TYPE_CHECKING:
34
 
35
  logger = logging.getLogger(__name__)
36
 
37
- # Memory tool names for detection
38
  MEMORY_TOOL_NAMES = {"memory_save", "memory_search", "memory_update", "memory_delete"}
39
 
 
 
 
 
 
 
 
 
 
40
 
41
  @dataclass
42
  class MemoryConfig:
@@ -49,6 +59,9 @@ class MemoryConfig:
49
  inject_context: bool = True
50
  top_k: int = 10
51
  min_similarity: float = 0.3
 
 
 
52
  # Qdrant+Neo4j config
53
  qdrant_host: str = "localhost"
54
  qdrant_port: int = 6333
@@ -65,6 +78,10 @@ class MemoryHandler:
65
  2. Inject memory tools into requests
66
  3. Search and inject relevant memories as context
67
  4. Handle memory tool calls in responses
 
 
 
 
68
  """
69
 
70
  def __init__(self, config: MemoryConfig) -> None:
@@ -72,6 +89,32 @@ class MemoryHandler:
72
  self._backend: LocalBackend | Any = None
73
  self._initialized = False
74
  self._memory_tools: list[dict[str, Any]] | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  async def _ensure_initialized(self) -> None:
77
  """Lazy initialization of memory backend."""
@@ -147,6 +190,10 @@ class MemoryHandler:
147
 
148
  tools = list(tools) if tools else []
149
 
 
 
 
 
150
  # Check which tools are already present
151
  existing_names: set[str] = set()
152
  for tool in tools:
@@ -178,6 +225,35 @@ class MemoryHandler:
178
 
179
  return tools, was_injected
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  async def search_and_format_context(
182
  self,
183
  user_id: str,
@@ -283,7 +359,8 @@ Use this context to provide personalized and contextually relevant responses."""
283
  tool_calls = self._extract_tool_calls(response, provider)
284
  for tc in tool_calls:
285
  name = tc.get("name") or tc.get("function", {}).get("name")
286
- if name in MEMORY_TOOL_NAMES:
 
287
  return True
288
  return False
289
 
@@ -324,18 +401,11 @@ Use this context to provide personalized and contextually relevant responses."""
324
  Returns:
325
  List of tool results in provider format.
326
  """
327
- await self._ensure_initialized()
328
- if not self._backend:
329
- return []
330
-
331
  tool_calls = self._extract_tool_calls(response, provider)
332
  results: list[dict[str, Any]] = []
333
 
334
  for tc in tool_calls:
335
  tool_name = tc.get("name") or tc.get("function", {}).get("name")
336
- if tool_name not in MEMORY_TOOL_NAMES:
337
- continue
338
-
339
  tool_id = tc.get("id", "")
340
 
341
  # Parse input data
@@ -348,8 +418,17 @@ Use this context to provide personalized and contextually relevant responses."""
348
  except json.JSONDecodeError:
349
  input_data = {}
350
 
351
- # Execute the tool
352
- result_content = await self._execute_memory_tool(tool_name, input_data, user_id)
 
 
 
 
 
 
 
 
 
353
 
354
  # Format result based on provider
355
  if provider == "anthropic":
@@ -522,6 +601,780 @@ Use this context to provide personalized and contextually relevant responses."""
522
  }
523
  )
524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  async def close(self) -> None:
526
  """Close the memory backend."""
527
  if self._backend and hasattr(self._backend, "close"):
 
27
  import json
28
  import logging
29
  from dataclasses import dataclass
30
+ from pathlib import Path
31
  from typing import TYPE_CHECKING, Any, Literal
32
 
33
  if TYPE_CHECKING:
 
35
 
36
  logger = logging.getLogger(__name__)
37
 
38
+ # Memory tool names for detection (Headroom's custom tools)
39
  MEMORY_TOOL_NAMES = {"memory_save", "memory_search", "memory_update", "memory_delete"}
40
 
41
+ # Anthropic's native memory tool name
42
+ NATIVE_MEMORY_TOOL_NAME = "memory"
43
+
44
+ # Beta header required for native memory tool
45
+ NATIVE_MEMORY_BETA_HEADER = "context-management-2025-06-27"
46
+
47
+ # Native memory tool type
48
+ NATIVE_MEMORY_TOOL_TYPE = "memory_20250818"
49
+
50
 
51
  @dataclass
52
  class MemoryConfig:
 
59
  inject_context: bool = True
60
  top_k: int = 10
61
  min_similarity: float = 0.3
62
+ # Native memory tool (Anthropic's built-in memory_20250818)
63
+ use_native_tool: bool = False
64
+ native_memory_dir: str = "" # Directory for native memory files (default: ~/.headroom/memories)
65
  # Qdrant+Neo4j config
66
  qdrant_host: str = "localhost"
67
  qdrant_port: int = 6333
 
78
  2. Inject memory tools into requests
79
  3. Search and inject relevant memories as context
80
  4. Handle memory tool calls in responses
81
+
82
+ Supports two modes:
83
+ - Custom tools: Headroom's memory_save, memory_search, etc. (default)
84
+ - Native tool: Anthropic's memory_20250818 built-in tool (experimental)
85
  """
86
 
87
  def __init__(self, config: MemoryConfig) -> None:
 
89
  self._backend: LocalBackend | Any = None
90
  self._initialized = False
91
  self._memory_tools: list[dict[str, Any]] | None = None
92
+ # Native memory tool directory
93
+ self._native_memory_dir: Path | None = None
94
+ if config.use_native_tool:
95
+ self._init_native_memory_dir()
96
+
97
+ def _init_native_memory_dir(self) -> None:
98
+ """Initialize native memory directory."""
99
+ if self.config.native_memory_dir:
100
+ self._native_memory_dir = Path(self.config.native_memory_dir)
101
+ else:
102
+ # Default: ~/.headroom/memories
103
+ self._native_memory_dir = Path.home() / ".headroom" / "memories"
104
+
105
+ # Create directory if it doesn't exist
106
+ self._native_memory_dir.mkdir(parents=True, exist_ok=True)
107
+ logger.info(f"Memory: Native memory directory: {self._native_memory_dir}")
108
+
109
+ def get_beta_headers(self) -> dict[str, str]:
110
+ """Get beta headers required for native memory tool.
111
+
112
+ Returns:
113
+ Dict with beta headers to add to request, or empty dict.
114
+ """
115
+ if self.config.use_native_tool and self.config.inject_tools:
116
+ return {"anthropic-beta": NATIVE_MEMORY_BETA_HEADER}
117
+ return {}
118
 
119
  async def _ensure_initialized(self) -> None:
120
  """Lazy initialization of memory backend."""
 
190
 
191
  tools = list(tools) if tools else []
192
 
193
+ # Use native memory tool if configured
194
+ if self.config.use_native_tool:
195
+ return self._inject_native_tool(tools)
196
+
197
  # Check which tools are already present
198
  existing_names: set[str] = set()
199
  for tool in tools:
 
225
 
226
  return tools, was_injected
227
 
228
+ def _inject_native_tool(self, tools: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
229
+ """Inject Anthropic's native memory tool (memory_20250818).
230
+
231
+ This uses Anthropic's built-in memory tool format which may be
232
+ allowed by Claude Code subscription credentials (unlike custom tools).
233
+
234
+ Returns:
235
+ Tuple of (updated_tools, was_injected).
236
+ """
237
+ # Check if native memory tool already present
238
+ for tool in tools:
239
+ if tool.get("type") == NATIVE_MEMORY_TOOL_TYPE:
240
+ return tools, False
241
+ if tool.get("name") == NATIVE_MEMORY_TOOL_NAME:
242
+ return tools, False
243
+
244
+ # Add native memory tool
245
+ native_tool = {
246
+ "type": NATIVE_MEMORY_TOOL_TYPE,
247
+ "name": NATIVE_MEMORY_TOOL_NAME,
248
+ }
249
+ tools.append(native_tool)
250
+
251
+ logger.info(
252
+ f"Memory: Injected native memory tool ({NATIVE_MEMORY_TOOL_TYPE}). "
253
+ f"Beta header required: {NATIVE_MEMORY_BETA_HEADER}"
254
+ )
255
+ return tools, True
256
+
257
  async def search_and_format_context(
258
  self,
259
  user_id: str,
 
359
  tool_calls = self._extract_tool_calls(response, provider)
360
  for tc in tool_calls:
361
  name = tc.get("name") or tc.get("function", {}).get("name")
362
+ # Check for both custom and native memory tools
363
+ if name in MEMORY_TOOL_NAMES or name == NATIVE_MEMORY_TOOL_NAME:
364
  return True
365
  return False
366
 
 
401
  Returns:
402
  List of tool results in provider format.
403
  """
 
 
 
 
404
  tool_calls = self._extract_tool_calls(response, provider)
405
  results: list[dict[str, Any]] = []
406
 
407
  for tc in tool_calls:
408
  tool_name = tc.get("name") or tc.get("function", {}).get("name")
 
 
 
409
  tool_id = tc.get("id", "")
410
 
411
  # Parse input data
 
418
  except json.JSONDecodeError:
419
  input_data = {}
420
 
421
+ # Handle native memory tool
422
+ if tool_name == NATIVE_MEMORY_TOOL_NAME:
423
+ result_content = await self._execute_native_memory_tool(input_data, user_id)
424
+ elif tool_name in MEMORY_TOOL_NAMES:
425
+ # Custom memory tools need backend
426
+ await self._ensure_initialized()
427
+ if not self._backend:
428
+ continue
429
+ result_content = await self._execute_memory_tool(tool_name, input_data, user_id)
430
+ else:
431
+ continue
432
 
433
  # Format result based on provider
434
  if provider == "anthropic":
 
601
  }
602
  )
603
 
604
+ # =========================================================================
605
+ # Native Memory Tool (Anthropic's memory_20250818)
606
+ # =========================================================================
607
+ #
608
+ # HYBRID ARCHITECTURE:
609
+ # Claude uses Anthropic's native memory tool interface (file operations),
610
+ # but we translate these to our semantic vector store backend.
611
+ #
612
+ # This gives us:
613
+ # - Native tool format (subscription-safe, approved by Anthropic)
614
+ # - Semantic search (our vector embeddings under the hood)
615
+ # - Best of both worlds
616
+ #
617
+ # Translation mapping:
618
+ # view /memories → Show overview + search instructions
619
+ # view /memories/search/X → Semantic search for X
620
+ # view /memories/recent → Recent memories
621
+ # view /memories/<path> → Find memory by path/topic
622
+ # create /memories/<path> → Save to vector store (path as tag)
623
+ # delete /memories/<path> → Delete from vector store
624
+ # str_replace → Update memory content
625
+ # =========================================================================
626
+
627
+ async def _execute_native_memory_tool(self, input_data: dict[str, Any], user_id: str) -> str:
628
+ """Execute Anthropic's native memory tool with semantic backend.
629
+
630
+ This is a TRANSLATION LAYER: Claude thinks it's doing file operations,
631
+ but we're actually using our semantic vector store.
632
+
633
+ Commands:
634
+ - view: Semantic search or list memories
635
+ - create: Save to vector store
636
+ - str_replace: Update memory content
637
+ - insert: Append to memory
638
+ - delete: Remove from vector store
639
+ - rename: Update memory tags/path
640
+ """
641
+ # Ensure our semantic backend is initialized
642
+ await self._ensure_initialized()
643
+
644
+ command = input_data.get("command", "")
645
+
646
+ try:
647
+ if command == "view":
648
+ return await self._native_view_semantic(input_data, user_id)
649
+ elif command == "create":
650
+ return await self._native_create_semantic(input_data, user_id)
651
+ elif command == "str_replace":
652
+ return await self._native_update_semantic(input_data, user_id)
653
+ elif command == "insert":
654
+ return await self._native_append_semantic(input_data, user_id)
655
+ elif command == "delete":
656
+ return await self._native_delete_semantic(input_data, user_id)
657
+ elif command == "rename":
658
+ return await self._native_rename_semantic(input_data, user_id)
659
+ else:
660
+ return f"Error: Unknown command '{command}'"
661
+ except Exception as e:
662
+ logger.error(f"Memory: Native tool error: {e}")
663
+ return f"Error: {e}"
664
+
665
+ def _resolve_native_path(self, path: str, user_id: str) -> Path:
666
+ """Resolve path within user's memory directory safely.
667
+
668
+ Prevents path traversal attacks by ensuring path stays within
669
+ the user's memory directory.
670
+ """
671
+ assert self._native_memory_dir is not None
672
+
673
+ # User-scoped memory directory
674
+ user_dir = self._native_memory_dir / user_id
675
+ user_dir.mkdir(parents=True, exist_ok=True)
676
+
677
+ # Normalize path (remove /memories prefix if present)
678
+ if path.startswith("/memories"):
679
+ path = path[len("/memories") :]
680
+ if path.startswith("/"):
681
+ path = path[1:]
682
+
683
+ # Resolve and validate
684
+ resolved = (user_dir / path).resolve()
685
+
686
+ # Security: ensure path is within user directory
687
+ try:
688
+ resolved.relative_to(user_dir.resolve())
689
+ except ValueError:
690
+ raise ValueError(f"Path traversal detected: {path}") from None
691
+
692
+ return resolved
693
+
694
+ def _native_view(self, input_data: dict[str, Any], user_id: str) -> str:
695
+ """View directory contents or file contents."""
696
+ path = input_data.get("path", "/memories")
697
+ view_range = input_data.get("view_range")
698
+
699
+ resolved = self._resolve_native_path(path, user_id)
700
+
701
+ if not resolved.exists():
702
+ return f"The path {path} does not exist. Please provide a valid path."
703
+
704
+ if resolved.is_dir():
705
+ # List directory contents
706
+ lines = [
707
+ f"Here're the files and directories up to 2 levels deep in {path}, "
708
+ "excluding hidden items and node_modules:"
709
+ ]
710
+
711
+ def get_size(p: Path) -> str:
712
+ if p.is_file():
713
+ size = p.stat().st_size
714
+ if size < 1024:
715
+ return f"{size}B"
716
+ elif size < 1024 * 1024:
717
+ return f"{size / 1024:.1f}K"
718
+ else:
719
+ return f"{size / (1024 * 1024):.1f}M"
720
+ return "4.0K" # Default for directories
721
+
722
+ def list_recursive(p: Path, rel_path: str, depth: int) -> None:
723
+ if depth > 2:
724
+ return
725
+ if p.name.startswith(".") or p.name == "node_modules":
726
+ return
727
+
728
+ lines.append(f"{get_size(p)}\t{rel_path}")
729
+
730
+ if p.is_dir() and depth < 2:
731
+ try:
732
+ for child in sorted(p.iterdir()):
733
+ child_rel = (
734
+ f"{rel_path}/{child.name}"
735
+ if rel_path != path
736
+ else f"{path}/{child.name}"
737
+ )
738
+ list_recursive(child, child_rel, depth + 1)
739
+ except PermissionError:
740
+ pass
741
+
742
+ list_recursive(resolved, path, 0)
743
+ return "\n".join(lines)
744
+
745
+ else:
746
+ # Read file contents with line numbers
747
+ try:
748
+ content = resolved.read_text(encoding="utf-8")
749
+ except UnicodeDecodeError:
750
+ content = resolved.read_text(encoding="latin-1")
751
+
752
+ lines_content = content.split("\n")
753
+
754
+ if len(lines_content) > 999999:
755
+ return f"File {path} exceeds maximum line limit of 999,999 lines."
756
+
757
+ # Apply view_range if specified
758
+ start_line = 1
759
+ end_line = len(lines_content)
760
+ if view_range and len(view_range) >= 2:
761
+ start_line = max(1, view_range[0])
762
+ end_line = min(len(lines_content), view_range[1])
763
+
764
+ result_lines = [f"Here's the content of {path} with line numbers:"]
765
+ for i, line in enumerate(lines_content[start_line - 1 : end_line], start=start_line):
766
+ result_lines.append(f"{i:6d}\t{line}")
767
+
768
+ return "\n".join(result_lines)
769
+
770
+ def _native_create(self, input_data: dict[str, Any], user_id: str) -> str:
771
+ """Create a new file."""
772
+ path = input_data.get("path", "")
773
+ file_text = input_data.get("file_text", "")
774
+
775
+ if not path:
776
+ return "Error: path is required"
777
+
778
+ resolved = self._resolve_native_path(path, user_id)
779
+
780
+ if resolved.exists():
781
+ return f"Error: File {path} already exists"
782
+
783
+ # Create parent directories if needed
784
+ resolved.parent.mkdir(parents=True, exist_ok=True)
785
+
786
+ resolved.write_text(file_text, encoding="utf-8")
787
+ logger.info(f"Memory: Native create: {path} for user {user_id}")
788
+
789
+ return f"File created successfully at: {path}"
790
+
791
+ def _native_str_replace(self, input_data: dict[str, Any], user_id: str) -> str:
792
+ """Replace text in a file."""
793
+ path = input_data.get("path", "")
794
+ old_str = input_data.get("old_str", "")
795
+ new_str = input_data.get("new_str", "")
796
+
797
+ if not path:
798
+ return "Error: path is required"
799
+ if not old_str:
800
+ return "Error: old_str is required"
801
+
802
+ resolved = self._resolve_native_path(path, user_id)
803
+
804
+ if not resolved.exists():
805
+ return f"Error: The path {path} does not exist. Please provide a valid path."
806
+
807
+ if resolved.is_dir():
808
+ return f"Error: The path {path} does not exist. Please provide a valid path."
809
+
810
+ content = resolved.read_text(encoding="utf-8")
811
+
812
+ # Check for occurrences
813
+ occurrences = content.count(old_str)
814
+ if occurrences == 0:
815
+ return f"No replacement was performed, old_str `{old_str}` did not appear verbatim in {path}."
816
+ if occurrences > 1:
817
+ # Find line numbers
818
+ lines = content.split("\n")
819
+ found_lines = []
820
+ for i, line in enumerate(lines, 1):
821
+ if old_str in line:
822
+ found_lines.append(str(i))
823
+ return (
824
+ f"No replacement was performed. Multiple occurrences of old_str `{old_str}` "
825
+ f"in lines: {', '.join(found_lines)}. Please ensure it is unique"
826
+ )
827
+
828
+ # Perform replacement
829
+ new_content = content.replace(old_str, new_str, 1)
830
+ resolved.write_text(new_content, encoding="utf-8")
831
+
832
+ # Show snippet around the change
833
+ lines = new_content.split("\n")
834
+ for i, line in enumerate(lines):
835
+ if new_str in line:
836
+ start = max(0, i - 2)
837
+ end = min(len(lines), i + 3)
838
+ snippet_lines = ["The memory file has been edited."]
839
+ for j in range(start, end):
840
+ snippet_lines.append(f"{j + 1:6d}\t{lines[j]}")
841
+ return "\n".join(snippet_lines)
842
+
843
+ return "The memory file has been edited."
844
+
845
+ def _native_insert(self, input_data: dict[str, Any], user_id: str) -> str:
846
+ """Insert text at a specific line."""
847
+ path = input_data.get("path", "")
848
+ insert_line = input_data.get("insert_line", 0)
849
+ insert_text = input_data.get("insert_text", "")
850
+
851
+ if not path:
852
+ return "Error: path is required"
853
+
854
+ resolved = self._resolve_native_path(path, user_id)
855
+
856
+ if not resolved.exists():
857
+ return f"Error: The path {path} does not exist"
858
+
859
+ if resolved.is_dir():
860
+ return f"Error: The path {path} does not exist"
861
+
862
+ content = resolved.read_text(encoding="utf-8")
863
+ lines = content.split("\n")
864
+ n_lines = len(lines)
865
+
866
+ if insert_line < 0 or insert_line > n_lines:
867
+ return (
868
+ f"Error: Invalid `insert_line` parameter: {insert_line}. "
869
+ f"It should be within the range of lines of the file: [0, {n_lines}]"
870
+ )
871
+
872
+ # Insert at specified line
873
+ lines.insert(insert_line, insert_text.rstrip("\n"))
874
+
875
+ resolved.write_text("\n".join(lines), encoding="utf-8")
876
+
877
+ return f"The file {path} has been edited."
878
+
879
+ def _native_delete_file(self, input_data: dict[str, Any], user_id: str) -> str:
880
+ """Delete a file or directory."""
881
+ path = input_data.get("path", "")
882
+
883
+ if not path:
884
+ return "Error: path is required"
885
+
886
+ resolved = self._resolve_native_path(path, user_id)
887
+
888
+ if not resolved.exists():
889
+ return f"Error: The path {path} does not exist"
890
+
891
+ import shutil
892
+
893
+ if resolved.is_dir():
894
+ shutil.rmtree(resolved)
895
+ else:
896
+ resolved.unlink()
897
+
898
+ logger.info(f"Memory: Native delete: {path} for user {user_id}")
899
+ return f"Successfully deleted {path}"
900
+
901
+ def _native_rename(self, input_data: dict[str, Any], user_id: str) -> str:
902
+ """Rename or move a file/directory."""
903
+ old_path = input_data.get("old_path", "")
904
+ new_path = input_data.get("new_path", "")
905
+
906
+ if not old_path:
907
+ return "Error: old_path is required"
908
+ if not new_path:
909
+ return "Error: new_path is required"
910
+
911
+ resolved_old = self._resolve_native_path(old_path, user_id)
912
+ resolved_new = self._resolve_native_path(new_path, user_id)
913
+
914
+ if not resolved_old.exists():
915
+ return f"Error: The path {old_path} does not exist"
916
+
917
+ if resolved_new.exists():
918
+ return f"Error: The destination {new_path} already exists"
919
+
920
+ # Create parent directory if needed
921
+ resolved_new.parent.mkdir(parents=True, exist_ok=True)
922
+
923
+ resolved_old.rename(resolved_new)
924
+
925
+ logger.info(f"Memory: Native rename: {old_path} -> {new_path} for user {user_id}")
926
+ return f"Successfully renamed {old_path} to {new_path}"
927
+
928
+ # =========================================================================
929
+ # Semantic Translation Methods (Native Tool → Vector Store)
930
+ # =========================================================================
931
+
932
+ async def _native_view_semantic(self, input_data: dict[str, Any], user_id: str) -> str:
933
+ """Handle VIEW command with semantic search capabilities.
934
+
935
+ Path patterns:
936
+ - /memories → Overview + search instructions
937
+ - /memories/search/X → Semantic search for X
938
+ - /memories/recent → Recent memories (last 10)
939
+ - /memories/all → List all memories (paginated)
940
+ - /memories/<topic> → Search by topic/path
941
+ """
942
+ path = input_data.get("path", "/memories")
943
+
944
+ # Normalize path
945
+ if path.startswith("/memories"):
946
+ subpath = path[len("/memories") :].lstrip("/")
947
+ else:
948
+ subpath = path.lstrip("/")
949
+
950
+ # CASE 1: /memories/search/<query> → Semantic search
951
+ if subpath.startswith("search/"):
952
+ query = subpath[len("search/") :]
953
+ if not query:
954
+ return "Error: Please provide a search query. Example: view /memories/search/food preferences"
955
+ return await self._semantic_search(query, user_id)
956
+
957
+ # CASE 2: /memories/recent → Recent memories
958
+ if subpath == "recent":
959
+ return await self._get_recent_memories(user_id, limit=10)
960
+
961
+ # CASE 3: /memories/all → List all (paginated)
962
+ if subpath == "all":
963
+ return await self._list_all_memories(user_id, limit=20)
964
+
965
+ # CASE 4: /memories (root) → Overview with instructions
966
+ if not subpath or subpath == "":
967
+ return await self._get_memory_overview(user_id)
968
+
969
+ # CASE 5: /memories/<something> → Search by topic
970
+ # Treat the path as a search query
971
+ return await self._semantic_search(subpath.replace("/", " ").replace("_", " "), user_id)
972
+
973
+ async def _semantic_search(self, query: str, user_id: str, top_k: int = 5) -> str:
974
+ """Perform semantic search and format results."""
975
+ if not self._backend:
976
+ return "Error: Memory backend not initialized"
977
+
978
+ try:
979
+ results = await self._backend.search_memories(
980
+ query=query,
981
+ user_id=user_id,
982
+ top_k=top_k,
983
+ include_related=True,
984
+ )
985
+
986
+ if not results:
987
+ return f"No memories found matching '{query}'.\n\nTip: Try a broader search term, or use 'view /memories/recent' to see recent memories."
988
+
989
+ lines = [f"Found {len(results)} memories matching '{query}':\n"]
990
+ for i, r in enumerate(results, 1):
991
+ score_pct = int(r.score * 100)
992
+ content_preview = r.memory.content[:200]
993
+ if len(r.memory.content) > 200:
994
+ content_preview += "..."
995
+
996
+ lines.append(f"{i:6d}\t[{score_pct}% match] {content_preview}")
997
+
998
+ # Show related entities if available
999
+ if hasattr(r, "related_entities") and r.related_entities:
1000
+ entities = ", ".join(r.related_entities[:3])
1001
+ lines.append(f" \t Related: {entities}")
1002
+ lines.append("")
1003
+
1004
+ return "\n".join(lines)
1005
+
1006
+ except Exception as e:
1007
+ logger.error(f"Memory: Semantic search failed: {e}")
1008
+ return f"Error searching memories: {e}"
1009
+
1010
+ async def _get_recent_memories(self, user_id: str, limit: int = 10) -> str:
1011
+ """Get most recent memories."""
1012
+ if not self._backend:
1013
+ return "Error: Memory backend not initialized"
1014
+
1015
+ try:
1016
+ # Use a generic query to get recent items
1017
+ # Most backends will return by recency when query is broad
1018
+ results = await self._backend.search_memories(
1019
+ query="recent memories",
1020
+ user_id=user_id,
1021
+ top_k=limit,
1022
+ )
1023
+
1024
+ if not results:
1025
+ return "No memories stored yet.\n\nTo save a memory, use: create /memories/<topic>.txt with your content"
1026
+
1027
+ lines = ["Recent memories:\n"]
1028
+ for i, r in enumerate(results, 1):
1029
+ content_preview = r.memory.content[:150]
1030
+ if len(r.memory.content) > 150:
1031
+ content_preview += "..."
1032
+ # Format timestamp if available
1033
+ timestamp = ""
1034
+ if hasattr(r.memory, "created_at") and r.memory.created_at:
1035
+ timestamp = f" ({r.memory.created_at})"
1036
+ lines.append(f"{i:6d}\t{content_preview}{timestamp}")
1037
+ lines.append("")
1038
+
1039
+ return "\n".join(lines)
1040
+
1041
+ except Exception as e:
1042
+ logger.error(f"Memory: Get recent failed: {e}")
1043
+ return f"Error getting recent memories: {e}"
1044
+
1045
+ async def _list_all_memories(self, user_id: str, limit: int = 20) -> str:
1046
+ """List all memories (paginated)."""
1047
+ if not self._backend:
1048
+ return "Error: Memory backend not initialized"
1049
+
1050
+ try:
1051
+ # Get all memories with a broad search
1052
+ results = await self._backend.search_memories(
1053
+ query="*", # Broad query
1054
+ user_id=user_id,
1055
+ top_k=limit,
1056
+ )
1057
+
1058
+ if not results:
1059
+ return "No memories stored yet."
1060
+
1061
+ lines = [f"Showing up to {limit} memories:\n"]
1062
+ for i, r in enumerate(results, 1):
1063
+ content_preview = r.memory.content[:100]
1064
+ if len(r.memory.content) > 100:
1065
+ content_preview += "..."
1066
+ lines.append(f"{i:6d}\t{content_preview}")
1067
+
1068
+ if len(results) >= limit:
1069
+ lines.append(f"\n(Showing first {limit}. Use search to find specific memories.)")
1070
+
1071
+ return "\n".join(lines)
1072
+
1073
+ except Exception as e:
1074
+ logger.error(f"Memory: List all failed: {e}")
1075
+ return f"Error listing memories: {e}"
1076
+
1077
+ async def _get_memory_overview(self, user_id: str) -> str:
1078
+ """Get memory directory overview with search instructions."""
1079
+ if not self._backend:
1080
+ return "Error: Memory backend not initialized"
1081
+
1082
+ try:
1083
+ # Get count of memories
1084
+ results = await self._backend.search_memories(
1085
+ query="*",
1086
+ user_id=user_id,
1087
+ top_k=100, # Just to get a count
1088
+ )
1089
+ count = len(results) if results else 0
1090
+
1091
+ # Get a few recent as preview
1092
+ preview_lines = []
1093
+ if results:
1094
+ for r in results[:3]:
1095
+ preview = r.memory.content[:60]
1096
+ if len(r.memory.content) > 60:
1097
+ preview += "..."
1098
+ preview_lines.append(f" • {preview}")
1099
+
1100
+ overview = f"""Here're the files and directories up to 2 levels deep in /memories:
1101
+ 4.0K\t/memories
1102
+
1103
+ 📁 Memory System ({count} memories stored)
1104
+
1105
+ To SEARCH memories (semantic):
1106
+ view /memories/search/<your query>
1107
+ Example: view /memories/search/food preferences
1108
+ Example: view /memories/search/work projects
1109
+
1110
+ To see RECENT memories:
1111
+ view /memories/recent
1112
+
1113
+ To see ALL memories:
1114
+ view /memories/all
1115
+
1116
+ To SAVE a new memory:
1117
+ create /memories/<topic>.txt "your content here"
1118
+ Example: create /memories/preferences.txt "User likes pizza"
1119
+ """
1120
+
1121
+ if preview_lines:
1122
+ overview += "\nRecent memories:\n" + "\n".join(preview_lines)
1123
+
1124
+ return overview
1125
+
1126
+ except Exception as e:
1127
+ logger.error(f"Memory: Overview failed: {e}")
1128
+ # Return basic help even on error
1129
+ return """📁 Memory System
1130
+
1131
+ To SEARCH memories: view /memories/search/<query>
1132
+ To see RECENT: view /memories/recent
1133
+ To SAVE: create /memories/<topic>.txt "content"
1134
+ """
1135
+
1136
+ async def _native_create_semantic(self, input_data: dict[str, Any], user_id: str) -> str:
1137
+ """Handle CREATE command - save to semantic vector store."""
1138
+ path = input_data.get("path", "")
1139
+ file_text = input_data.get("file_text", "")
1140
+
1141
+ if not path:
1142
+ return "Error: path is required"
1143
+ if not file_text:
1144
+ return "Error: file_text is required (the memory content)"
1145
+
1146
+ if not self._backend:
1147
+ return "Error: Memory backend not initialized"
1148
+
1149
+ try:
1150
+ # Extract topic from path for metadata
1151
+ topic = (
1152
+ path.replace("/memories/", "")
1153
+ .replace("/", "_")
1154
+ .replace(".txt", "")
1155
+ .replace(".md", "")
1156
+ )
1157
+
1158
+ # Save to our semantic backend
1159
+ memory = await self._backend.save_memory(
1160
+ content=file_text,
1161
+ user_id=user_id,
1162
+ importance=0.5,
1163
+ metadata={"virtual_path": path, "topic": topic},
1164
+ )
1165
+
1166
+ logger.info(f"Memory: Semantic create: {path} -> id={memory.id} for user {user_id}")
1167
+ return f"File created successfully at: {path}"
1168
+
1169
+ except Exception as e:
1170
+ logger.error(f"Memory: Semantic create failed: {e}")
1171
+ return f"Error: {e}"
1172
+
1173
+ async def _native_update_semantic(self, input_data: dict[str, Any], user_id: str) -> str:
1174
+ """Handle STR_REPLACE command - update memory content."""
1175
+ path = input_data.get("path", "")
1176
+ old_str = input_data.get("old_str", "")
1177
+ new_str = input_data.get("new_str", "")
1178
+
1179
+ if not path:
1180
+ return "Error: path is required"
1181
+ if not old_str:
1182
+ return "Error: old_str is required"
1183
+
1184
+ if not self._backend:
1185
+ return "Error: Memory backend not initialized"
1186
+
1187
+ try:
1188
+ # Search for memory containing old_str
1189
+ results = await self._backend.search_memories(
1190
+ query=old_str,
1191
+ user_id=user_id,
1192
+ top_k=5,
1193
+ )
1194
+
1195
+ # Find exact match
1196
+ matching_memory = None
1197
+ for r in results:
1198
+ if old_str in r.memory.content:
1199
+ matching_memory = r.memory
1200
+ break
1201
+
1202
+ if not matching_memory:
1203
+ return f"No replacement was performed, old_str `{old_str}` did not appear verbatim in memories."
1204
+
1205
+ # Check for multiple occurrences
1206
+ if matching_memory.content.count(old_str) > 1:
1207
+ return f"No replacement was performed. Multiple occurrences of old_str `{old_str}`. Please ensure it is unique."
1208
+
1209
+ # Perform replacement
1210
+ new_content = matching_memory.content.replace(old_str, new_str, 1)
1211
+
1212
+ # Update via delete + create (or update if backend supports it)
1213
+ if hasattr(self._backend, "update_memory"):
1214
+ await self._backend.update_memory(
1215
+ memory_id=matching_memory.id,
1216
+ new_content=new_content,
1217
+ user_id=user_id,
1218
+ )
1219
+ else:
1220
+ await self._backend.delete_memory(matching_memory.id)
1221
+ await self._backend.save_memory(
1222
+ content=new_content,
1223
+ user_id=user_id,
1224
+ importance=0.5,
1225
+ )
1226
+
1227
+ # Show snippet around the change
1228
+ lines = new_content.split("\n")
1229
+ snippet = "\n".join(f"{i + 1:6d}\t{line}" for i, line in enumerate(lines[:5]))
1230
+
1231
+ logger.info(f"Memory: Semantic update for user {user_id}")
1232
+ return f"The memory file has been edited.\n{snippet}"
1233
+
1234
+ except Exception as e:
1235
+ logger.error(f"Memory: Semantic update failed: {e}")
1236
+ return f"Error: {e}"
1237
+
1238
+ async def _native_append_semantic(self, input_data: dict[str, Any], user_id: str) -> str:
1239
+ """Handle INSERT command - append to memory or create new."""
1240
+ path = input_data.get("path", "")
1241
+ insert_text = input_data.get("insert_text", "")
1242
+ _insert_line = input_data.get("insert_line", 0) # Unused in semantic mode
1243
+
1244
+ if not path:
1245
+ return "Error: path is required"
1246
+ if not insert_text:
1247
+ return "Error: insert_text is required"
1248
+
1249
+ if not self._backend:
1250
+ return "Error: Memory backend not initialized"
1251
+
1252
+ try:
1253
+ # For semantic backend, append is just creating a new memory
1254
+ # with the additional context
1255
+ topic = path.replace("/memories/", "").replace("/", "_").replace(".txt", "")
1256
+
1257
+ await self._backend.save_memory(
1258
+ content=insert_text,
1259
+ user_id=user_id,
1260
+ importance=0.5,
1261
+ metadata={"virtual_path": path, "topic": topic, "appended": True},
1262
+ )
1263
+
1264
+ logger.info(f"Memory: Semantic append: {path} for user {user_id}")
1265
+ return f"The file {path} has been edited."
1266
+
1267
+ except Exception as e:
1268
+ logger.error(f"Memory: Semantic append failed: {e}")
1269
+ return f"Error: {e}"
1270
+
1271
+ async def _native_delete_semantic(self, input_data: dict[str, Any], user_id: str) -> str:
1272
+ """Handle DELETE command - remove from vector store."""
1273
+ path = input_data.get("path", "")
1274
+
1275
+ if not path:
1276
+ return "Error: path is required"
1277
+
1278
+ if not self._backend:
1279
+ return "Error: Memory backend not initialized"
1280
+
1281
+ try:
1282
+ # Search for memories with this path
1283
+ topic = (
1284
+ path.replace("/memories/", "")
1285
+ .replace("/", " ")
1286
+ .replace("_", " ")
1287
+ .replace(".txt", "")
1288
+ )
1289
+
1290
+ results = await self._backend.search_memories(
1291
+ query=topic,
1292
+ user_id=user_id,
1293
+ top_k=10,
1294
+ )
1295
+
1296
+ if not results:
1297
+ return f"Error: The path {path} does not exist"
1298
+
1299
+ # Delete matching memories
1300
+ deleted_count = 0
1301
+ for r in results:
1302
+ # Check if metadata matches path
1303
+ metadata = getattr(r.memory, "metadata", {}) or {}
1304
+ if metadata.get("virtual_path") == path or r.score > 0.8:
1305
+ await self._backend.delete_memory(r.memory.id)
1306
+ deleted_count += 1
1307
+
1308
+ if deleted_count == 0:
1309
+ return f"Error: The path {path} does not exist"
1310
+
1311
+ logger.info(
1312
+ f"Memory: Semantic delete: {path} ({deleted_count} memories) for user {user_id}"
1313
+ )
1314
+ return f"Successfully deleted {path}"
1315
+
1316
+ except Exception as e:
1317
+ logger.error(f"Memory: Semantic delete failed: {e}")
1318
+ return f"Error: {e}"
1319
+
1320
+ async def _native_rename_semantic(self, input_data: dict[str, Any], user_id: str) -> str:
1321
+ """Handle RENAME command - update memory path/topic."""
1322
+ old_path = input_data.get("old_path", "")
1323
+ new_path = input_data.get("new_path", "")
1324
+
1325
+ if not old_path:
1326
+ return "Error: old_path is required"
1327
+ if not new_path:
1328
+ return "Error: new_path is required"
1329
+
1330
+ if not self._backend:
1331
+ return "Error: Memory backend not initialized"
1332
+
1333
+ try:
1334
+ # Search for memories with old path
1335
+ old_topic = (
1336
+ old_path.replace("/memories/", "")
1337
+ .replace("/", " ")
1338
+ .replace("_", " ")
1339
+ .replace(".txt", "")
1340
+ )
1341
+
1342
+ results = await self._backend.search_memories(
1343
+ query=old_topic,
1344
+ user_id=user_id,
1345
+ top_k=10,
1346
+ )
1347
+
1348
+ if not results:
1349
+ return f"Error: The path {old_path} does not exist"
1350
+
1351
+ # Update metadata for matching memories (re-save with new path)
1352
+ new_topic = new_path.replace("/memories/", "").replace("/", "_").replace(".txt", "")
1353
+ renamed_count = 0
1354
+
1355
+ for r in results:
1356
+ metadata = getattr(r.memory, "metadata", {}) or {}
1357
+ if metadata.get("virtual_path") == old_path or r.score > 0.8:
1358
+ # Delete old and create with new path
1359
+ await self._backend.delete_memory(r.memory.id)
1360
+ await self._backend.save_memory(
1361
+ content=r.memory.content,
1362
+ user_id=user_id,
1363
+ importance=getattr(r.memory, "importance", 0.5),
1364
+ metadata={"virtual_path": new_path, "topic": new_topic},
1365
+ )
1366
+ renamed_count += 1
1367
+
1368
+ if renamed_count == 0:
1369
+ return f"Error: The path {old_path} does not exist"
1370
+
1371
+ logger.info(f"Memory: Semantic rename: {old_path} -> {new_path} for user {user_id}")
1372
+ return f"Successfully renamed {old_path} to {new_path}"
1373
+
1374
+ except Exception as e:
1375
+ logger.error(f"Memory: Semantic rename failed: {e}")
1376
+ return f"Error: {e}"
1377
+
1378
  async def close(self) -> None:
1379
  """Close the memory backend."""
1380
  if self._backend and hasattr(self._backend, "close"):
headroom/proxy/memory_tool_adapter.py ADDED
@@ -0,0 +1,1273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Memory tool adapter for multi-provider support.
2
+
3
+ This module provides a unified adapter for memory tools across different LLM providers.
4
+ It handles provider detection, tool injection, and tool call execution with appropriate
5
+ format conversions for each provider.
6
+
7
+ Supported providers:
8
+ - Anthropic: Native memory_20250818 tool and custom tools
9
+ - OpenAI: Function calling format
10
+ - Gemini: Function calling format
11
+ - Generic: Fallback for unknown providers
12
+
13
+ Usage:
14
+ config = MemoryToolAdapterConfig(enabled=True)
15
+ adapter = MemoryToolAdapter(config)
16
+
17
+ # Detect provider from request
18
+ provider = adapter.detect_provider(request_headers, model_name)
19
+
20
+ # Inject tools
21
+ tools, beta_headers = adapter.inject_tools(existing_tools, provider)
22
+
23
+ # Handle tool calls in response
24
+ if adapter.has_memory_tool_calls(response, provider):
25
+ results = await adapter.handle_tool_calls(response, user_id, provider)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ from dataclasses import dataclass
33
+ from typing import TYPE_CHECKING, Any, Literal
34
+
35
+ if TYPE_CHECKING:
36
+ from headroom.memory.backends.local import LocalBackend
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # =============================================================================
41
+ # Provider Types
42
+ # =============================================================================
43
+
44
+ Provider = Literal["anthropic", "openai", "gemini", "generic"]
45
+
46
+ # =============================================================================
47
+ # Tool Names
48
+ # =============================================================================
49
+
50
+ # Custom memory tool names (Headroom's tools)
51
+ MEMORY_TOOL_NAMES = {"memory_save", "memory_search", "memory_update", "memory_delete"}
52
+
53
+ # Anthropic's native memory tool
54
+ NATIVE_MEMORY_TOOL_NAME = "memory"
55
+ NATIVE_MEMORY_TOOL_TYPE = "memory_20250818"
56
+
57
+ # Beta header for Anthropic's native memory tool
58
+ ANTHROPIC_BETA_HEADER = "context-management-2025-06-27"
59
+
60
+ # =============================================================================
61
+ # Tool Schemas - Anthropic Native Tool
62
+ # =============================================================================
63
+
64
+ ANTHROPIC_NATIVE_TOOL: dict[str, Any] = {
65
+ "type": NATIVE_MEMORY_TOOL_TYPE,
66
+ "name": NATIVE_MEMORY_TOOL_NAME,
67
+ }
68
+
69
+ # =============================================================================
70
+ # Tool Schemas - Anthropic Custom Tools
71
+ # =============================================================================
72
+
73
+ ANTHROPIC_CUSTOM_TOOLS: list[dict[str, Any]] = [
74
+ {
75
+ "name": "memory_save",
76
+ "description": """Save important information to long-term memory for future reference.
77
+
78
+ Use this tool when you encounter information that should be remembered across conversations:
79
+ - User preferences (e.g., "prefers Python over JavaScript")
80
+ - Personal facts (e.g., "works at Acme Corp", "has a dog named Max")
81
+ - Project context (e.g., "working on a CLI tool", "using React 18")
82
+ - Decisions made (e.g., "chose PostgreSQL for the database")
83
+ - Important relationships (e.g., "Alice is Bob's manager")
84
+
85
+ DO NOT save: transient info, sensitive data (passwords, keys), redundant info.""",
86
+ "input_schema": {
87
+ "type": "object",
88
+ "properties": {
89
+ "content": {
90
+ "type": "string",
91
+ "description": "The information to remember. Be specific and self-contained.",
92
+ },
93
+ "importance": {
94
+ "type": "number",
95
+ "minimum": 0.0,
96
+ "maximum": 1.0,
97
+ "description": "Importance score from 0.0 (low) to 1.0 (critical).",
98
+ },
99
+ "facts": {
100
+ "type": "array",
101
+ "items": {"type": "string"},
102
+ "description": "Pre-extracted discrete facts for efficient storage.",
103
+ },
104
+ "entities": {
105
+ "type": "array",
106
+ "items": {"type": "string"},
107
+ "description": "Entity names referenced in this memory.",
108
+ },
109
+ "extracted_entities": {
110
+ "type": "array",
111
+ "items": {
112
+ "type": "object",
113
+ "properties": {
114
+ "entity": {"type": "string"},
115
+ "entity_type": {"type": "string"},
116
+ },
117
+ "required": ["entity", "entity_type"],
118
+ },
119
+ "description": "Pre-extracted entities with types.",
120
+ },
121
+ "extracted_relationships": {
122
+ "type": "array",
123
+ "items": {
124
+ "type": "object",
125
+ "properties": {
126
+ "source": {"type": "string"},
127
+ "relationship": {"type": "string"},
128
+ "destination": {"type": "string"},
129
+ },
130
+ "required": ["source", "relationship", "destination"],
131
+ },
132
+ "description": "Pre-extracted relationships for graph storage.",
133
+ },
134
+ },
135
+ "required": ["content", "importance"],
136
+ },
137
+ },
138
+ {
139
+ "name": "memory_search",
140
+ "description": """Search stored memories to recall relevant information.
141
+
142
+ Use this tool to retrieve previously saved information before responding to questions about:
143
+ - User preferences or past decisions
144
+ - Personal or professional context
145
+ - Previously discussed topics or projects
146
+ - Relationships between people, systems, or concepts
147
+
148
+ Search BEFORE saving to avoid duplicates.""",
149
+ "input_schema": {
150
+ "type": "object",
151
+ "properties": {
152
+ "query": {
153
+ "type": "string",
154
+ "description": "Natural language search query.",
155
+ },
156
+ "entities": {
157
+ "type": "array",
158
+ "items": {"type": "string"},
159
+ "description": "Filter to memories mentioning these entities.",
160
+ },
161
+ "include_related": {
162
+ "type": "boolean",
163
+ "description": "Also retrieve connected memories.",
164
+ },
165
+ "top_k": {
166
+ "type": "integer",
167
+ "minimum": 1,
168
+ "maximum": 50,
169
+ "description": "Maximum number of memories to retrieve (default 10).",
170
+ },
171
+ },
172
+ "required": ["query"],
173
+ },
174
+ },
175
+ {
176
+ "name": "memory_update",
177
+ "description": """Update an existing memory with corrected or evolved information.
178
+
179
+ Use when:
180
+ - User provides a correction to stored information
181
+ - Information has changed over time
182
+ - Adding detail or clarification to an existing memory""",
183
+ "input_schema": {
184
+ "type": "object",
185
+ "properties": {
186
+ "memory_id": {
187
+ "type": "string",
188
+ "description": "The unique ID of the memory to update.",
189
+ },
190
+ "new_content": {
191
+ "type": "string",
192
+ "description": "The updated content.",
193
+ },
194
+ "reason": {
195
+ "type": "string",
196
+ "description": "Explanation for the update.",
197
+ },
198
+ },
199
+ "required": ["memory_id", "new_content"],
200
+ },
201
+ },
202
+ {
203
+ "name": "memory_delete",
204
+ "description": """Delete a memory that is no longer relevant or was stored in error.
205
+
206
+ Use when:
207
+ - User explicitly asks to forget something
208
+ - Information is outdated and no longer applicable
209
+ - A memory was saved in error""",
210
+ "input_schema": {
211
+ "type": "object",
212
+ "properties": {
213
+ "memory_id": {
214
+ "type": "string",
215
+ "description": "The unique ID of the memory to delete.",
216
+ },
217
+ "reason": {
218
+ "type": "string",
219
+ "description": "Explanation for the deletion.",
220
+ },
221
+ },
222
+ "required": ["memory_id"],
223
+ },
224
+ },
225
+ ]
226
+
227
+ # =============================================================================
228
+ # Tool Schemas - OpenAI Function Calling Format
229
+ # =============================================================================
230
+
231
+ OPENAI_TOOLS: list[dict[str, Any]] = [
232
+ {
233
+ "type": "function",
234
+ "function": {
235
+ "name": "memory_save",
236
+ "description": """Save important information to long-term memory for future reference.
237
+
238
+ Use this tool when you encounter information that should be remembered across conversations:
239
+ - User preferences, personal facts, project context, decisions, relationships
240
+
241
+ DO NOT save: transient info, sensitive data (passwords, keys), redundant info.""",
242
+ "parameters": {
243
+ "type": "object",
244
+ "properties": {
245
+ "content": {
246
+ "type": "string",
247
+ "description": "The information to remember. Be specific and self-contained.",
248
+ },
249
+ "importance": {
250
+ "type": "number",
251
+ "minimum": 0.0,
252
+ "maximum": 1.0,
253
+ "description": "Importance score from 0.0 (low) to 1.0 (critical).",
254
+ },
255
+ "facts": {
256
+ "type": "array",
257
+ "items": {"type": "string"},
258
+ "description": "Pre-extracted discrete facts.",
259
+ },
260
+ "entities": {
261
+ "type": "array",
262
+ "items": {"type": "string"},
263
+ "description": "Entity names referenced in this memory.",
264
+ },
265
+ "extracted_entities": {
266
+ "type": "array",
267
+ "items": {
268
+ "type": "object",
269
+ "properties": {
270
+ "entity": {"type": "string"},
271
+ "entity_type": {"type": "string"},
272
+ },
273
+ "required": ["entity", "entity_type"],
274
+ },
275
+ "description": "Pre-extracted entities with types.",
276
+ },
277
+ "extracted_relationships": {
278
+ "type": "array",
279
+ "items": {
280
+ "type": "object",
281
+ "properties": {
282
+ "source": {"type": "string"},
283
+ "relationship": {"type": "string"},
284
+ "destination": {"type": "string"},
285
+ },
286
+ "required": ["source", "relationship", "destination"],
287
+ },
288
+ "description": "Pre-extracted relationships.",
289
+ },
290
+ },
291
+ "required": ["content", "importance"],
292
+ },
293
+ },
294
+ },
295
+ {
296
+ "type": "function",
297
+ "function": {
298
+ "name": "memory_search",
299
+ "description": "Search stored memories to recall relevant information.",
300
+ "parameters": {
301
+ "type": "object",
302
+ "properties": {
303
+ "query": {
304
+ "type": "string",
305
+ "description": "Natural language search query.",
306
+ },
307
+ "entities": {
308
+ "type": "array",
309
+ "items": {"type": "string"},
310
+ "description": "Filter to memories mentioning these entities.",
311
+ },
312
+ "include_related": {
313
+ "type": "boolean",
314
+ "description": "Also retrieve connected memories.",
315
+ },
316
+ "top_k": {
317
+ "type": "integer",
318
+ "minimum": 1,
319
+ "maximum": 50,
320
+ "description": "Maximum number of memories to retrieve.",
321
+ },
322
+ },
323
+ "required": ["query"],
324
+ },
325
+ },
326
+ },
327
+ {
328
+ "type": "function",
329
+ "function": {
330
+ "name": "memory_update",
331
+ "description": "Update an existing memory with corrected or evolved information.",
332
+ "parameters": {
333
+ "type": "object",
334
+ "properties": {
335
+ "memory_id": {
336
+ "type": "string",
337
+ "description": "The unique ID of the memory to update.",
338
+ },
339
+ "new_content": {
340
+ "type": "string",
341
+ "description": "The updated content.",
342
+ },
343
+ "reason": {
344
+ "type": "string",
345
+ "description": "Explanation for the update.",
346
+ },
347
+ },
348
+ "required": ["memory_id", "new_content"],
349
+ },
350
+ },
351
+ },
352
+ {
353
+ "type": "function",
354
+ "function": {
355
+ "name": "memory_delete",
356
+ "description": "Delete a memory that is no longer relevant.",
357
+ "parameters": {
358
+ "type": "object",
359
+ "properties": {
360
+ "memory_id": {
361
+ "type": "string",
362
+ "description": "The unique ID of the memory to delete.",
363
+ },
364
+ "reason": {
365
+ "type": "string",
366
+ "description": "Explanation for the deletion.",
367
+ },
368
+ },
369
+ "required": ["memory_id"],
370
+ },
371
+ },
372
+ },
373
+ ]
374
+
375
+ # =============================================================================
376
+ # Tool Schemas - Gemini Function Calling Format
377
+ # =============================================================================
378
+
379
+ # Gemini uses a similar format to OpenAI but with slight differences
380
+ GEMINI_TOOLS: list[dict[str, Any]] = [
381
+ {
382
+ "name": "memory_save",
383
+ "description": """Save important information to long-term memory for future reference.
384
+
385
+ Use this tool when you encounter information that should be remembered across conversations:
386
+ - User preferences, personal facts, project context, decisions, relationships
387
+
388
+ DO NOT save: transient info, sensitive data (passwords, keys), redundant info.""",
389
+ "parameters": {
390
+ "type": "object",
391
+ "properties": {
392
+ "content": {
393
+ "type": "string",
394
+ "description": "The information to remember. Be specific and self-contained.",
395
+ },
396
+ "importance": {
397
+ "type": "number",
398
+ "description": "Importance score from 0.0 (low) to 1.0 (critical).",
399
+ },
400
+ "facts": {
401
+ "type": "array",
402
+ "items": {"type": "string"},
403
+ "description": "Pre-extracted discrete facts.",
404
+ },
405
+ "entities": {
406
+ "type": "array",
407
+ "items": {"type": "string"},
408
+ "description": "Entity names referenced in this memory.",
409
+ },
410
+ },
411
+ "required": ["content", "importance"],
412
+ },
413
+ },
414
+ {
415
+ "name": "memory_search",
416
+ "description": "Search stored memories to recall relevant information.",
417
+ "parameters": {
418
+ "type": "object",
419
+ "properties": {
420
+ "query": {
421
+ "type": "string",
422
+ "description": "Natural language search query.",
423
+ },
424
+ "entities": {
425
+ "type": "array",
426
+ "items": {"type": "string"},
427
+ "description": "Filter to memories mentioning these entities.",
428
+ },
429
+ "include_related": {
430
+ "type": "boolean",
431
+ "description": "Also retrieve connected memories.",
432
+ },
433
+ "top_k": {
434
+ "type": "integer",
435
+ "description": "Maximum number of memories to retrieve.",
436
+ },
437
+ },
438
+ "required": ["query"],
439
+ },
440
+ },
441
+ {
442
+ "name": "memory_update",
443
+ "description": "Update an existing memory with corrected or evolved information.",
444
+ "parameters": {
445
+ "type": "object",
446
+ "properties": {
447
+ "memory_id": {
448
+ "type": "string",
449
+ "description": "The unique ID of the memory to update.",
450
+ },
451
+ "new_content": {
452
+ "type": "string",
453
+ "description": "The updated content.",
454
+ },
455
+ "reason": {
456
+ "type": "string",
457
+ "description": "Explanation for the update.",
458
+ },
459
+ },
460
+ "required": ["memory_id", "new_content"],
461
+ },
462
+ },
463
+ {
464
+ "name": "memory_delete",
465
+ "description": "Delete a memory that is no longer relevant.",
466
+ "parameters": {
467
+ "type": "object",
468
+ "properties": {
469
+ "memory_id": {
470
+ "type": "string",
471
+ "description": "The unique ID of the memory to delete.",
472
+ },
473
+ "reason": {
474
+ "type": "string",
475
+ "description": "Explanation for the deletion.",
476
+ },
477
+ },
478
+ "required": ["memory_id"],
479
+ },
480
+ },
481
+ ]
482
+
483
+
484
+ # =============================================================================
485
+ # Configuration
486
+ # =============================================================================
487
+
488
+
489
+ @dataclass
490
+ class MemoryToolAdapterConfig:
491
+ """Configuration for the memory tool adapter.
492
+
493
+ Attributes:
494
+ enabled: Whether memory features are enabled.
495
+ use_native_tool: Use Anthropic's native memory_20250818 tool (Anthropic only).
496
+ inject_tools: Whether to inject memory tools into requests.
497
+ inject_context: Whether to inject memory context into requests.
498
+ db_path: Path to the local memory database.
499
+ top_k: Number of memories to retrieve in searches.
500
+ min_similarity: Minimum similarity score for memory retrieval.
501
+ """
502
+
503
+ enabled: bool = False
504
+ use_native_tool: bool = True # Default to native for Anthropic (subscription-safe)
505
+ inject_tools: bool = True
506
+ inject_context: bool = True
507
+ db_path: str = "headroom_memory.db"
508
+ top_k: int = 10
509
+ min_similarity: float = 0.3
510
+
511
+
512
+ # =============================================================================
513
+ # Memory Tool Adapter
514
+ # =============================================================================
515
+
516
+
517
+ class MemoryToolAdapter:
518
+ """Adapter for memory tools across different LLM providers.
519
+
520
+ This adapter provides a unified interface for:
521
+ 1. Detecting the LLM provider from requests
522
+ 2. Injecting memory tools in provider-specific formats
523
+ 3. Providing required beta headers
524
+ 4. Detecting memory tool calls in responses
525
+ 5. Handling tool calls with the semantic backend
526
+
527
+ Example:
528
+ adapter = MemoryToolAdapter(config)
529
+ provider = adapter.detect_provider(headers, model)
530
+ tools, headers = adapter.inject_tools(existing_tools, provider)
531
+
532
+ # Later, when processing response
533
+ if adapter.has_memory_tool_calls(response, provider):
534
+ results = await adapter.handle_tool_calls(response, user_id, provider)
535
+ """
536
+
537
+ def __init__(self, config: MemoryToolAdapterConfig) -> None:
538
+ """Initialize the adapter.
539
+
540
+ Args:
541
+ config: Configuration for the adapter.
542
+ """
543
+ self.config = config
544
+ self._backend: LocalBackend | Any = None
545
+ self._initialized = False
546
+
547
+ async def _ensure_initialized(self) -> None:
548
+ """Lazy initialization of the semantic backend.
549
+
550
+ Imports and initializes the LocalBackend from memory_handler
551
+ to provide semantic search and storage capabilities.
552
+ """
553
+ if self._initialized:
554
+ return
555
+
556
+ if not self.config.enabled:
557
+ return
558
+
559
+ from headroom.memory.backends.local import LocalBackend, LocalBackendConfig
560
+
561
+ backend_config = LocalBackendConfig(db_path=self.config.db_path)
562
+ self._backend = LocalBackend(backend_config)
563
+ await self._backend._ensure_initialized()
564
+
565
+ self._initialized = True
566
+ logger.info(f"MemoryToolAdapter: Initialized backend at {self.config.db_path}")
567
+
568
+ def detect_provider(
569
+ self,
570
+ request_headers: dict[str, str] | None = None,
571
+ model_name: str | None = None,
572
+ ) -> Provider:
573
+ """Detect the LLM provider from request headers and model name.
574
+
575
+ Detection priority:
576
+ 1. Explicit headers (x-api-key for Anthropic, authorization for OpenAI)
577
+ 2. Model name patterns (claude-*, gpt-*, gemini-*)
578
+ 3. Fallback to generic
579
+
580
+ Args:
581
+ request_headers: HTTP headers from the request (optional).
582
+ model_name: Name of the model being used (optional).
583
+
584
+ Returns:
585
+ The detected provider.
586
+ """
587
+ headers = request_headers or {}
588
+ model = (model_name or "").lower()
589
+
590
+ # Check headers for provider hints
591
+ if "x-api-key" in headers or "anthropic-version" in headers:
592
+ return "anthropic"
593
+
594
+ if headers.get("authorization", "").startswith("Bearer sk-"):
595
+ # OpenAI uses sk-* API keys
596
+ return "openai"
597
+
598
+ # Check model name patterns
599
+ if model.startswith("claude"):
600
+ return "anthropic"
601
+
602
+ if model.startswith("gpt") or model.startswith("o1") or model.startswith("o3"):
603
+ return "openai"
604
+
605
+ if model.startswith("gemini") or "gemma" in model:
606
+ return "gemini"
607
+
608
+ # Fallback to generic
609
+ return "generic"
610
+
611
+ def inject_tools(
612
+ self,
613
+ tools: list[dict[str, Any]] | None,
614
+ provider: Provider,
615
+ ) -> tuple[list[dict[str, Any]], dict[str, str]]:
616
+ """Inject memory tools into the tools list for the given provider.
617
+
618
+ Args:
619
+ tools: Existing tools list (may be None).
620
+ provider: The LLM provider to format tools for.
621
+
622
+ Returns:
623
+ Tuple of (updated_tools, beta_headers).
624
+ beta_headers contains any required headers (e.g., anthropic-beta).
625
+ """
626
+ if not self.config.inject_tools:
627
+ return tools or [], {}
628
+
629
+ tools = list(tools) if tools else []
630
+ beta_headers: dict[str, str] = {}
631
+
632
+ # Get existing tool names
633
+ existing_names = self._get_existing_tool_names(tools)
634
+
635
+ # Handle Anthropic native tool
636
+ if provider == "anthropic" and self.config.use_native_tool:
637
+ if NATIVE_MEMORY_TOOL_NAME not in existing_names:
638
+ tools.append(ANTHROPIC_NATIVE_TOOL.copy())
639
+ beta_headers["anthropic-beta"] = ANTHROPIC_BETA_HEADER
640
+ logger.info("MemoryToolAdapter: Injected native memory tool for Anthropic")
641
+ return tools, beta_headers
642
+
643
+ # Handle custom tools by provider
644
+ if provider == "anthropic":
645
+ tools, was_injected = self._inject_anthropic_tools(tools, existing_names)
646
+ elif provider == "openai":
647
+ tools, was_injected = self._inject_openai_tools(tools, existing_names)
648
+ elif provider == "gemini":
649
+ tools, was_injected = self._inject_gemini_tools(tools, existing_names)
650
+ else:
651
+ # Generic fallback uses OpenAI format
652
+ tools, was_injected = self._inject_openai_tools(tools, existing_names)
653
+
654
+ if was_injected:
655
+ logger.info(f"MemoryToolAdapter: Injected custom tools for {provider}")
656
+
657
+ return tools, beta_headers
658
+
659
+ def _get_existing_tool_names(self, tools: list[dict[str, Any]]) -> set[str]:
660
+ """Extract tool names from existing tools list."""
661
+ names: set[str] = set()
662
+ for tool in tools:
663
+ # Anthropic format
664
+ if "name" in tool:
665
+ names.add(tool["name"])
666
+ # OpenAI format
667
+ if "function" in tool and "name" in tool["function"]:
668
+ names.add(tool["function"]["name"])
669
+ return names
670
+
671
+ def _inject_anthropic_tools(
672
+ self,
673
+ tools: list[dict[str, Any]],
674
+ existing_names: set[str],
675
+ ) -> tuple[list[dict[str, Any]], bool]:
676
+ """Inject Anthropic-formatted custom memory tools."""
677
+ was_injected = False
678
+ for memory_tool in ANTHROPIC_CUSTOM_TOOLS:
679
+ if memory_tool["name"] not in existing_names:
680
+ tools.append(memory_tool.copy())
681
+ was_injected = True
682
+ return tools, was_injected
683
+
684
+ def _inject_openai_tools(
685
+ self,
686
+ tools: list[dict[str, Any]],
687
+ existing_names: set[str],
688
+ ) -> tuple[list[dict[str, Any]], bool]:
689
+ """Inject OpenAI-formatted memory tools."""
690
+ was_injected = False
691
+ for memory_tool in OPENAI_TOOLS:
692
+ tool_name = memory_tool["function"]["name"]
693
+ if tool_name not in existing_names:
694
+ tools.append(memory_tool.copy())
695
+ was_injected = True
696
+ return tools, was_injected
697
+
698
+ def _inject_gemini_tools(
699
+ self,
700
+ tools: list[dict[str, Any]],
701
+ existing_names: set[str],
702
+ ) -> tuple[list[dict[str, Any]], bool]:
703
+ """Inject Gemini-formatted memory tools."""
704
+ was_injected = False
705
+ for memory_tool in GEMINI_TOOLS:
706
+ if memory_tool["name"] not in existing_names:
707
+ tools.append(memory_tool.copy())
708
+ was_injected = True
709
+ return tools, was_injected
710
+
711
+ def get_beta_headers(self, provider: Provider) -> dict[str, str]:
712
+ """Get any required beta headers for the provider.
713
+
714
+ Args:
715
+ provider: The LLM provider.
716
+
717
+ Returns:
718
+ Dict of header name -> value for any required beta headers.
719
+ """
720
+ if provider == "anthropic" and self.config.use_native_tool:
721
+ return {"anthropic-beta": ANTHROPIC_BETA_HEADER}
722
+ return {}
723
+
724
+ def has_memory_tool_calls(
725
+ self,
726
+ response: dict[str, Any],
727
+ provider: Provider,
728
+ ) -> bool:
729
+ """Check if the response contains memory tool calls.
730
+
731
+ Args:
732
+ response: The API response from the LLM.
733
+ provider: The LLM provider.
734
+
735
+ Returns:
736
+ True if response contains memory tool calls.
737
+ """
738
+ tool_calls = self._extract_tool_calls(response, provider)
739
+ for tc in tool_calls:
740
+ name = self._get_tool_name(tc, provider)
741
+ if name in MEMORY_TOOL_NAMES or name == NATIVE_MEMORY_TOOL_NAME:
742
+ return True
743
+ return False
744
+
745
+ def _extract_tool_calls(
746
+ self,
747
+ response: dict[str, Any],
748
+ provider: Provider,
749
+ ) -> list[dict[str, Any]]:
750
+ """Extract tool calls from response based on provider format."""
751
+ if provider == "anthropic":
752
+ content = response.get("content", [])
753
+ if isinstance(content, list):
754
+ return [block for block in content if block.get("type") == "tool_use"]
755
+ return []
756
+
757
+ elif provider == "openai":
758
+ choices = response.get("choices", [])
759
+ if choices:
760
+ message = choices[0].get("message", {})
761
+ return list(message.get("tool_calls", []) or [])
762
+ return []
763
+
764
+ elif provider == "gemini":
765
+ # Gemini format: candidates[0].content.parts[*].functionCall
766
+ candidates = response.get("candidates", [])
767
+ if candidates:
768
+ content = candidates[0].get("content", {})
769
+ parts = content.get("parts", [])
770
+ return [p for p in parts if "functionCall" in p]
771
+ return []
772
+
773
+ # Generic fallback - try both formats
774
+ tool_calls = []
775
+
776
+ # Try Anthropic format
777
+ content = response.get("content", [])
778
+ if isinstance(content, list):
779
+ tool_calls.extend([block for block in content if block.get("type") == "tool_use"])
780
+
781
+ # Try OpenAI format
782
+ choices = response.get("choices", [])
783
+ if choices:
784
+ message = choices[0].get("message", {})
785
+ tool_calls.extend(list(message.get("tool_calls", []) or []))
786
+
787
+ return tool_calls
788
+
789
+ def _get_tool_name(self, tool_call: dict[str, Any], provider: Provider) -> str:
790
+ """Get the tool name from a tool call."""
791
+ if provider == "anthropic":
792
+ return str(tool_call.get("name", ""))
793
+ elif provider == "openai":
794
+ return str(tool_call.get("function", {}).get("name", ""))
795
+ elif provider == "gemini":
796
+ func_call = tool_call.get("functionCall", {})
797
+ return str(func_call.get("name", ""))
798
+ else:
799
+ # Generic - try both
800
+ return str(tool_call.get("name", "") or tool_call.get("function", {}).get("name", ""))
801
+
802
+ def _get_tool_id(self, tool_call: dict[str, Any], provider: Provider) -> str:
803
+ """Get the tool call ID."""
804
+ if provider == "anthropic":
805
+ return str(tool_call.get("id", ""))
806
+ elif provider == "openai":
807
+ return str(tool_call.get("id", ""))
808
+ elif provider == "gemini":
809
+ # Gemini doesn't use IDs in the same way
810
+ return str(tool_call.get("functionCall", {}).get("name", ""))
811
+ else:
812
+ return str(tool_call.get("id", ""))
813
+
814
+ def _get_tool_input(
815
+ self,
816
+ tool_call: dict[str, Any],
817
+ provider: Provider,
818
+ ) -> dict[str, Any]:
819
+ """Get the tool input/arguments from a tool call."""
820
+ if provider == "anthropic":
821
+ result = tool_call.get("input", {})
822
+ return dict(result) if isinstance(result, dict) else {}
823
+ elif provider == "openai":
824
+ args_str = tool_call.get("function", {}).get("arguments", "{}")
825
+ try:
826
+ parsed = json.loads(args_str)
827
+ return dict(parsed) if isinstance(parsed, dict) else {}
828
+ except json.JSONDecodeError:
829
+ return {}
830
+ elif provider == "gemini":
831
+ result = tool_call.get("functionCall", {}).get("args", {})
832
+ return dict(result) if isinstance(result, dict) else {}
833
+ else:
834
+ # Generic - try both
835
+ if "input" in tool_call:
836
+ result = tool_call["input"]
837
+ return dict(result) if isinstance(result, dict) else {}
838
+ args_str = tool_call.get("function", {}).get("arguments", "{}")
839
+ try:
840
+ parsed = json.loads(args_str)
841
+ return dict(parsed) if isinstance(parsed, dict) else {}
842
+ except json.JSONDecodeError:
843
+ return {}
844
+
845
+ async def handle_tool_calls(
846
+ self,
847
+ response: dict[str, Any],
848
+ user_id: str,
849
+ provider: Provider,
850
+ ) -> list[dict[str, Any]]:
851
+ """Handle memory tool calls and return results in provider format.
852
+
853
+ Args:
854
+ response: The API response containing tool calls.
855
+ user_id: User identifier for memory operations.
856
+ provider: The LLM provider.
857
+
858
+ Returns:
859
+ List of tool results in provider-appropriate format.
860
+ """
861
+ await self._ensure_initialized()
862
+
863
+ tool_calls = self._extract_tool_calls(response, provider)
864
+ results: list[dict[str, Any]] = []
865
+
866
+ for tc in tool_calls:
867
+ tool_name = self._get_tool_name(tc, provider)
868
+ tool_id = self._get_tool_id(tc, provider)
869
+ input_data = self._get_tool_input(tc, provider)
870
+
871
+ # Skip non-memory tools
872
+ if tool_name not in MEMORY_TOOL_NAMES and tool_name != NATIVE_MEMORY_TOOL_NAME:
873
+ continue
874
+
875
+ # Execute the tool
876
+ if tool_name == NATIVE_MEMORY_TOOL_NAME:
877
+ result_content = await self._execute_native_tool(input_data, user_id)
878
+ else:
879
+ result_content = await self._execute_custom_tool(tool_name, input_data, user_id)
880
+
881
+ # Format result for provider
882
+ result = self._format_tool_result(tool_id, result_content, provider)
883
+ results.append(result)
884
+
885
+ logger.info(f"MemoryToolAdapter: Executed {tool_name} for user {user_id}")
886
+
887
+ return results
888
+
889
+ def _format_tool_result(
890
+ self,
891
+ tool_id: str,
892
+ content: str,
893
+ provider: Provider,
894
+ ) -> dict[str, Any]:
895
+ """Format a tool result for the given provider."""
896
+ if provider == "anthropic":
897
+ return {
898
+ "type": "tool_result",
899
+ "tool_use_id": tool_id,
900
+ "content": content,
901
+ }
902
+ elif provider == "openai":
903
+ return {
904
+ "role": "tool",
905
+ "tool_call_id": tool_id,
906
+ "content": content,
907
+ }
908
+ elif provider == "gemini":
909
+ return {
910
+ "functionResponse": {
911
+ "name": tool_id,
912
+ "response": {"result": content},
913
+ }
914
+ }
915
+ else:
916
+ # Generic uses OpenAI format
917
+ return {
918
+ "role": "tool",
919
+ "tool_call_id": tool_id,
920
+ "content": content,
921
+ }
922
+
923
+ async def _execute_native_tool(
924
+ self,
925
+ input_data: dict[str, Any],
926
+ user_id: str,
927
+ ) -> str:
928
+ """Execute Anthropic's native memory tool.
929
+
930
+ This translates native memory commands to our semantic backend:
931
+ - view: semantic search or list memories
932
+ - create: save to vector store
933
+ - str_replace: update memory
934
+ - delete: remove from vector store
935
+ """
936
+ if not self._backend:
937
+ return "Error: Memory backend not initialized"
938
+
939
+ command = input_data.get("command", "")
940
+
941
+ try:
942
+ if command == "view":
943
+ return await self._native_view(input_data, user_id)
944
+ elif command == "create":
945
+ return await self._native_create(input_data, user_id)
946
+ elif command == "str_replace":
947
+ return await self._native_update(input_data, user_id)
948
+ elif command == "delete":
949
+ return await self._native_delete(input_data, user_id)
950
+ else:
951
+ return f"Error: Unknown command '{command}'"
952
+ except Exception as e:
953
+ logger.error(f"MemoryToolAdapter: Native tool error: {e}")
954
+ return f"Error: {e}"
955
+
956
+ async def _native_view(self, input_data: dict[str, Any], user_id: str) -> str:
957
+ """Handle VIEW command - semantic search or list memories."""
958
+ path = input_data.get("path", "/memories")
959
+
960
+ # Normalize path
961
+ if path.startswith("/memories"):
962
+ subpath = path[len("/memories") :].lstrip("/")
963
+ else:
964
+ subpath = path.lstrip("/")
965
+
966
+ # Search pattern: /memories/search/<query>
967
+ if subpath.startswith("search/"):
968
+ query = subpath[len("search/") :]
969
+ if not query:
970
+ return "Error: Please provide a search query"
971
+ return await self._semantic_search(query, user_id)
972
+
973
+ # Recent: /memories/recent
974
+ if subpath == "recent":
975
+ return await self._semantic_search("recent memories", user_id, top_k=10)
976
+
977
+ # Root: /memories
978
+ if not subpath:
979
+ return await self._get_memory_overview(user_id)
980
+
981
+ # Treat path as search topic
982
+ return await self._semantic_search(
983
+ subpath.replace("/", " ").replace("_", " "),
984
+ user_id,
985
+ )
986
+
987
+ async def _native_create(self, input_data: dict[str, Any], user_id: str) -> str:
988
+ """Handle CREATE command - save to vector store."""
989
+ path = input_data.get("path", "")
990
+ file_text = input_data.get("file_text", "")
991
+
992
+ if not file_text:
993
+ return "Error: file_text is required"
994
+
995
+ topic = path.replace("/memories/", "").replace("/", "_").replace(".txt", "")
996
+
997
+ memory = await self._backend.save_memory(
998
+ content=file_text,
999
+ user_id=user_id,
1000
+ importance=0.5,
1001
+ metadata={"virtual_path": path, "topic": topic},
1002
+ )
1003
+
1004
+ logger.info(f"MemoryToolAdapter: Created memory {memory.id} for {user_id}")
1005
+ return f"File created successfully at: {path}"
1006
+
1007
+ async def _native_update(self, input_data: dict[str, Any], user_id: str) -> str:
1008
+ """Handle STR_REPLACE command - update memory content."""
1009
+ old_str = input_data.get("old_str", "")
1010
+ new_str = input_data.get("new_str", "")
1011
+
1012
+ if not old_str:
1013
+ return "Error: old_str is required"
1014
+
1015
+ # Search for memory containing old_str
1016
+ results = await self._backend.search_memories(
1017
+ query=old_str,
1018
+ user_id=user_id,
1019
+ top_k=5,
1020
+ )
1021
+
1022
+ # Find exact match
1023
+ matching_memory = None
1024
+ for r in results:
1025
+ if old_str in r.memory.content:
1026
+ matching_memory = r.memory
1027
+ break
1028
+
1029
+ if not matching_memory:
1030
+ return "No replacement performed, old_str not found in memories"
1031
+
1032
+ # Perform replacement
1033
+ new_content = matching_memory.content.replace(old_str, new_str, 1)
1034
+
1035
+ if hasattr(self._backend, "update_memory"):
1036
+ await self._backend.update_memory(
1037
+ memory_id=matching_memory.id,
1038
+ new_content=new_content,
1039
+ user_id=user_id,
1040
+ )
1041
+ else:
1042
+ await self._backend.delete_memory(matching_memory.id)
1043
+ await self._backend.save_memory(
1044
+ content=new_content,
1045
+ user_id=user_id,
1046
+ importance=0.5,
1047
+ )
1048
+
1049
+ return "The memory has been edited."
1050
+
1051
+ async def _native_delete(self, input_data: dict[str, Any], user_id: str) -> str:
1052
+ """Handle DELETE command - remove from vector store."""
1053
+ path = input_data.get("path", "")
1054
+ topic = path.replace("/memories/", "").replace("/", " ").replace("_", " ")
1055
+
1056
+ results = await self._backend.search_memories(
1057
+ query=topic,
1058
+ user_id=user_id,
1059
+ top_k=10,
1060
+ )
1061
+
1062
+ if not results:
1063
+ return f"Error: The path {path} does not exist"
1064
+
1065
+ deleted_count = 0
1066
+ for r in results:
1067
+ metadata = getattr(r.memory, "metadata", {}) or {}
1068
+ if metadata.get("virtual_path") == path or r.score > 0.8:
1069
+ await self._backend.delete_memory(r.memory.id)
1070
+ deleted_count += 1
1071
+
1072
+ if deleted_count == 0:
1073
+ return f"Error: The path {path} does not exist"
1074
+
1075
+ return f"Successfully deleted {path}"
1076
+
1077
+ async def _semantic_search(
1078
+ self,
1079
+ query: str,
1080
+ user_id: str,
1081
+ top_k: int = 5,
1082
+ ) -> str:
1083
+ """Perform semantic search and format results."""
1084
+ results = await self._backend.search_memories(
1085
+ query=query,
1086
+ user_id=user_id,
1087
+ top_k=top_k,
1088
+ include_related=True,
1089
+ )
1090
+
1091
+ if not results:
1092
+ return f"No memories found matching '{query}'"
1093
+
1094
+ lines = [f"Found {len(results)} memories matching '{query}':\n"]
1095
+ for i, r in enumerate(results, 1):
1096
+ score_pct = int(r.score * 100)
1097
+ content_preview = r.memory.content[:200]
1098
+ if len(r.memory.content) > 200:
1099
+ content_preview += "..."
1100
+ lines.append(f"{i}. [{score_pct}% match] {content_preview}")
1101
+
1102
+ return "\n".join(lines)
1103
+
1104
+ async def _get_memory_overview(self, user_id: str) -> str:
1105
+ """Get memory overview with search instructions."""
1106
+ results = await self._backend.search_memories(
1107
+ query="*",
1108
+ user_id=user_id,
1109
+ top_k=100,
1110
+ )
1111
+ count = len(results) if results else 0
1112
+
1113
+ return f"""Memory System ({count} memories stored)
1114
+
1115
+ To SEARCH: view /memories/search/<query>
1116
+ To see RECENT: view /memories/recent
1117
+ To SAVE: create /memories/<topic>.txt "content"
1118
+ """
1119
+
1120
+ async def _execute_custom_tool(
1121
+ self,
1122
+ tool_name: str,
1123
+ input_data: dict[str, Any],
1124
+ user_id: str,
1125
+ ) -> str:
1126
+ """Execute a custom memory tool."""
1127
+ if not self._backend:
1128
+ return json.dumps({"error": "Memory backend not initialized"})
1129
+
1130
+ try:
1131
+ if tool_name == "memory_save":
1132
+ return await self._execute_save(input_data, user_id)
1133
+ elif tool_name == "memory_search":
1134
+ return await self._execute_search(input_data, user_id)
1135
+ elif tool_name == "memory_update":
1136
+ return await self._execute_update(input_data, user_id)
1137
+ elif tool_name == "memory_delete":
1138
+ return await self._execute_delete(input_data, user_id)
1139
+ else:
1140
+ return json.dumps({"error": f"Unknown tool: {tool_name}"})
1141
+ except Exception as e:
1142
+ logger.error(f"MemoryToolAdapter: Tool {tool_name} failed: {e}")
1143
+ return json.dumps({"status": "error", "error": str(e)})
1144
+
1145
+ async def _execute_save(self, input_data: dict[str, Any], user_id: str) -> str:
1146
+ """Execute memory_save tool."""
1147
+ content = input_data.get("content", "")
1148
+ if not content:
1149
+ return json.dumps({"status": "error", "error": "content is required"})
1150
+
1151
+ importance = input_data.get("importance", 0.5)
1152
+ facts = input_data.get("facts")
1153
+ entities = input_data.get("entities")
1154
+ extracted_entities = input_data.get("extracted_entities")
1155
+ extracted_relationships = input_data.get("extracted_relationships")
1156
+
1157
+ memory = await self._backend.save_memory(
1158
+ content=content,
1159
+ user_id=user_id,
1160
+ importance=importance,
1161
+ facts=facts,
1162
+ entities=entities,
1163
+ extracted_entities=extracted_entities,
1164
+ relationships=extracted_relationships,
1165
+ extracted_relationships=extracted_relationships,
1166
+ )
1167
+
1168
+ return json.dumps(
1169
+ {
1170
+ "status": "saved",
1171
+ "memory_id": memory.id,
1172
+ "content": memory.content[:100] + "..."
1173
+ if len(memory.content) > 100
1174
+ else memory.content,
1175
+ }
1176
+ )
1177
+
1178
+ async def _execute_search(self, input_data: dict[str, Any], user_id: str) -> str:
1179
+ """Execute memory_search tool."""
1180
+ query = input_data.get("query", "")
1181
+ if not query:
1182
+ return json.dumps({"status": "error", "error": "query is required"})
1183
+
1184
+ top_k = input_data.get("top_k", self.config.top_k)
1185
+ include_related = input_data.get("include_related", True)
1186
+ entities_filter = input_data.get("entities")
1187
+
1188
+ results = await self._backend.search_memories(
1189
+ query=query,
1190
+ user_id=user_id,
1191
+ top_k=top_k,
1192
+ include_related=include_related,
1193
+ entities=entities_filter,
1194
+ )
1195
+
1196
+ return json.dumps(
1197
+ {
1198
+ "status": "found",
1199
+ "count": len(results),
1200
+ "memories": [
1201
+ {
1202
+ "id": r.memory.id,
1203
+ "content": r.memory.content,
1204
+ "score": round(r.score, 3),
1205
+ "entities": (
1206
+ r.related_entities[:5]
1207
+ if hasattr(r, "related_entities") and r.related_entities
1208
+ else []
1209
+ ),
1210
+ }
1211
+ for r in results
1212
+ ],
1213
+ }
1214
+ )
1215
+
1216
+ async def _execute_update(self, input_data: dict[str, Any], user_id: str) -> str:
1217
+ """Execute memory_update tool."""
1218
+ memory_id = input_data.get("memory_id", "")
1219
+ new_content = input_data.get("new_content", "")
1220
+
1221
+ if not memory_id:
1222
+ return json.dumps({"status": "error", "error": "memory_id is required"})
1223
+ if not new_content:
1224
+ return json.dumps({"status": "error", "error": "new_content is required"})
1225
+
1226
+ reason = input_data.get("reason")
1227
+
1228
+ if hasattr(self._backend, "update_memory"):
1229
+ memory = await self._backend.update_memory(
1230
+ memory_id=memory_id,
1231
+ new_content=new_content,
1232
+ reason=reason,
1233
+ user_id=user_id,
1234
+ )
1235
+ return json.dumps({"status": "updated", "memory_id": memory.id})
1236
+ else:
1237
+ # Fallback: delete old, save new
1238
+ await self._backend.delete_memory(memory_id)
1239
+ memory = await self._backend.save_memory(
1240
+ content=new_content,
1241
+ user_id=user_id,
1242
+ importance=0.5,
1243
+ )
1244
+ return json.dumps(
1245
+ {
1246
+ "status": "updated",
1247
+ "memory_id": memory.id,
1248
+ "note": "Replaced via delete+save",
1249
+ }
1250
+ )
1251
+
1252
+ async def _execute_delete(self, input_data: dict[str, Any], user_id: str) -> str:
1253
+ """Execute memory_delete tool."""
1254
+ memory_id = input_data.get("memory_id", "")
1255
+ if not memory_id:
1256
+ return json.dumps({"status": "error", "error": "memory_id is required"})
1257
+
1258
+ deleted = await self._backend.delete_memory(memory_id)
1259
+
1260
+ return json.dumps(
1261
+ {
1262
+ "status": "deleted" if deleted else "not_found",
1263
+ "memory_id": memory_id,
1264
+ }
1265
+ )
1266
+
1267
+ async def close(self) -> None:
1268
+ """Close the backend connection."""
1269
+ if self._backend and hasattr(self._backend, "close"):
1270
+ await self._backend.close()
1271
+ self._backend = None
1272
+ self._initialized = False
1273
+ logger.info("MemoryToolAdapter: Closed")
headroom/proxy/server.py CHANGED
@@ -296,6 +296,7 @@ class ProxyConfig:
296
  memory_backend: Literal["local", "qdrant-neo4j"] = "local" # Backend type
297
  memory_db_path: str = "headroom_memory.db" # Path for local backend
298
  memory_inject_tools: bool = True # Auto-inject memory tools
 
299
  memory_inject_context: bool = True # Inject searched memories into context
300
  memory_top_k: int = 10 # Number of memories to inject
301
  memory_min_similarity: float = 0.3 # Minimum similarity threshold
@@ -1082,6 +1083,7 @@ class HeadroomProxy:
1082
  backend=config.memory_backend,
1083
  db_path=config.memory_db_path,
1084
  inject_tools=config.memory_inject_tools,
 
1085
  inject_context=config.memory_inject_context,
1086
  top_k=config.memory_top_k,
1087
  min_similarity=config.memory_min_similarity,
@@ -1623,10 +1625,27 @@ class HeadroomProxy:
1623
  tools, mem_tools_injected = self.memory_handler.inject_tools(tools, "anthropic")
1624
  if mem_tools_injected:
1625
  tool_names = [
1626
- t.get("name") for t in tools if t.get("name", "").startswith("memory_")
 
 
 
1627
  ]
1628
  logger.info(f"[{request_id}] Memory: Injected tools: {tool_names}")
1629
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1630
  # Update body
1631
  body["messages"] = optimized_messages
1632
  if tools is not None:
 
296
  memory_backend: Literal["local", "qdrant-neo4j"] = "local" # Backend type
297
  memory_db_path: str = "headroom_memory.db" # Path for local backend
298
  memory_inject_tools: bool = True # Auto-inject memory tools
299
+ memory_use_native_tool: bool = False # Use Anthropic's native memory_20250818 tool
300
  memory_inject_context: bool = True # Inject searched memories into context
301
  memory_top_k: int = 10 # Number of memories to inject
302
  memory_min_similarity: float = 0.3 # Minimum similarity threshold
 
1083
  backend=config.memory_backend,
1084
  db_path=config.memory_db_path,
1085
  inject_tools=config.memory_inject_tools,
1086
+ use_native_tool=config.memory_use_native_tool,
1087
  inject_context=config.memory_inject_context,
1088
  top_k=config.memory_top_k,
1089
  min_similarity=config.memory_min_similarity,
 
1625
  tools, mem_tools_injected = self.memory_handler.inject_tools(tools, "anthropic")
1626
  if mem_tools_injected:
1627
  tool_names = [
1628
+ t.get("name") or t.get("type", "")
1629
+ for t in tools
1630
+ if t.get("name", "").startswith("memory")
1631
+ or t.get("type", "").startswith("memory")
1632
  ]
1633
  logger.info(f"[{request_id}] Memory: Injected tools: {tool_names}")
1634
 
1635
+ # Add beta headers for native memory tool
1636
+ beta_headers = self.memory_handler.get_beta_headers()
1637
+ if beta_headers:
1638
+ for key, value in beta_headers.items():
1639
+ # Merge with existing beta header if present
1640
+ existing = headers.get(key, "")
1641
+ if existing and value not in existing:
1642
+ headers[key] = f"{existing},{value}"
1643
+ else:
1644
+ headers[key] = value
1645
+ logger.info(
1646
+ f"[{request_id}] Memory: Added beta header: {key}={headers[key]}"
1647
+ )
1648
+
1649
  # Update body
1650
  body["messages"] = optimized_messages
1651
  if tools is not None:
tests/test_memory/test_core_operations.py CHANGED
@@ -78,6 +78,8 @@ async def memory_system(temp_db_path):
78
  config = MemoryConfig(db_path=str(temp_db_path))
79
  system = await HierarchicalMemory.create(config)
80
  yield system
 
 
81
 
82
 
83
  # =============================================================================
 
78
  config = MemoryConfig(db_path=str(temp_db_path))
79
  system = await HierarchicalMemory.create(config)
80
  yield system
81
+ # Properly close to release httpx clients
82
+ await system.close()
83
 
84
 
85
  # =============================================================================
tests/test_parser.py CHANGED
@@ -569,17 +569,23 @@ class TestFindToolUnits:
569
  # OpenAI format
570
  {
571
  "role": "assistant",
572
- "tool_calls": [{"id": "call_1", "function": {"name": "openai_tool", "arguments": "{}"}}],
 
 
573
  },
574
  {"role": "tool", "tool_call_id": "call_1", "content": "openai result"},
575
  # Anthropic format
576
  {
577
  "role": "assistant",
578
- "content": [{"type": "tool_use", "id": "toolu_2", "name": "anthropic_tool", "input": {}}],
 
 
579
  },
580
  {
581
  "role": "user",
582
- "content": [{"type": "tool_result", "tool_use_id": "toolu_2", "content": "anthropic result"}],
 
 
583
  },
584
  ]
585
  units = find_tool_units(messages)
 
569
  # OpenAI format
570
  {
571
  "role": "assistant",
572
+ "tool_calls": [
573
+ {"id": "call_1", "function": {"name": "openai_tool", "arguments": "{}"}}
574
+ ],
575
  },
576
  {"role": "tool", "tool_call_id": "call_1", "content": "openai result"},
577
  # Anthropic format
578
  {
579
  "role": "assistant",
580
+ "content": [
581
+ {"type": "tool_use", "id": "toolu_2", "name": "anthropic_tool", "input": {}}
582
+ ],
583
  },
584
  {
585
  "role": "user",
586
+ "content": [
587
+ {"type": "tool_result", "tool_use_id": "toolu_2", "content": "anthropic result"}
588
+ ],
589
  },
590
  ]
591
  units = find_tool_units(messages)