basic-memory 0.16.1__py3-none-any.whl → 0.17.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -23,6 +23,7 @@ from basic_memory.markdown.schemas import (
23
23
  )
24
24
  from basic_memory.utils import parse_tags
25
25
 
26
+
26
27
  md = MarkdownIt().use(observation_plugin).use(relation_plugin)
27
28
 
28
29
 
@@ -189,35 +190,69 @@ class EntityParser:
189
190
  return self.base_path / path
190
191
 
191
192
  async def parse_file_content(self, absolute_path, file_content):
192
- # Parse frontmatter with proper error handling for malformed YAML (issue #185)
193
+ """Parse markdown content from file stats.
194
+
195
+ Delegates to parse_markdown_content() for actual parsing logic.
196
+ Exists for backwards compatibility with code that passes file paths.
197
+ """
198
+ # Extract file stat info for timestamps
199
+ file_stats = absolute_path.stat()
200
+
201
+ # Delegate to parse_markdown_content with timestamps from file stats
202
+ return await self.parse_markdown_content(
203
+ file_path=absolute_path,
204
+ content=file_content,
205
+ mtime=file_stats.st_mtime,
206
+ ctime=file_stats.st_ctime,
207
+ )
208
+
209
+ async def parse_markdown_content(
210
+ self,
211
+ file_path: Path,
212
+ content: str,
213
+ mtime: Optional[float] = None,
214
+ ctime: Optional[float] = None,
215
+ ) -> EntityMarkdown:
216
+ """Parse markdown content without requiring file to exist on disk.
217
+
218
+ Useful for parsing content from S3 or other remote sources where the file
219
+ is not available locally.
220
+
221
+ Args:
222
+ file_path: Path for metadata (doesn't need to exist on disk)
223
+ content: Markdown content as string
224
+ mtime: Optional modification time (Unix timestamp)
225
+ ctime: Optional creation time (Unix timestamp)
226
+
227
+ Returns:
228
+ EntityMarkdown with parsed content
229
+ """
230
+ # Strip BOM before parsing (can be present in files from Windows or certain sources)
231
+ # See issue #452
232
+ from basic_memory.file_utils import strip_bom
233
+
234
+ content = strip_bom(content)
235
+
236
+ # Parse frontmatter with proper error handling for malformed YAML
193
237
  try:
194
- post = frontmatter.loads(file_content)
238
+ post = frontmatter.loads(content)
195
239
  except yaml.YAMLError as e:
196
- # Log the YAML parsing error with file context
197
240
  logger.warning(
198
- f"Failed to parse YAML frontmatter in {absolute_path}: {e}. "
241
+ f"Failed to parse YAML frontmatter in {file_path}: {e}. "
199
242
  f"Treating file as plain markdown without frontmatter."
200
243
  )
201
- # Create a post with no frontmatter - treat entire content as markdown
202
- post = frontmatter.Post(file_content, metadata={})
203
-
204
- # Extract file stat info
205
- file_stats = absolute_path.stat()
244
+ post = frontmatter.Post(content, metadata={})
206
245
 
207
- # Normalize frontmatter values to prevent AttributeError on date objects (issue #236)
208
- # PyYAML automatically converts date strings like "2025-10-24" to datetime.date objects
209
- # This normalization converts them back to ISO format strings to ensure compatibility
210
- # with code that expects string values
246
+ # Normalize frontmatter values
211
247
  metadata = normalize_frontmatter_metadata(post.metadata)
212
248
 
213
- # Ensure required fields have defaults (issue #184, #387)
214
- # Handle title - use default if missing, None/null, empty, or string "None"
249
+ # Ensure required fields have defaults
215
250
  title = metadata.get("title")
216
251
  if not title or title == "None":
217
- metadata["title"] = absolute_path.stem
252
+ metadata["title"] = file_path.stem
218
253
  else:
219
254
  metadata["title"] = title
220
- # Handle type - use default if missing OR explicitly set to None/null
255
+
221
256
  entity_type = metadata.get("type")
222
257
  metadata["type"] = entity_type if entity_type is not None else "note"
223
258
 
@@ -225,16 +260,20 @@ class EntityParser:
225
260
  if tags:
226
261
  metadata["tags"] = tags
227
262
 
