basic-memory 0.2.12__py3-none-any.whl → 0.16.1__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 +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +27 -3
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/api/app.py +63 -31
- basic_memory/api/routers/__init__.py +4 -1
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +165 -28
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +28 -67
- basic_memory/api/routers/project_router.py +406 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +219 -14
- basic_memory/api/routers/search_router.py +21 -13
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +52 -1
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +13 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +51 -0
- basic_memory/cli/commands/db.py +26 -7
- basic_memory/cli/commands/import_chatgpt.py +83 -0
- basic_memory/cli/commands/import_claude_conversations.py +86 -0
- basic_memory/cli/commands/import_claude_projects.py +85 -0
- basic_memory/cli/commands/import_memory_json.py +35 -92
- basic_memory/cli/commands/mcp.py +84 -10
- basic_memory/cli/commands/project.py +876 -0
- basic_memory/cli/commands/status.py +47 -30
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +13 -6
- basic_memory/config.py +481 -22
- basic_memory/db.py +192 -32
- basic_memory/deps.py +252 -22
- basic_memory/file_utils.py +113 -58
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +177 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +143 -23
- basic_memory/markdown/markdown_processor.py +3 -3
- basic_memory/markdown/plugins.py +39 -21
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +28 -13
- basic_memory/mcp/async_client.py +134 -4
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +7 -13
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +130 -0
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +225 -0
- basic_memory/mcp/tools/edit_note.py +320 -0
- basic_memory/mcp/tools/list_directory.py +167 -0
- basic_memory/mcp/tools/move_note.py +545 -0
- basic_memory/mcp/tools/project_management.py +200 -0
- basic_memory/mcp/tools/read_content.py +271 -0
- basic_memory/mcp/tools/read_note.py +255 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +369 -14
- basic_memory/mcp/tools/utils.py +374 -16
- basic_memory/mcp/tools/view_note.py +77 -0
- basic_memory/mcp/tools/write_note.py +207 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +67 -15
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +10 -6
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +229 -7
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +103 -0
- basic_memory/repository/relation_repository.py +21 -2
- basic_memory/repository/repository.py +147 -29
- basic_memory/repository/search_repository.py +437 -59
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +97 -8
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +188 -23
- basic_memory/schemas/project_info.py +211 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +57 -3
- basic_memory/schemas/response.py +9 -1
- basic_memory/schemas/search.py +33 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +251 -106
- basic_memory/services/directory_service.py +295 -0
- basic_memory/services/entity_service.py +595 -60
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +284 -30
- basic_memory/services/initialization.py +191 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +863 -0
- basic_memory/services/search_service.py +172 -34
- basic_memory/sync/__init__.py +3 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1176 -96
- basic_memory/sync/watch_service.py +412 -135
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +388 -28
- basic_memory-0.16.1.dist-info/METADATA +493 -0
- basic_memory-0.16.1.dist-info/RECORD +148 -0
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -203
- basic_memory/mcp/tools/knowledge.py +0 -56
- basic_memory/mcp/tools/memory.py +0 -151
- basic_memory/mcp/tools/notes.py +0 -122
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -34
- basic_memory-0.2.12.dist-info/METADATA +0 -291
- basic_memory-0.2.12.dist-info/RECORD +0 -78
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Utility functions for import services."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def clean_filename(name: str) -> str: # pragma: no cover
|
|
9
|
+
"""Clean a string to be used as a filename.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
name: The string to clean.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
A cleaned string suitable for use as a filename.
|
|
16
|
+
"""
|
|
17
|
+
# Replace common punctuation and whitespace with underscores
|
|
18
|
+
name = re.sub(r"[\s\-,.:/\\\[\]\(\)]+", "_", name)
|
|
19
|
+
# Remove any non-alphanumeric or underscore characters
|
|
20
|
+
name = re.sub(r"[^\w]+", "", name)
|
|
21
|
+
# Ensure the name isn't too long
|
|
22
|
+
if len(name) > 100: # pragma: no cover
|
|
23
|
+
name = name[:100]
|
|
24
|
+
# Ensure the name isn't empty
|
|
25
|
+
if not name: # pragma: no cover
|
|
26
|
+
name = "untitled"
|
|
27
|
+
return name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_timestamp(timestamp: Any) -> str: # pragma: no cover
|
|
31
|
+
"""Format a timestamp for use in a filename or title.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
timestamp: A timestamp in various formats.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A formatted string representation of the timestamp.
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(timestamp, str):
|
|
40
|
+
try:
|
|
41
|
+
# Try ISO format
|
|
42
|
+
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
43
|
+
except ValueError:
|
|
44
|
+
try:
|
|
45
|
+
# Try unix timestamp as string
|
|
46
|
+
timestamp = datetime.fromtimestamp(float(timestamp)).astimezone()
|
|
47
|
+
except ValueError:
|
|
48
|
+
# Return as is if we can't parse it
|
|
49
|
+
return timestamp
|
|
50
|
+
elif isinstance(timestamp, (int, float)):
|
|
51
|
+
# Unix timestamp
|
|
52
|
+
timestamp = datetime.fromtimestamp(timestamp).astimezone()
|
|
53
|
+
|
|
54
|
+
if isinstance(timestamp, datetime):
|
|
55
|
+
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
|
56
|
+
|
|
57
|
+
# Return as is if we can't format it
|
|
58
|
+
return str(timestamp) # pragma: no cover
|
|
@@ -4,25 +4,104 @@ Uses markdown-it with plugins to parse structured data from markdown content.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date, datetime
|
|
7
8
|
from pathlib import Path
|
|
8
|
-
from datetime import datetime
|
|
9
9
|
from typing import Any, Optional
|
|
10
|
-
import dateparser
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
import dateparser
|
|
13
12
|
import frontmatter
|
|
13
|
+
import yaml
|
|
14
|
+
from loguru import logger
|
|
15
|
+
from markdown_it import MarkdownIt
|
|
14
16
|
|
|
15
17
|
from basic_memory.markdown.plugins import observation_plugin, relation_plugin
|
|
16
18
|
from basic_memory.markdown.schemas import (
|
|
17
|
-
EntityMarkdown,
|
|
18
19
|
EntityFrontmatter,
|
|
20
|
+
EntityMarkdown,
|
|
19
21
|
Observation,
|
|
20
22
|
Relation,
|
|
21
23
|
)
|
|
24
|
+
from basic_memory.utils import parse_tags
|
|
22
25
|
|
|
23
26
|
md = MarkdownIt().use(observation_plugin).use(relation_plugin)
|
|
24
27
|
|
|
25
28
|
|
|
29
|
+
def normalize_frontmatter_value(value: Any) -> Any:
|
|
30
|
+
"""Normalize frontmatter values to safe types for processing.
|
|
31
|
+
|
|
32
|
+
PyYAML automatically converts various string-like values into native Python types:
|
|
33
|
+
- Date strings ("2025-10-24") → datetime.date objects
|
|
34
|
+
- Numbers ("1.0") → int or float
|
|
35
|
+
- Booleans ("true") → bool
|
|
36
|
+
- Lists → list objects
|
|
37
|
+
|
|
38
|
+
This can cause AttributeError when code expects strings and calls string methods
|
|
39
|
+
like .strip() on these values (see GitHub issue #236).
|
|
40
|
+
|
|
41
|
+
This function normalizes all frontmatter values to safe types:
|
|
42
|
+
- Dates/datetimes → ISO format strings
|
|
43
|
+
- Numbers (int/float) → strings
|
|
44
|
+
- Booleans → strings ("True"/"False")
|
|
45
|
+
- Lists → preserved as lists, but items are recursively normalized
|
|
46
|
+
- Dicts → preserved as dicts, but values are recursively normalized
|
|
47
|
+
- Strings → kept as-is
|
|
48
|
+
- None → kept as None
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
value: The frontmatter value to normalize
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The normalized value safe for string operations
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
>>> normalize_frontmatter_value(datetime.date(2025, 10, 24))
|
|
58
|
+
'2025-10-24'
|
|
59
|
+
>>> normalize_frontmatter_value([datetime.date(2025, 10, 24), "tag", 123])
|
|
60
|
+
['2025-10-24', 'tag', '123']
|
|
61
|
+
>>> normalize_frontmatter_value(True)
|
|
62
|
+
'True'
|
|
63
|
+
"""
|
|
64
|
+
# Convert date/datetime objects to ISO format strings
|
|
65
|
+
if isinstance(value, datetime):
|
|
66
|
+
return value.isoformat()
|
|
67
|
+
if isinstance(value, date):
|
|
68
|
+
return value.isoformat()
|
|
69
|
+
|
|
70
|
+
# Convert boolean to string (must come before int check since bool is subclass of int)
|
|
71
|
+
if isinstance(value, bool):
|
|
72
|
+
return str(value)
|
|
73
|
+
|
|
74
|
+
# Convert numbers to strings
|
|
75
|
+
if isinstance(value, (int, float)):
|
|
76
|
+
return str(value)
|
|
77
|
+
|
|
78
|
+
# Recursively process lists (preserve as list, normalize items)
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
return [normalize_frontmatter_value(item) for item in value]
|
|
81
|
+
|
|
82
|
+
# Recursively process dicts (preserve as dict, normalize values)
|
|
83
|
+
if isinstance(value, dict):
|
|
84
|
+
return {key: normalize_frontmatter_value(val) for key, val in value.items()}
|
|
85
|
+
|
|
86
|
+
# Keep strings and None as-is
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def normalize_frontmatter_metadata(metadata: dict) -> dict:
|
|
91
|
+
"""Normalize all values in frontmatter metadata dict.
|
|
92
|
+
|
|
93
|
+
Converts date/datetime objects to ISO format strings to prevent
|
|
94
|
+
AttributeError when code expects strings (GitHub issue #236).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
metadata: The frontmatter metadata dictionary
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A new dictionary with all values normalized
|
|
101
|
+
"""
|
|
102
|
+
return {key: normalize_frontmatter_value(value) for key, value in metadata.items()}
|
|
103
|
+
|
|
104
|
+
|
|
26
105
|
@dataclass
|
|
27
106
|
class EntityContent:
|
|
28
107
|
content: str
|
|
@@ -56,11 +135,11 @@ def parse(content: str) -> EntityContent:
|
|
|
56
135
|
)
|
|
57
136
|
|
|
58
137
|
|
|
59
|
-
def parse_tags(tags: Any) -> list[str]:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
138
|
+
# def parse_tags(tags: Any) -> list[str]:
|
|
139
|
+
# """Parse tags into list of strings."""
|
|
140
|
+
# if isinstance(tags, (list, tuple)):
|
|
141
|
+
# return [str(t).strip() for t in tags if str(t).strip()]
|
|
142
|
+
# return [t.strip() for t in tags.split(",") if t.strip()]
|
|
64
143
|
|
|
65
144
|
|
|
66
145
|
class EntityParser:
|
|
@@ -88,33 +167,74 @@ class EntityParser:
|
|
|
88
167
|
return parsed
|
|
89
168
|
return None
|
|
90
169
|
|
|
91
|
-
async def parse_file(self,
|
|
170
|
+
async def parse_file(self, path: Path | str) -> EntityMarkdown:
|
|
92
171
|
"""Parse markdown file into EntityMarkdown."""
|
|
93
172
|
|
|
94
|
-
|
|
173
|
+
# Check if the path is already absolute
|
|
174
|
+
if (
|
|
175
|
+
isinstance(path, Path)
|
|
176
|
+
and path.is_absolute()
|
|
177
|
+
or (isinstance(path, str) and Path(path).is_absolute())
|
|
178
|
+
):
|
|
179
|
+
absolute_path = Path(path)
|
|
180
|
+
else:
|
|
181
|
+
absolute_path = self.get_file_path(path)
|
|
182
|
+
|
|
95
183
|
# Parse frontmatter and content using python-frontmatter
|
|
96
|
-
|
|
184
|
+
file_content = absolute_path.read_text(encoding="utf-8")
|
|
185
|
+
return await self.parse_file_content(absolute_path, file_content)
|
|
186
|
+
|
|
187
|
+
def get_file_path(self, path):
|
|
188
|
+
"""Get absolute path for a file using the base path for the project."""
|
|
189
|
+
return self.base_path / path
|
|
190
|
+
|
|
191
|
+
async def parse_file_content(self, absolute_path, file_content):
|
|
192
|
+
# Parse frontmatter with proper error handling for malformed YAML (issue #185)
|
|
193
|
+
try:
|
|
194
|
+
post = frontmatter.loads(file_content)
|
|
195
|
+
except yaml.YAMLError as e:
|
|
196
|
+
# Log the YAML parsing error with file context
|
|
197
|
+
logger.warning(
|
|
198
|
+
f"Failed to parse YAML frontmatter in {absolute_path}: {e}. "
|
|
199
|
+
f"Treating file as plain markdown without frontmatter."
|
|
200
|
+
)
|
|
201
|
+
# Create a post with no frontmatter - treat entire content as markdown
|
|
202
|
+
post = frontmatter.Post(file_content, metadata={})
|
|
97
203
|
|
|
98
204
|
# Extract file stat info
|
|
99
205
|
file_stats = absolute_path.stat()
|
|
100
206
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
211
|
+
metadata = normalize_frontmatter_metadata(post.metadata)
|
|
212
|
+
|
|
213
|
+
# Ensure required fields have defaults (issue #184, #387)
|
|
214
|
+
# Handle title - use default if missing, None/null, empty, or string "None"
|
|
215
|
+
title = metadata.get("title")
|
|
216
|
+
if not title or title == "None":
|
|
217
|
+
metadata["title"] = absolute_path.stem
|
|
218
|
+
else:
|
|
219
|
+
metadata["title"] = title
|
|
220
|
+
# Handle type - use default if missing OR explicitly set to None/null
|
|
221
|
+
entity_type = metadata.get("type")
|
|
222
|
+
metadata["type"] = entity_type if entity_type is not None else "note"
|
|
223
|
+
|
|
224
|
+
tags = parse_tags(metadata.get("tags", [])) # pyright: ignore
|
|
225
|
+
if tags:
|
|
226
|
+
metadata["tags"] = tags
|
|
227
|
+
|
|
228
|
+
# frontmatter - use metadata with defaults applied
|
|
107
229
|
entity_frontmatter = EntityFrontmatter(
|
|
108
|
-
metadata=
|
|
230
|
+
metadata=metadata,
|
|
109
231
|
)
|
|
110
|
-
|
|
111
232
|
entity_content = parse(post.content)
|
|
112
|
-
|
|
113
233
|
return EntityMarkdown(
|
|
114
234
|
frontmatter=entity_frontmatter,
|
|
115
235
|
content=post.content,
|
|
116
236
|
observations=entity_content.observations,
|
|
117
237
|
relations=entity_content.relations,
|
|
118
|
-
created=datetime.fromtimestamp(file_stats.st_ctime),
|
|
119
|
-
modified=datetime.fromtimestamp(file_stats.st_mtime),
|
|
238
|
+
created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
|
|
239
|
+
modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
|
|
120
240
|
)
|
|
@@ -2,11 +2,11 @@ from pathlib import Path
|
|
|
2
2
|
from typing import Optional
|
|
3
3
|
from collections import OrderedDict
|
|
4
4
|
|
|
5
|
-
import frontmatter
|
|
6
5
|
from frontmatter import Post
|
|
7
6
|
from loguru import logger
|
|
8
7
|
|
|
9
8
|
from basic_memory import file_utils
|
|
9
|
+
from basic_memory.file_utils import dump_frontmatter
|
|
10
10
|
from basic_memory.markdown.entity_parser import EntityParser
|
|
11
11
|
from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation
|
|
12
12
|
|
|
@@ -83,7 +83,7 @@ class MarkdownProcessor:
|
|
|
83
83
|
"""
|
|
84
84
|
# Dirty check if needed
|
|
85
85
|
if expected_checksum is not None:
|
|
86
|
-
current_content = path.read_text()
|
|
86
|
+
current_content = path.read_text(encoding="utf-8")
|
|
87
87
|
current_checksum = await file_utils.compute_checksum(current_content)
|
|
88
88
|
if current_checksum != expected_checksum:
|
|
89
89
|
raise DirtyFileError(f"File {path} has been modified")
|
|
@@ -115,7 +115,7 @@ class MarkdownProcessor:
|
|
|
115
115
|
|
|
116
116
|
# Create Post object for frontmatter
|
|
117
117
|
post = Post(content, **frontmatter_dict)
|
|
118
|
-
final_content =
|
|
118
|
+
final_content = dump_frontmatter(post)
|
|
119
119
|
|
|
120
120
|
logger.debug(f"writing file {path} with content:\n{final_content}")
|
|
121
121
|
|
basic_memory/markdown/plugins.py
CHANGED
|
@@ -8,34 +8,50 @@ from markdown_it.token import Token
|
|
|
8
8
|
# Observation handling functions
|
|
9
9
|
def is_observation(token: Token) -> bool:
|
|
10
10
|
"""Check if token looks like our observation format."""
|
|
11
|
+
import re
|
|
12
|
+
|
|
11
13
|
if token.type != "inline": # pragma: no cover
|
|
12
14
|
return False
|
|
13
|
-
|
|
14
|
-
content = token.content.strip()
|
|
15
|
+
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
16
|
+
content = (token.tag or token.content).strip()
|
|
15
17
|
if not content: # pragma: no cover
|
|
16
18
|
return False
|
|
17
|
-
|
|
18
19
|
# if it's a markdown_task, return false
|
|
19
20
|
if content.startswith("[ ]") or content.startswith("[x]") or content.startswith("[-]"):
|
|
20
21
|
return False
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
# Exclude markdown links: [text](url)
|
|
24
|
+
if re.match(r"^\[.*?\]\(.*?\)$", content):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
# Exclude wiki links: [[text]]
|
|
28
|
+
if re.match(r"^\[\[.*?\]\]$", content):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
# Check for proper observation format: [category] content
|
|
32
|
+
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
|
|
23
33
|
has_tags = "#" in content
|
|
24
|
-
return
|
|
34
|
+
return bool(match) or has_tags
|
|
25
35
|
|
|
26
36
|
|
|
27
37
|
def parse_observation(token: Token) -> Dict[str, Any]:
|
|
28
38
|
"""Extract observation parts from token."""
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
import re
|
|
40
|
+
|
|
41
|
+
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
42
|
+
content = (token.tag or token.content).strip()
|
|
31
43
|
|
|
32
|
-
# Parse [category]
|
|
44
|
+
# Parse [category] with regex
|
|
45
|
+
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
|
|
33
46
|
category = None
|
|
34
|
-
if
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
if match:
|
|
48
|
+
category = match.group(1).strip()
|
|
49
|
+
content = match.group(2).strip()
|
|
50
|
+
else:
|
|
51
|
+
# Handle empty brackets [] followed by content
|
|
52
|
+
empty_match = re.match(r"^\[\]\s+(.+)", content)
|
|
53
|
+
if empty_match:
|
|
54
|
+
content = empty_match.group(1).strip()
|
|
39
55
|
|
|
40
56
|
# Parse (context)
|
|
41
57
|
context = None
|
|
@@ -50,9 +66,7 @@ def parse_observation(token: Token) -> Dict[str, Any]:
|
|
|
50
66
|
parts = content.split()
|
|
51
67
|
for part in parts:
|
|
52
68
|
if part.startswith("#"):
|
|
53
|
-
# Handle multiple #tags stuck together
|
|
54
69
|
if "#" in part[1:]:
|
|
55
|
-
# Split on # but keep non-empty tags
|
|
56
70
|
subtags = [t for t in part.split("#") if t]
|
|
57
71
|
tags.extend(subtags)
|
|
58
72
|
else:
|
|
@@ -72,14 +86,16 @@ def is_explicit_relation(token: Token) -> bool:
|
|
|
72
86
|
if token.type != "inline": # pragma: no cover
|
|
73
87
|
return False
|
|
74
88
|
|
|
75
|
-
|
|
89
|
+
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
90
|
+
content = (token.tag or token.content).strip()
|
|
76
91
|
return "[[" in content and "]]" in content
|
|
77
92
|
|
|
78
93
|
|
|
79
94
|
def parse_relation(token: Token) -> Dict[str, Any] | None:
|
|
80
95
|
"""Extract relation parts from token."""
|
|
81
96
|
# Remove bullet point if present
|
|
82
|
-
|
|
97
|
+
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
98
|
+
content = (token.tag or token.content).strip()
|
|
83
99
|
|
|
84
100
|
# Extract [[target]]
|
|
85
101
|
target = None
|
|
@@ -213,10 +229,12 @@ def relation_plugin(md: MarkdownIt) -> None:
|
|
|
213
229
|
token.meta["relations"] = [rel]
|
|
214
230
|
|
|
215
231
|
# Always check for inline links in any text
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if
|
|
219
|
-
|
|
232
|
+
else:
|
|
233
|
+
content = token.tag or token.content
|
|
234
|
+
if "[[" in content:
|
|
235
|
+
rels = parse_inline_relations(content)
|
|
236
|
+
if rels:
|
|
237
|
+
token.meta["relations"] = token.meta.get("relations", []) + rels
|
|
220
238
|
|
|
221
239
|
# Add the rule after inline processing
|
|
222
240
|
md.core.ruler.after("inline", "relations", relation_rule)
|
basic_memory/markdown/schemas.py
CHANGED
|
@@ -42,7 +42,7 @@ class EntityFrontmatter(BaseModel):
|
|
|
42
42
|
|
|
43
43
|
@property
|
|
44
44
|
def tags(self) -> List[str]:
|
|
45
|
-
return self.metadata.get("tags") if self.metadata else
|
|
45
|
+
return self.metadata.get("tags") if self.metadata else None # pyright: ignore
|
|
46
46
|
|
|
47
47
|
@property
|
|
48
48
|
def title(self) -> str:
|
basic_memory/markdown/utils.py
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"""Utilities for converting between markdown and entity models."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import Any, Optional
|
|
5
5
|
|
|
6
6
|
from frontmatter import Post
|
|
7
7
|
|
|
8
|
+
from basic_memory.file_utils import has_frontmatter, remove_frontmatter, parse_frontmatter
|
|
8
9
|
from basic_memory.markdown import EntityMarkdown
|
|
9
|
-
from basic_memory.models import Entity
|
|
10
|
-
from basic_memory.
|
|
10
|
+
from basic_memory.models import Entity
|
|
11
|
+
from basic_memory.models import Observation as ObservationModel
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def entity_model_from_markdown(
|
|
@@ -31,17 +32,16 @@ def entity_model_from_markdown(
|
|
|
31
32
|
if not markdown.created or not markdown.modified: # pragma: no cover
|
|
32
33
|
raise ValueError("Both created and modified dates are required in markdown")
|
|
33
34
|
|
|
34
|
-
# Generate permalink if not provided
|
|
35
|
-
permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
|
|
36
|
-
|
|
37
35
|
# Create or update entity
|
|
38
36
|
model = entity or Entity()
|
|
39
37
|
|
|
40
38
|
# Update basic fields
|
|
41
39
|
model.title = markdown.frontmatter.title
|
|
42
40
|
model.entity_type = markdown.frontmatter.type
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
# Only update permalink if it exists in frontmatter, otherwise preserve existing
|
|
42
|
+
if markdown.frontmatter.permalink is not None:
|
|
43
|
+
model.permalink = markdown.frontmatter.permalink
|
|
44
|
+
model.file_path = file_path.as_posix()
|
|
45
45
|
model.content_type = "text/markdown"
|
|
46
46
|
model.created_at = markdown.created
|
|
47
47
|
model.updated_at = markdown.modified
|
|
@@ -76,18 +76,33 @@ async def schema_to_markdown(schema: Any) -> Post:
|
|
|
76
76
|
"""
|
|
77
77
|
# Extract content and metadata
|
|
78
78
|
content = schema.content or ""
|
|
79
|
-
|
|
79
|
+
entity_metadata = dict(schema.entity_metadata or {})
|
|
80
|
+
|
|
81
|
+
# if the content contains frontmatter, remove it and merge
|
|
82
|
+
if has_frontmatter(content):
|
|
83
|
+
content_frontmatter = parse_frontmatter(content)
|
|
84
|
+
content = remove_frontmatter(content)
|
|
85
|
+
|
|
86
|
+
# Merge content frontmatter with entity metadata
|
|
87
|
+
# (entity_metadata takes precedence for conflicts)
|
|
88
|
+
content_frontmatter.update(entity_metadata)
|
|
89
|
+
entity_metadata = content_frontmatter
|
|
80
90
|
|
|
81
91
|
# Remove special fields for ordered frontmatter
|
|
82
92
|
for field in ["type", "title", "permalink"]:
|
|
83
|
-
|
|
93
|
+
entity_metadata.pop(field, None)
|
|
84
94
|
|
|
85
|
-
# Create Post with ordered
|
|
95
|
+
# Create Post with fields ordered by insert order
|
|
86
96
|
post = Post(
|
|
87
97
|
content,
|
|
88
98
|
title=schema.title,
|
|
89
99
|
type=schema.entity_type,
|
|
90
|
-
permalink=schema.permalink,
|
|
91
|
-
**frontmatter_metadata,
|
|
92
100
|
)
|
|
101
|
+
# set the permalink if passed in
|
|
102
|
+
if schema.permalink:
|
|
103
|
+
post.metadata["permalink"] = schema.permalink
|
|
104
|
+
|
|
105
|
+
if entity_metadata:
|
|
106
|
+
post.metadata.update(entity_metadata)
|
|
107
|
+
|
|
93
108
|
return post
|
basic_memory/mcp/async_client.py
CHANGED
|
@@ -1,8 +1,138 @@
|
|
|
1
|
-
from
|
|
1
|
+
from contextlib import asynccontextmanager, AbstractAsyncContextManager
|
|
2
|
+
from typing import AsyncIterator, Callable, Optional
|
|
3
|
+
|
|
4
|
+
from httpx import ASGITransport, AsyncClient, Timeout
|
|
5
|
+
from loguru import logger
|
|
2
6
|
|
|
3
7
|
from basic_memory.api.app import app as fastapi_app
|
|
8
|
+
from basic_memory.config import ConfigManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Optional factory override for dependency injection
|
|
12
|
+
_client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncClient]]) -> None:
|
|
16
|
+
"""Override the default client factory (for cloud app, testing, etc).
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
factory: An async context manager that yields an AsyncClient
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
@asynccontextmanager
|
|
23
|
+
async def custom_client_factory():
|
|
24
|
+
async with AsyncClient(...) as client:
|
|
25
|
+
yield client
|
|
26
|
+
|
|
27
|
+
set_client_factory(custom_client_factory)
|
|
28
|
+
"""
|
|
29
|
+
global _client_factory
|
|
30
|
+
_client_factory = factory
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@asynccontextmanager
|
|
34
|
+
async def get_client() -> AsyncIterator[AsyncClient]:
|
|
35
|
+
"""Get an AsyncClient as a context manager.
|
|
36
|
+
|
|
37
|
+
This function provides proper resource management for HTTP clients,
|
|
38
|
+
ensuring connections are closed after use. It supports three modes:
|
|
39
|
+
|
|
40
|
+
1. **Factory injection** (cloud app, tests):
|
|
41
|
+
If a custom factory is set via set_client_factory(), use that.
|
|
42
|
+
|
|
43
|
+
2. **CLI cloud mode**:
|
|
44
|
+
When cloud_mode_enabled is True, create HTTP client with auth
|
|
45
|
+
token from CLIAuth for requests to cloud proxy endpoint.
|
|
46
|
+
|
|
47
|
+
3. **Local mode** (default):
|
|
48
|
+
Use ASGI transport for in-process requests to local FastAPI app.
|
|
49
|
+
|
|
50
|
+
Usage:
|
|
51
|
+
async with get_client() as client:
|
|
52
|
+
response = await client.get("/path")
|
|
53
|
+
|
|
54
|
+
Yields:
|
|
55
|
+
AsyncClient: Configured HTTP client for the current mode
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
RuntimeError: If cloud mode is enabled but user is not authenticated
|
|
59
|
+
"""
|
|
60
|
+
if _client_factory:
|
|
61
|
+
# Use injected factory (cloud app, tests)
|
|
62
|
+
async with _client_factory() as client:
|
|
63
|
+
yield client
|
|
64
|
+
else:
|
|
65
|
+
# Default: create based on config
|
|
66
|
+
config = ConfigManager().config
|
|
67
|
+
timeout = Timeout(
|
|
68
|
+
connect=10.0, # 10 seconds for connection
|
|
69
|
+
read=30.0, # 30 seconds for reading response
|
|
70
|
+
write=30.0, # 30 seconds for writing request
|
|
71
|
+
pool=30.0, # 30 seconds for connection pool
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if config.cloud_mode_enabled:
|
|
75
|
+
# CLI cloud mode: inject auth when creating client
|
|
76
|
+
from basic_memory.cli.auth import CLIAuth
|
|
77
|
+
|
|
78
|
+
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
|
79
|
+
token = await auth.get_valid_token()
|
|
80
|
+
|
|
81
|
+
if not token:
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
"Cloud mode enabled but not authenticated. "
|
|
84
|
+
"Run 'basic-memory cloud login' first."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Auth header set ONCE at client creation
|
|
88
|
+
proxy_base_url = f"{config.cloud_host}/proxy"
|
|
89
|
+
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
|
|
90
|
+
async with AsyncClient(
|
|
91
|
+
base_url=proxy_base_url,
|
|
92
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
93
|
+
timeout=timeout,
|
|
94
|
+
) as client:
|
|
95
|
+
yield client
|
|
96
|
+
else:
|
|
97
|
+
# Local mode: ASGI transport for in-process calls
|
|
98
|
+
logger.info("Creating ASGI client for local Basic Memory API")
|
|
99
|
+
async with AsyncClient(
|
|
100
|
+
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
|
101
|
+
) as client:
|
|
102
|
+
yield client
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def create_client() -> AsyncClient:
|
|
106
|
+
"""Create an HTTP client based on configuration.
|
|
107
|
+
|
|
108
|
+
DEPRECATED: Use get_client() context manager instead for proper resource management.
|
|
109
|
+
|
|
110
|
+
This function is kept for backward compatibility but will be removed in a future version.
|
|
111
|
+
The returned client should be closed manually by calling await client.aclose().
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
AsyncClient configured for either local ASGI or remote proxy
|
|
115
|
+
"""
|
|
116
|
+
config_manager = ConfigManager()
|
|
117
|
+
config = config_manager.config
|
|
4
118
|
|
|
5
|
-
|
|
119
|
+
# Configure timeout for longer operations like write_note
|
|
120
|
+
# Default httpx timeout is 5 seconds which is too short for file operations
|
|
121
|
+
timeout = Timeout(
|
|
122
|
+
connect=10.0, # 10 seconds for connection
|
|
123
|
+
read=30.0, # 30 seconds for reading response
|
|
124
|
+
write=30.0, # 30 seconds for writing request
|
|
125
|
+
pool=30.0, # 30 seconds for connection pool
|
|
126
|
+
)
|
|
6
127
|
|
|
7
|
-
|
|
8
|
-
|
|
128
|
+
if config.cloud_mode_enabled:
|
|
129
|
+
# Use HTTP transport to proxy endpoint
|
|
130
|
+
proxy_base_url = f"{config.cloud_host}/proxy"
|
|
131
|
+
logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
|
|
132
|
+
return AsyncClient(base_url=proxy_base_url, timeout=timeout)
|
|
133
|
+
else:
|
|
134
|
+
# Default: use ASGI transport for local API (development mode)
|
|
135
|
+
logger.info("Creating ASGI client for local Basic Memory API")
|
|
136
|
+
return AsyncClient(
|
|
137
|
+
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
|
138
|
+
)
|