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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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(
|
|
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 {
|
|
241
|
+
f"Failed to parse YAML frontmatter in {file_path}: {e}. "
|
|
199
242
|
f"Treating file as plain markdown without frontmatter."
|
|
200
243
|
)
|
|
201
|
-
|
|
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
|
|
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
|
|
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"] =
|
|
252
|
+
metadata["title"] = file_path.stem
|
|
218
253
|
else:
|
|
219
254
|
metadata["title"] = title
|
|
220
|
-
|
|
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
|
-
#
|
|
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=
|
|
239
|
-
modified=
|
|
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__(
|
|
39
|
-
|
|
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
|
-
|
|
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.
|
basic_memory/markdown/plugins.py
CHANGED
|
@@ -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
|
-
|
|
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": "
|
|
165
|
+
relations.append({"type": "links_to", "target": target, "context": None})
|
|
164
166
|
|
|
165
167
|
start = end + 2
|
|
166
168
|
|
basic_memory/markdown/utils.py
CHANGED
|
@@ -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,
|
|
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,
|
basic_memory/mcp/async_client.py
CHANGED
|
@@ -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())
|