228
- # frontmatter - use metadata with defaults applied
229
- entity_frontmatter = EntityFrontmatter(
230
- metadata=metadata,
231
- )
263
+ # Parse content for observations and relations
264
+ entity_frontmatter = EntityFrontmatter(metadata=metadata)
232
265
  entity_content = parse(post.content)
266
+
267
+ # Use provided timestamps or current time as fallback
268
+ now = datetime.now().astimezone()
269
+ created = datetime.fromtimestamp(ctime).astimezone() if ctime else now
270
+ modified = datetime.fromtimestamp(mtime).astimezone() if mtime else now
271
+
233
272
  return EntityMarkdown(
234
273
  frontmatter=entity_frontmatter,
235
274
  content=post.content,
236
275
  observations=entity_content.observations,
237
276
  relations=entity_content.relations,
238
- created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
239
- modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
277
+ created=created,
278
+ modified=modified,
240
279
  )
@@ -1,15 +1,19 @@
1
1
  from pathlib import Path
2
- from typing import Optional
2
+ from typing import TYPE_CHECKING, Optional
3
3
  from collections import OrderedDict
4
4
 
5
5
  from frontmatter import Post
6
6
  from loguru import logger
7
7
 
8
+
8
9
  from basic_memory import file_utils
9
10
  from basic_memory.file_utils import dump_frontmatter
10
11
  from basic_memory.markdown.entity_parser import EntityParser
11
12
  from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation
12
13
 
14
+ if TYPE_CHECKING: # pragma: no cover
15
+ from basic_memory.config import BasicMemoryConfig
16
+
13
17
 
14
18
  class DirtyFileError(Exception):
15
19
  """Raised when attempting to write to a file that has been modified."""
@@ -35,9 +39,14 @@ class MarkdownProcessor:
35
39
  3. Track schema changes (that's done by the database)
36
40
  """
37
41
 
38
- def __init__(self, entity_parser: EntityParser):
39
- """Initialize processor with base path and parser."""
42
+ def __init__(
43
+ self,
44
+ entity_parser: EntityParser,
45
+ app_config: Optional["BasicMemoryConfig"] = None,
46
+ ):
47
+ """Initialize processor with parser and optional config."""
40
48
  self.entity_parser = entity_parser
49
+ self.app_config = app_config
41
50
 
42
51
  async def read_file(self, path: Path) -> EntityMarkdown:
43
52
  """Read and parse file into EntityMarkdown schema.
@@ -122,7 +131,61 @@ class MarkdownProcessor:
122
131
  # Write atomically and return checksum of updated file
123
132
  path.parent.mkdir(parents=True, exist_ok=True)
124
133
  await file_utils.write_file_atomic(path, final_content)
125
- return await file_utils.compute_checksum(final_content)
134
+
135
+ # Format file if configured (MarkdownProcessor always handles markdown files)
136
+ content_for_checksum = final_content
137
+ if self.app_config:
138
+ formatted_content = await file_utils.format_file( # pragma: no cover
139
+ path, self.app_config, is_markdown=True
140
+ )
141
+ if formatted_content is not None: # pragma: no cover
142
+ content_for_checksum = formatted_content # pragma: no cover
143
+
144
+ return await file_utils.compute_checksum(content_for_checksum)
145
+
146
+ def to_markdown_string(self, markdown: EntityMarkdown) -> str:
147
+ """Convert EntityMarkdown to markdown string with frontmatter.
148
+
149
+ This method handles serialization only - it does not write to files.
150
+ Use FileService.write_file() to persist the output.
151
+
152
+ This enables cloud environments to override file operations via
153
+ dependency injection while reusing the serialization logic.
154
+
155
+ Args:
156
+ markdown: EntityMarkdown schema to serialize
157
+
158
+ Returns:
159
+ Complete markdown string with frontmatter, content, and structured sections
160
+ """
161
+ # Convert frontmatter to dict
162
+ frontmatter_dict = OrderedDict()
163
+ frontmatter_dict["title"] = markdown.frontmatter.title
164
+ frontmatter_dict["type"] = markdown.frontmatter.type
165
+ frontmatter_dict["permalink"] = markdown.frontmatter.permalink
166
+
167
+ metadata = markdown.frontmatter.metadata or {}
168
+ for k, v in metadata.items():
169
+ frontmatter_dict[k] = v
170
+
171
+ # Start with user content (or minimal title for new files)
172
+ content = markdown.content or f"# {markdown.frontmatter.title}\n"
173
+
174
+ # Add structured sections with proper spacing
175
+ content = content.rstrip() # Remove trailing whitespace
176
+
177
+ # Add a blank line if we have semantic content
178
+ if markdown.observations or markdown.relations:
179
+ content += "\n"
180
+
181
+ if markdown.observations:
182
+ content += self.format_observations(markdown.observations)
183
+ if markdown.relations:
184
+ content += self.format_relations(markdown.relations)
185
+
186
+ # Create Post object for frontmatter
187
+ post = Post(content, **frontmatter_dict)
188
+ return dump_frontmatter(post)
126
189
 
127
190
  def format_observations(self, observations: list[Observation]) -> str:
128
191
  """Format observations section in standard way.
@@ -30,7 +30,9 @@ def is_observation(token: Token) -> bool:
30
30
 
31
31
  # Check for proper observation format: [category] content
32
32
  match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
33
- has_tags = "#" in content
33
+ # Check for standalone hashtags (words starting with #)
34
+ # This excludes # in HTML attributes like color="#4285F4"
35
+ has_tags = any(part.startswith("#") for part in content.split())
34
36
  return bool(match) or has_tags
35
37
 
36
38
 
@@ -160,7 +162,7 @@ def parse_inline_relations(content: str) -> List[Dict[str, Any]]:
160
162
 
161
163
  target = content[start + 2 : end].strip()
162
164
  if target:
163
- relations.append({"type": "links to", "target": target, "context": None})
165
+ relations.append({"type": "links_to", "target": target, "context": None})
164
166
 
165
167
  start = end + 2
166
168
 
@@ -3,6 +3,7 @@
3
3
  from pathlib import Path
4
4
  from typing import Any, Optional
5
5
 
6
+
6
7
  from frontmatter import Post
7
8
 
8
9
  from basic_memory.file_utils import has_frontmatter, remove_frontmatter, parse_frontmatter
@@ -12,7 +13,10 @@ from basic_memory.models import Observation as ObservationModel
12
13
 
13
14
 
14
15
  def entity_model_from_markdown(
15
- file_path: Path, markdown: EntityMarkdown, entity: Optional[Entity] = None
16
+ file_path: Path,
17
+ markdown: EntityMarkdown,
18
+ entity: Optional[Entity] = None,
19
+ project_id: Optional[int] = None,
16
20
  ) -> Entity:
17
21
  """
18
22
  Convert markdown entity to model. Does not include relations.
@@ -21,6 +25,7 @@ def entity_model_from_markdown(
21
25
  file_path: Path to the markdown file
22
26
  markdown: Parsed markdown entity
23
27
  entity: Optional existing entity to update
28
+ project_id: Project ID for new observations (uses entity.project_id if not provided)
24
29
 
25
30
  Returns:
26
31
  Entity model populated from markdown
@@ -50,9 +55,13 @@ def entity_model_from_markdown(
50
55
  metadata = markdown.frontmatter.metadata or {}
51
56
  model.entity_metadata = {k: str(v) for k, v in metadata.items() if v is not None}
52
57
 
58
+ # Get project_id from entity if not provided
59
+ obs_project_id = project_id or (model.project_id if hasattr(model, "project_id") else None)
60
+
53
61
  # Convert observations
54
62
  model.observations = [
55
63
  ObservationModel(
64
+ project_id=obs_project_id,
56
65
  content=obs.content,
57
66
  category=obs.category,
58
67
  context=obs.context,
@@ -95,6 +95,7 @@ async def get_client() -> AsyncIterator[AsyncClient]:
95
95
  yield client
96
96
  else:
97
97
  # Local mode: ASGI transport for in-process calls
98
+ # Note: ASGI transport does NOT trigger FastAPI lifespan, so no special handling needed
98
99
  logger.info("Creating ASGI client for local Basic Memory API")
99
100
  async with AsyncClient(
100
101
  transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
@@ -0,0 +1,28 @@
1
+ """Typed internal API clients for MCP tools.
2
+
3
+ These clients encapsulate API paths, error handling, and response validation.
4
+ MCP tools become thin adapters that call these clients and format results.
5
+
6
+ Usage:
7
+ from basic_memory.mcp.clients import KnowledgeClient, SearchClient
8
+
9
+ async with get_client() as http_client:
10
+ knowledge = KnowledgeClient(http_client, project_id)
11
+ entity = await knowledge.create_entity(entity_data)
12
+ """
13
+
14
+ from basic_memory.mcp.clients.knowledge import KnowledgeClient
15
+ from basic_memory.mcp.clients.search import SearchClient
16
+ from basic_memory.mcp.clients.memory import MemoryClient
17
+ from basic_memory.mcp.clients.directory import DirectoryClient
18
+ from basic_memory.mcp.clients.resource import ResourceClient
19
+ from basic_memory.mcp.clients.project import ProjectClient
20
+
21
+ __all__ = [
22
+ "KnowledgeClient",
23
+ "SearchClient",
24
+ "MemoryClient",
25
+ "DirectoryClient",
26
+ "ResourceClient",
27
+ "ProjectClient",
28
+ ]
@@ -0,0 +1,70 @@
1
+ """Typed client for directory API operations.
2
+
3
+ Encapsulates all /v2/projects/{project_id}/directory/* endpoints.
4
+ """
5
+
6
+ from typing import Optional, Any
7
+
8
+ from httpx import AsyncClient
9
+
10
+ from basic_memory.mcp.tools.utils import call_get
11
+
12
+
13
+ class DirectoryClient:
14
+ """Typed client for directory listing operations.
15
+
16
+ Centralizes:
17
+ - API path construction for /v2/projects/{project_id}/directory/*
18
+ - Response validation
19
+ - Consistent error handling through call_* utilities
20
+
21
+ Usage:
22
+ async with get_client() as http_client:
23
+ client = DirectoryClient(http_client, project_id)
24
+ nodes = await client.list("/", depth=2)
25
+ """
26
+
27
+ def __init__(self, http_client: AsyncClient, project_id: str):
28
+ """Initialize the directory client.
29
+
30
+ Args:
31
+ http_client: HTTPX AsyncClient for making requests
32
+ project_id: Project external_id (UUID) for API calls
33
+ """
34
+ self.http_client = http_client
35
+ self.project_id = project_id
36
+ self._base_path = f"/v2/projects/{project_id}/directory"
37
+
38
+ async def list(
39
+ self,
40
+ dir_name: str = "/",
41
+ *,
42
+ depth: int = 1,
43
+ file_name_glob: Optional[str] = None,
44
+ ) -> list[dict[str, Any]]:
45
+ """List directory contents.
46
+
47
+ Args:
48
+ dir_name: Directory path to list (default: root)
49
+ depth: How deep to traverse (default: 1)
50
+ file_name_glob: Optional glob pattern to filter files
51
+
52
+ Returns:
53
+ List of directory nodes with their contents
54
+
55
+ Raises:
56
+ ToolError: If the request fails
57
+ """
58
+ params: dict = {
59
+ "dir_name": dir_name,
60
+ "depth": depth,
61
+ }
62
+ if file_name_glob:
63
+ params["file_name_glob"] = file_name_glob
64
+
65
+ response = await call_get(
66
+ self.http_client,
67
+ f"{self._base_path}/list",
68
+ params=params,
69
+ )
70
+ return response.json()
@@ -0,0 +1,176 @@
1
+ """Typed client for knowledge/entity API operations.
2
+
3
+ Encapsulates all /v2/projects/{project_id}/knowledge/* endpoints.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from httpx import AsyncClient
9
+
10
+ from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
11
+ from basic_memory.schemas.response import EntityResponse, DeleteEntitiesResponse
12
+
13
+
14
+ class KnowledgeClient:
15
+ """Typed client for knowledge graph entity operations.
16
+
17
+ Centralizes:
18
+ - API path construction for /v2/projects/{project_id}/knowledge/*
19
+ - Response validation via Pydantic models
20
+ - Consistent error handling through call_* utilities
21
+
22
+ Usage:
23
+ async with get_client() as http_client:
24
+ client = KnowledgeClient(http_client, project_id)
25
+ entity = await client.create_entity(entity_data)
26
+ """
27
+
28
+ def __init__(self, http_client: AsyncClient, project_id: str):
29
+ """Initialize the knowledge client.
30
+
31
+ Args:
32
+ http_client: HTTPX AsyncClient for making requests
33
+ project_id: Project external_id (UUID) for API calls
34
+ """
35
+ self.http_client = http_client
36
+ self.project_id = project_id
37
+ self._base_path = f"/v2/projects/{project_id}/knowledge"
38
+
39
+ # --- Entity CRUD Operations ---
40
+
41
+ async def create_entity(self, entity_data: dict[str, Any]) -> EntityResponse:
42
+ """Create a new entity.
43
+
44
+ Args:
45
+ entity_data: Entity data including title, content, folder, etc.
46
+
47
+ Returns:
48
+ EntityResponse with created entity details
49
+
50
+ Raises:
51
+ ToolError: If the request fails
52
+ """
53
+ response = await call_post(
54
+ self.http_client,
55
+ f"{self._base_path}/entities",
56
+ json=entity_data,
57
+ )
58
+ return EntityResponse.model_validate(response.json())
59
+
60
+ async def update_entity(self, entity_id: str, entity_data: dict[str, Any]) -> EntityResponse:
61
+ """Update an existing entity (full replacement).
62
+
63
+ Args:
64
+ entity_id: Entity external_id (UUID)
65
+ entity_data: Complete entity data for replacement
66
+
67
+ Returns:
68
+ EntityResponse with updated entity details
69
+
70
+ Raises:
71
+ ToolError: If the request fails
72
+ """
73
+ response = await call_put(
74
+ self.http_client,
75
+ f"{self._base_path}/entities/{entity_id}",
76
+ json=entity_data,
77
+ )
78
+ return EntityResponse.model_validate(response.json())
79
+
80
+ async def get_entity(self, entity_id: str) -> EntityResponse:
81
+ """Get an entity by ID.
82
+
83
+ Args:
84
+ entity_id: Entity external_id (UUID)
85
+
86
+ Returns:
87
+ EntityResponse with entity details
88
+
89
+ Raises:
90
+ ToolError: If the entity is not found or request fails
91
+ """
92
+ response = await call_get(
93
+ self.http_client,
94
+ f"{self._base_path}/entities/{entity_id}",
95
+ )
96
+ return EntityResponse.model_validate(response.json())
97
+
98
+ async def patch_entity(self, entity_id: str, patch_data: dict[str, Any]) -> EntityResponse:
99
+ """Partially update an entity.
100
+
101
+ Args:
102
+ entity_id: Entity external_id (UUID)
103
+ patch_data: Partial entity data to update
104
+
105
+ Returns:
106
+ EntityResponse with updated entity details
107
+
108
+ Raises:
109
+ ToolError: If the request fails
110
+ """
111
+ response = await call_patch(
112
+ self.http_client,
113
+ f"{self._base_path}/entities/{entity_id}",
114
+ json=patch_data,
115
+ )
116
+ return EntityResponse.model_validate(response.json())
117
+
118
+ async def delete_entity(self, entity_id: str) -> DeleteEntitiesResponse:
119
+ """Delete an entity.
120
+
121
+ Args:
122
+ entity_id: Entity external_id (UUID)
123
+
124
+ Returns:
125
+ DeleteEntitiesResponse confirming deletion
126
+
127
+ Raises:
128
+ ToolError: If the entity is not found or request fails
129
+ """
130
+ response = await call_delete(
131
+ self.http_client,
132
+ f"{self._base_path}/entities/{entity_id}",
133
+ )
134
+ return DeleteEntitiesResponse.model_validate(response.json())
135
+
136
+ async def move_entity(self, entity_id: str, destination_path: str) -> EntityResponse:
137
+ """Move an entity to a new location.
138
+
139
+ Args:
140
+ entity_id: Entity external_id (UUID)
141
+ destination_path: New file path for the entity
142
+
143
+ Returns:
144
+ EntityResponse with updated entity details
145
+
146
+ Raises:
147
+ ToolError: If the request fails
148
+ """
149
+ response = await call_put(
150
+ self.http_client,
151
+ f"{self._base_path}/entities/{entity_id}/move",
152
+ json={"destination_path": destination_path},
153
+ )
154
+ return EntityResponse.model_validate(response.json())
155
+
156
+ # --- Resolution ---
157
+
158
+ async def resolve_entity(self, identifier: str) -> str:
159
+ """Resolve a string identifier to an entity external_id.
160
+
161
+ Args:
162
+ identifier: The identifier to resolve (permalink, title, or path)
163
+
164
+ Returns:
165
+ The resolved entity external_id (UUID)
166
+
167
+ Raises:
168
+ ToolError: If the identifier cannot be resolved
169
+ """
170
+ response = await call_post(
171
+ self.http_client,
172
+ f"{self._base_path}/resolve",
173
+ json={"identifier": identifier},
174
+ )
175
+ data = response.json()
176
+ return data["external_id"]
@@ -0,0 +1,120 @@
1
+ """Typed client for memory/context API operations.
2
+
3
+ Encapsulates all /v2/projects/{project_id}/memory/* endpoints.
4
+ """
5
+
6
+ from typing import Optional
7
+
8
+ from httpx import AsyncClient
9
+
10
+ from basic_memory.mcp.tools.utils import call_get
11
+ from basic_memory.schemas.memory import GraphContext
12
+
13
+
14
+ class MemoryClient:
15
+ """Typed client for memory context operations.
16
+
17
+ Centralizes:
18
+ - API path construction for /v2/projects/{project_id}/memory/*
19
+ - Response validation via Pydantic models
20
+ - Consistent error handling through call_* utilities
21
+
22
+ Usage:
23
+ async with get_client() as http_client:
24
+ client = MemoryClient(http_client, project_id)
25
+ context = await client.build_context("memory://specs/search")
26
+ """
27
+
28
+ def __init__(self, http_client: AsyncClient, project_id: str):
29
+ """Initialize the memory client.
30
+
31
+ Args:
32
+ http_client: HTTPX AsyncClient for making requests
33
+ project_id: Project external_id (UUID) for API calls
34
+ """
35
+ self.http_client = http_client
36
+ self.project_id = project_id
37
+ self._base_path = f"/v2/projects/{project_id}/memory"
38
+
39
+ async def build_context(
40
+ self,
41
+ path: str,
42
+ *,
43
+ depth: int = 1,
44
+ timeframe: Optional[str] = None,
45
+ page: int = 1,
46
+ page_size: int = 10,
47
+ max_related: int = 10,
48
+ ) -> GraphContext:
49
+ """Build context from a memory path.
50
+
51
+ Args:
52
+ path: The path to build context for (without memory:// prefix)
53
+ depth: How deep to traverse relations
54
+ timeframe: Time filter (e.g., "7d", "1 week")
55
+ page: Page number (1-indexed)
56
+ page_size: Results per page
57
+ max_related: Maximum related items per result
58
+
59
+ Returns:
60
+ GraphContext with hierarchical results
61
+
62
+ Raises:
63
+ ToolError: If the request fails
64
+ """
65
+ params: dict = {
66
+ "depth": depth,
67
+ "page": page,
68
+ "page_size": page_size,
69
+ "max_related": max_related,
70
+ }
71
+ if timeframe:
72
+ params["timeframe"] = timeframe
73
+
74
+ response = await call_get(
75
+ self.http_client,
76
+ f"{self._base_path}/{path}",
77
+ params=params,
78
+ )
79
+ return GraphContext.model_validate(response.json())
80
+
81
+ async def recent(
82
+ self,
83
+ *,
84
+ timeframe: str = "7d",
85
+ depth: int = 1,
86
+ types: Optional[list[str]] = None,
87
+ page: int = 1,
88
+ page_size: int = 10,
89
+ ) -> GraphContext:
90
+ """Get recent activity.
91
+
92
+ Args:
93
+ timeframe: Time filter (e.g., "7d", "1 week", "2 days ago")
94
+ depth: How deep to traverse relations
95
+ types: Filter by item types
96
+ page: Page number (1-indexed)
97
+ page_size: Results per page
98
+
99
+ Returns:
100
+ GraphContext with recent activity
101
+
102
+ Raises:
103
+ ToolError: If the request fails
104
+ """
105
+ params: dict = {
106
+ "timeframe": timeframe,
107
+ "depth": depth,
108
+ "page": page,
109
+ "page_size": page_size,
110
+ }
111
+ if types:
112
+ # Join types as comma-separated string if provided
113
+ params["type"] = ",".join(types) if isinstance(types, list) else types
114
+
115
+ response = await call_get(
116
+ self.http_client,
117
+ f"{self._base_path}/recent",
118
+ params=params,
119
+ )
120
+ return GraphContext.model_validate(response.json())