basic-memory 0.14.4__py3-none-any.whl → 0.15.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 +1 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
- basic_memory/api/app.py +10 -4
- basic_memory/api/routers/directory_router.py +23 -2
- basic_memory/api/routers/knowledge_router.py +25 -8
- basic_memory/api/routers/project_router.py +100 -4
- basic_memory/cli/app.py +9 -28
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/cloud/__init__.py +5 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
- basic_memory/cli/commands/cloud/core_commands.py +288 -0
- basic_memory/cli/commands/cloud/mount_commands.py +295 -0
- basic_memory/cli/commands/cloud/rclone_config.py +288 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
- basic_memory/cli/commands/command_utils.py +43 -0
- basic_memory/cli/commands/import_memory_json.py +0 -4
- basic_memory/cli/commands/mcp.py +77 -60
- basic_memory/cli/commands/project.py +154 -152
- basic_memory/cli/commands/status.py +25 -22
- basic_memory/cli/commands/sync.py +45 -228
- basic_memory/cli/commands/tool.py +87 -16
- basic_memory/cli/main.py +1 -0
- basic_memory/config.py +131 -21
- basic_memory/db.py +104 -3
- basic_memory/deps.py +27 -8
- basic_memory/file_utils.py +37 -13
- basic_memory/ignore_utils.py +295 -0
- basic_memory/markdown/plugins.py +9 -7
- basic_memory/mcp/async_client.py +124 -14
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
- basic_memory/mcp/prompts/continue_conversation.py +17 -16
- basic_memory/mcp/prompts/recent_activity.py +116 -32
- basic_memory/mcp/prompts/search.py +13 -12
- basic_memory/mcp/prompts/utils.py +11 -4
- basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
- basic_memory/mcp/resources/project_info.py +27 -11
- basic_memory/mcp/server.py +0 -37
- basic_memory/mcp/tools/__init__.py +5 -6
- basic_memory/mcp/tools/build_context.py +67 -56
- basic_memory/mcp/tools/canvas.py +38 -26
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +81 -47
- basic_memory/mcp/tools/edit_note.py +155 -138
- basic_memory/mcp/tools/list_directory.py +112 -99
- basic_memory/mcp/tools/move_note.py +181 -101
- basic_memory/mcp/tools/project_management.py +113 -277
- basic_memory/mcp/tools/read_content.py +91 -74
- basic_memory/mcp/tools/read_note.py +152 -115
- basic_memory/mcp/tools/recent_activity.py +471 -68
- basic_memory/mcp/tools/search.py +105 -92
- basic_memory/mcp/tools/sync_status.py +136 -130
- basic_memory/mcp/tools/utils.py +4 -0
- basic_memory/mcp/tools/view_note.py +44 -33
- basic_memory/mcp/tools/write_note.py +151 -90
- basic_memory/models/knowledge.py +12 -6
- basic_memory/models/project.py +6 -2
- basic_memory/repository/entity_repository.py +89 -82
- basic_memory/repository/relation_repository.py +13 -0
- basic_memory/repository/repository.py +18 -5
- basic_memory/repository/search_repository.py +46 -2
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +39 -11
- basic_memory/schemas/cloud.py +46 -0
- basic_memory/schemas/memory.py +90 -21
- basic_memory/schemas/project_info.py +9 -10
- basic_memory/schemas/sync_report.py +48 -0
- basic_memory/services/context_service.py +25 -11
- basic_memory/services/directory_service.py +124 -3
- basic_memory/services/entity_service.py +100 -48
- basic_memory/services/initialization.py +30 -11
- basic_memory/services/project_service.py +101 -24
- basic_memory/services/search_service.py +16 -8
- basic_memory/sync/sync_service.py +173 -34
- basic_memory/sync/watch_service.py +101 -40
- basic_memory/utils.py +14 -4
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
- basic_memory-0.15.1.dist-info/RECORD +146 -0
- basic_memory/mcp/project_session.py +0 -120
- basic_memory-0.14.4.dist-info/RECORD +0 -133
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -4,11 +4,12 @@ from typing import List, Union, Optional
|
|
|
4
4
|
|
|
5
5
|
from loguru import logger
|
|
6
6
|
|
|
7
|
-
from basic_memory.mcp.async_client import
|
|
7
|
+
from basic_memory.mcp.async_client import get_client
|
|
8
|
+
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
|
8
9
|
from basic_memory.mcp.server import mcp
|
|
9
10
|
from basic_memory.mcp.tools.utils import call_put
|
|
10
|
-
from basic_memory.mcp.project_session import get_active_project
|
|
11
11
|
from basic_memory.schemas import EntityResponse
|
|
12
|
+
from fastmcp import Context
|
|
12
13
|
from basic_memory.schemas.base import Entity
|
|
13
14
|
from basic_memory.utils import parse_tags, validate_project_path
|
|
14
15
|
|
|
@@ -26,14 +27,20 @@ async def write_note(
|
|
|
26
27
|
title: str,
|
|
27
28
|
content: str,
|
|
28
29
|
folder: str,
|
|
29
|
-
tags=None, # Remove type hint completely to avoid schema issues
|
|
30
|
-
entity_type: str = "note",
|
|
31
30
|
project: Optional[str] = None,
|
|
31
|
+
tags=None,
|
|
32
|
+
entity_type: str = "note",
|
|
33
|
+
context: Context | None = None,
|
|
32
34
|
) -> str:
|
|
33
35
|
"""Write a markdown note to the knowledge base.
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
Creates or updates a markdown note with semantic observations and relations.
|
|
38
|
+
|
|
39
|
+
Project Resolution:
|
|
40
|
+
Server resolves projects in this order: Single Project Mode → project parameter → default project.
|
|
41
|
+
If project unknown, use list_memory_projects() or recent_activity() first.
|
|
42
|
+
|
|
43
|
+
The content can include semantic observations and relations using markdown syntax:
|
|
37
44
|
|
|
38
45
|
Observations format:
|
|
39
46
|
`- [category] Observation text #tag1 #tag2 (optional context)`
|
|
@@ -50,108 +57,162 @@ async def write_note(
|
|
|
50
57
|
Examples:
|
|
51
58
|
`- depends_on [[Content Parser]] (Need for semantic extraction)`
|
|
52
59
|
`- implements [[Search Spec]] (Initial implementation)`
|
|
53
|
-
`- This feature extends [[Base Design]]
|
|
60
|
+
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
|
|
54
61
|
|
|
55
62
|
Args:
|
|
56
63
|
title: The title of the note
|
|
57
64
|
content: Markdown content for the note, can include observations and relations
|
|
58
65
|
folder: Folder path relative to project root where the file should be saved.
|
|
59
|
-
Use forward slashes (/) as separators.
|
|
66
|
+
Use forward slashes (/) as separators. Use "/" or "" to write to project root.
|
|
67
|
+
Examples: "notes", "projects/2025", "research/ml", "/" (root)
|
|
68
|
+
project: Project name to write to. Optional - server will resolve using the
|
|
69
|
+
hierarchy above. If unknown, use list_memory_projects() to discover
|
|
70
|
+
available projects.
|
|
60
71
|
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
|
|
61
72
|
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
|
|
62
73
|
entity_type: Type of entity to create. Defaults to "note". Can be "guide", "report", "config", etc.
|
|
63
|
-
|
|
74
|
+
context: Optional FastMCP context for performance caching.
|
|
64
75
|
|
|
65
76
|
Returns:
|
|
66
77
|
A markdown formatted summary of the semantic content, including:
|
|
67
|
-
- Creation/update status
|
|
78
|
+
- Creation/update status with project name
|
|
68
79
|
- File path and checksum
|
|
69
80
|
- Observation counts by category
|
|
70
81
|
- Relation counts (resolved/unresolved)
|
|
71
82
|
- Tags if present
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
- Session tracking metadata for project awareness
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
# Assistant flow when project is unknown
|
|
87
|
+
# 1. list_memory_projects() -> Ask user which project
|
|
88
|
+
# 2. User: "Use my-research"
|
|
89
|
+
# 3. write_note(...) and remember "my-research" for session
|
|
90
|
+
|
|
91
|
+
# Create a simple note
|
|
92
|
+
write_note(
|
|
93
|
+
project="my-research",
|
|
94
|
+
title="Meeting Notes",
|
|
95
|
+
folder="meetings",
|
|
96
|
+
content="# Weekly Standup\\n\\n- [decision] Use SQLite for storage #tech"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Create a note with tags and entity type
|
|
100
|
+
write_note(
|
|
101
|
+
project="work-project",
|
|
102
|
+
title="API Design",
|
|
103
|
+
folder="specs",
|
|
104
|
+
content="# REST API Specification\\n\\n- implements [[Authentication]]",
|
|
105
|
+
tags=["api", "design"],
|
|
106
|
+
entity_type="guide"
|
|
107
|
+
)
|
|
74
108
|
|
|
75
|
-
|
|
76
|
-
|
|
109
|
+
# Update existing note (same title/folder)
|
|
110
|
+
write_note(
|
|
111
|
+
project="my-research",
|
|
112
|
+
title="Meeting Notes",
|
|
113
|
+
folder="meetings",
|
|
114
|
+
content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech"
|
|
115
|
+
)
|
|
77
116
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
117
|
+
Raises:
|
|
118
|
+
HTTPError: If project doesn't exist or is inaccessible
|
|
119
|
+
SecurityError: If folder path attempts path traversal
|
|
120
|
+
"""
|
|
121
|
+
async with get_client() as client:
|
|
122
|
+
logger.info(
|
|
123
|
+
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
|
|
83
124
|
)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
entity = Entity(
|
|
100
|
-
title=title,
|
|
101
|
-
folder=folder,
|
|
102
|
-
entity_type=entity_type,
|
|
103
|
-
content_type="text/markdown",
|
|
104
|
-
content=content,
|
|
105
|
-
entity_metadata=metadata,
|
|
106
|
-
)
|
|
107
|
-
project_url = active_project.project_url
|
|
108
|
-
|
|
109
|
-
# Create or update via knowledge API
|
|
110
|
-
logger.debug(f"Creating entity via API permalink={entity.permalink}")
|
|
111
|
-
url = f"{project_url}/knowledge/entities/{entity.permalink}"
|
|
112
|
-
response = await call_put(client, url, json=entity.model_dump())
|
|
113
|
-
result = EntityResponse.model_validate(response.json())
|
|
114
|
-
|
|
115
|
-
# Format semantic summary based on status code
|
|
116
|
-
action = "Created" if response.status_code == 201 else "Updated"
|
|
117
|
-
summary = [
|
|
118
|
-
f"# {action} note",
|
|
119
|
-
f"file_path: {result.file_path}",
|
|
120
|
-
f"permalink: {result.permalink}",
|
|
121
|
-
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
|
122
|
-
]
|
|
123
|
-
|
|
124
|
-
# Count observations by category
|
|
125
|
-
categories = {}
|
|
126
|
-
if result.observations:
|
|
127
|
-
for obs in result.observations:
|
|
128
|
-
categories[obs.category] = categories.get(obs.category, 0) + 1
|
|
129
|
-
|
|
130
|
-
summary.append("\n## Observations")
|
|
131
|
-
for category, count in sorted(categories.items()):
|
|
132
|
-
summary.append(f"- {category}: {count}")
|
|
133
|
-
|
|
134
|
-
# Count resolved/unresolved relations
|
|
135
|
-
unresolved = 0
|
|
136
|
-
resolved = 0
|
|
137
|
-
if result.relations:
|
|
138
|
-
unresolved = sum(1 for r in result.relations if not r.to_id)
|
|
139
|
-
resolved = len(result.relations) - unresolved
|
|
140
|
-
|
|
141
|
-
summary.append("\n## Relations")
|
|
142
|
-
summary.append(f"- Resolved: {resolved}")
|
|
143
|
-
if unresolved:
|
|
144
|
-
summary.append(f"- Unresolved: {unresolved}")
|
|
145
|
-
summary.append("\nNote: Unresolved relations point to entities that don't exist yet.")
|
|
146
|
-
summary.append(
|
|
147
|
-
"They will be automatically resolved when target entities are created or during sync operations."
|
|
125
|
+
|
|
126
|
+
# Get and validate the project (supports optional project parameter)
|
|
127
|
+
active_project = await get_active_project(client, project, context)
|
|
128
|
+
|
|
129
|
+
# Normalize "/" to empty string for root folder (must happen before validation)
|
|
130
|
+
if folder == "/":
|
|
131
|
+
folder = ""
|
|
132
|
+
|
|
133
|
+
# Validate folder path to prevent path traversal attacks
|
|
134
|
+
project_path = active_project.home
|
|
135
|
+
if folder and not validate_project_path(folder, project_path):
|
|
136
|
+
logger.warning(
|
|
137
|
+
"Attempted path traversal attack blocked",
|
|
138
|
+
folder=folder,
|
|
139
|
+
project=active_project.name,
|
|
148
140
|
)
|
|
141
|
+
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
|
|
149
142
|
|
|
150
|
-
|
|
151
|
-
|
|
143
|
+
# Check migration status and wait briefly if needed
|
|
144
|
+
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
|
152
145
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
146
|
+
migration_status = await wait_for_migration_or_return_status(
|
|
147
|
+
timeout=5.0, project_name=active_project.name
|
|
148
|
+
)
|
|
149
|
+
if migration_status: # pragma: no cover
|
|
150
|
+
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
|
|
151
|
+
|
|
152
|
+
# Process tags using the helper function
|
|
153
|
+
tag_list = parse_tags(tags)
|
|
154
|
+
# Create the entity request
|
|
155
|
+
metadata = {"tags": tag_list} if tag_list else None
|
|
156
|
+
entity = Entity(
|
|
157
|
+
title=title,
|
|
158
|
+
folder=folder,
|
|
159
|
+
entity_type=entity_type,
|
|
160
|
+
content_type="text/markdown",
|
|
161
|
+
content=content,
|
|
162
|
+
entity_metadata=metadata,
|
|
163
|
+
)
|
|
164
|
+
project_url = active_project.permalink
|
|
165
|
+
|
|
166
|
+
# Create or update via knowledge API
|
|
167
|
+
logger.debug(f"Creating entity via API permalink={entity.permalink}")
|
|
168
|
+
url = f"{project_url}/knowledge/entities/{entity.permalink}"
|
|
169
|
+
response = await call_put(client, url, json=entity.model_dump())
|
|
170
|
+
result = EntityResponse.model_validate(response.json())
|
|
171
|
+
|
|
172
|
+
# Format semantic summary based on status code
|
|
173
|
+
action = "Created" if response.status_code == 201 else "Updated"
|
|
174
|
+
summary = [
|
|
175
|
+
f"# {action} note",
|
|
176
|
+
f"project: {active_project.name}",
|
|
177
|
+
f"file_path: {result.file_path}",
|
|
178
|
+
f"permalink: {result.permalink}",
|
|
179
|
+
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
# Count observations by category
|
|
183
|
+
categories = {}
|
|
184
|
+
if result.observations:
|
|
185
|
+
for obs in result.observations:
|
|
186
|
+
categories[obs.category] = categories.get(obs.category, 0) + 1
|
|
187
|
+
|
|
188
|
+
summary.append("\n## Observations")
|
|
189
|
+
for category, count in sorted(categories.items()):
|
|
190
|
+
summary.append(f"- {category}: {count}")
|
|
191
|
+
|
|
192
|
+
# Count resolved/unresolved relations
|
|
193
|
+
unresolved = 0
|
|
194
|
+
resolved = 0
|
|
195
|
+
if result.relations:
|
|
196
|
+
unresolved = sum(1 for r in result.relations if not r.to_id)
|
|
197
|
+
resolved = len(result.relations) - unresolved
|
|
198
|
+
|
|
199
|
+
summary.append("\n## Relations")
|
|
200
|
+
summary.append(f"- Resolved: {resolved}")
|
|
201
|
+
if unresolved:
|
|
202
|
+
summary.append(f"- Unresolved: {unresolved}")
|
|
203
|
+
summary.append(
|
|
204
|
+
"\nNote: Unresolved relations point to entities that don't exist yet."
|
|
205
|
+
)
|
|
206
|
+
summary.append(
|
|
207
|
+
"They will be automatically resolved when target entities are created or during sync operations."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if tag_list:
|
|
211
|
+
summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
|
|
212
|
+
|
|
213
|
+
# Log the response with structured data
|
|
214
|
+
logger.info(
|
|
215
|
+
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved} status_code={response.status_code}"
|
|
216
|
+
)
|
|
217
|
+
result = "\n".join(summary)
|
|
218
|
+
return add_project_metadata(result, active_project.name)
|
basic_memory/models/knowledge.py
CHANGED
|
@@ -74,8 +74,14 @@ class Entity(Base):
|
|
|
74
74
|
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
75
75
|
|
|
76
76
|
# Metadata and tracking
|
|
77
|
-
created_at: Mapped[datetime] = mapped_column(
|
|
78
|
-
|
|
77
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
78
|
+
DateTime(timezone=True), default=lambda: datetime.now().astimezone()
|
|
79
|
+
)
|
|
80
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
81
|
+
DateTime(timezone=True),
|
|
82
|
+
default=lambda: datetime.now().astimezone(),
|
|
83
|
+
onupdate=lambda: datetime.now().astimezone(),
|
|
84
|
+
)
|
|
79
85
|
|
|
80
86
|
# Relationships
|
|
81
87
|
project = relationship("Project", back_populates="entities")
|
|
@@ -104,15 +110,15 @@ class Entity(Base):
|
|
|
104
110
|
def is_markdown(self):
|
|
105
111
|
"""Check if the entity is a markdown file."""
|
|
106
112
|
return self.content_type == "text/markdown"
|
|
107
|
-
|
|
113
|
+
|
|
108
114
|
def __getattribute__(self, name):
|
|
109
115
|
"""Override attribute access to ensure datetime fields are timezone-aware."""
|
|
110
116
|
value = super().__getattribute__(name)
|
|
111
|
-
|
|
117
|
+
|
|
112
118
|
# Ensure datetime fields are timezone-aware
|
|
113
|
-
if name in (
|
|
119
|
+
if name in ("created_at", "updated_at") and isinstance(value, datetime):
|
|
114
120
|
return ensure_timezone_aware(value)
|
|
115
|
-
|
|
121
|
+
|
|
116
122
|
return value
|
|
117
123
|
|
|
118
124
|
def __repr__(self) -> str:
|
basic_memory/models/project.py
CHANGED
|
@@ -52,9 +52,13 @@ class Project(Base):
|
|
|
52
52
|
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
|
|
53
53
|
|
|
54
54
|
# Timestamps
|
|
55
|
-
created_at: Mapped[datetime] = mapped_column(
|
|
55
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
56
|
+
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
|
57
|
+
)
|
|
56
58
|
updated_at: Mapped[datetime] = mapped_column(
|
|
57
|
-
DateTime(timezone=True),
|
|
59
|
+
DateTime(timezone=True),
|
|
60
|
+
default=lambda: datetime.now(UTC),
|
|
61
|
+
onupdate=lambda: datetime.now(UTC),
|
|
58
62
|
)
|
|
59
63
|
|
|
60
64
|
# Define relationships to entities, observations, and relations
|
|
@@ -101,11 +101,10 @@ class EntityRepository(Repository[Entity]):
|
|
|
101
101
|
return list(result.scalars().all())
|
|
102
102
|
|
|
103
103
|
async def upsert_entity(self, entity: Entity) -> Entity:
|
|
104
|
-
"""Insert or update entity using
|
|
104
|
+
"""Insert or update entity using simple try/catch with database-level conflict resolution.
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
insertion, then handles conflicts intelligently.
|
|
106
|
+
Handles file_path race conditions by checking for existing entity on IntegrityError.
|
|
107
|
+
For permalink conflicts, generates a unique permalink with numeric suffix.
|
|
109
108
|
|
|
110
109
|
Args:
|
|
111
110
|
entity: The entity to insert or update
|
|
@@ -113,50 +112,12 @@ class EntityRepository(Repository[Entity]):
|
|
|
113
112
|
Returns:
|
|
114
113
|
The inserted or updated entity
|
|
115
114
|
"""
|
|
116
|
-
|
|
117
115
|
async with db.scoped_session(self.session_maker) as session:
|
|
118
116
|
# Set project_id if applicable and not already set
|
|
119
117
|
self._set_project_id_if_needed(entity)
|
|
120
118
|
|
|
121
|
-
#
|
|
122
|
-
existing_by_path = await session.execute(
|
|
123
|
-
select(Entity).where(
|
|
124
|
-
Entity.file_path == entity.file_path, Entity.project_id == entity.project_id
|
|
125
|
-
)
|
|
126
|
-
)
|
|
127
|
-
existing_path_entity = existing_by_path.scalar_one_or_none()
|
|
128
|
-
|
|
129
|
-
if existing_path_entity:
|
|
130
|
-
# Update existing entity with same file path
|
|
131
|
-
for key, value in {
|
|
132
|
-
"title": entity.title,
|
|
133
|
-
"entity_type": entity.entity_type,
|
|
134
|
-
"entity_metadata": entity.entity_metadata,
|
|
135
|
-
"content_type": entity.content_type,
|
|
136
|
-
"permalink": entity.permalink,
|
|
137
|
-
"checksum": entity.checksum,
|
|
138
|
-
"updated_at": entity.updated_at,
|
|
139
|
-
}.items():
|
|
140
|
-
setattr(existing_path_entity, key, value)
|
|
141
|
-
|
|
142
|
-
await session.flush()
|
|
143
|
-
# Return with relationships loaded
|
|
144
|
-
query = (
|
|
145
|
-
self.select()
|
|
146
|
-
.where(Entity.file_path == entity.file_path)
|
|
147
|
-
.options(*self.get_load_options())
|
|
148
|
-
)
|
|
149
|
-
result = await session.execute(query)
|
|
150
|
-
found = result.scalar_one_or_none()
|
|
151
|
-
if not found: # pragma: no cover
|
|
152
|
-
raise RuntimeError(
|
|
153
|
-
f"Failed to retrieve entity after update: {entity.file_path}"
|
|
154
|
-
)
|
|
155
|
-
return found
|
|
156
|
-
|
|
157
|
-
# No existing entity with same file_path, try insert
|
|
119
|
+
# Try simple insert first
|
|
158
120
|
try:
|
|
159
|
-
# Simple insert for new entity
|
|
160
121
|
session.add(entity)
|
|
161
122
|
await session.flush()
|
|
162
123
|
|
|
@@ -175,20 +136,20 @@ class EntityRepository(Repository[Entity]):
|
|
|
175
136
|
return found
|
|
176
137
|
|
|
177
138
|
except IntegrityError:
|
|
178
|
-
# Could be either file_path or permalink conflict
|
|
179
139
|
await session.rollback()
|
|
180
140
|
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
select(Entity)
|
|
141
|
+
# Re-query after rollback to get a fresh, attached entity
|
|
142
|
+
existing_result = await session.execute(
|
|
143
|
+
select(Entity)
|
|
144
|
+
.where(
|
|
184
145
|
Entity.file_path == entity.file_path, Entity.project_id == entity.project_id
|
|
185
146
|
)
|
|
147
|
+
.options(*self.get_load_options())
|
|
186
148
|
)
|
|
187
|
-
|
|
149
|
+
existing_entity = existing_result.scalar_one_or_none()
|
|
188
150
|
|
|
189
|
-
if
|
|
190
|
-
#
|
|
191
|
-
# Update the existing entity instead
|
|
151
|
+
if existing_entity:
|
|
152
|
+
# File path conflict - update the existing entity
|
|
192
153
|
for key, value in {
|
|
193
154
|
"title": entity.title,
|
|
194
155
|
"entity_type": entity.entity_type,
|
|
@@ -198,25 +159,82 @@ class EntityRepository(Repository[Entity]):
|
|
|
198
159
|
"checksum": entity.checksum,
|
|
199
160
|
"updated_at": entity.updated_at,
|
|
200
161
|
}.items():
|
|
201
|
-
setattr(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
.
|
|
208
|
-
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if not found: # pragma: no cover
|
|
213
|
-
raise RuntimeError(
|
|
214
|
-
f"Failed to retrieve entity after race condition update: {entity.file_path}"
|
|
215
|
-
)
|
|
216
|
-
return found
|
|
162
|
+
setattr(existing_entity, key, value)
|
|
163
|
+
|
|
164
|
+
# Clear and re-add observations
|
|
165
|
+
existing_entity.observations.clear()
|
|
166
|
+
for obs in entity.observations:
|
|
167
|
+
obs.entity_id = existing_entity.id
|
|
168
|
+
existing_entity.observations.append(obs)
|
|
169
|
+
|
|
170
|
+
await session.commit()
|
|
171
|
+
return existing_entity
|
|
172
|
+
|
|
217
173
|
else:
|
|
218
|
-
#
|
|
219
|
-
|
|
174
|
+
# No file_path conflict - must be permalink conflict
|
|
175
|
+
# Generate unique permalink and retry
|
|
176
|
+
entity = await self._handle_permalink_conflict(entity, session)
|
|
177
|
+
return entity
|
|
178
|
+
|
|
179
|
+
async def get_distinct_directories(self) -> List[str]:
|
|
180
|
+
"""Extract unique directory paths from file_path column.
|
|
181
|
+
|
|
182
|
+
Optimized method for getting directory structure without loading full entities
|
|
183
|
+
or relationships. Returns a sorted list of unique directory paths.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
List of unique directory paths (e.g., ["notes", "notes/meetings", "specs"])
|
|
187
|
+
"""
|
|
188
|
+
# Query only file_path column, no entity objects or relationships
|
|
189
|
+
query = select(Entity.file_path).distinct()
|
|
190
|
+
query = self._add_project_filter(query)
|
|
191
|
+
|
|
192
|
+
# Execute with use_query_options=False to skip eager loading
|
|
193
|
+
result = await self.execute_query(query, use_query_options=False)
|
|
194
|
+
file_paths = [row for row in result.scalars().all()]
|
|
195
|
+
|
|
196
|
+
# Parse file paths to extract unique directories
|
|
197
|
+
directories = set()
|
|
198
|
+
for file_path in file_paths:
|
|
199
|
+
parts = [p for p in file_path.split("/") if p]
|
|
200
|
+
# Add all parent directories (exclude filename which is the last part)
|
|
201
|
+
for i in range(len(parts) - 1):
|
|
202
|
+
dir_path = "/".join(parts[: i + 1])
|
|
203
|
+
directories.add(dir_path)
|
|
204
|
+
|
|
205
|
+
return sorted(directories)
|
|
206
|
+
|
|
207
|
+
async def find_by_directory_prefix(self, directory_prefix: str) -> Sequence[Entity]:
|
|
208
|
+
"""Find entities whose file_path starts with the given directory prefix.
|
|
209
|
+
|
|
210
|
+
Optimized method for listing directory contents without loading all entities.
|
|
211
|
+
Uses SQL LIKE pattern matching to filter entities by directory path.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
directory_prefix: Directory path prefix (e.g., "docs", "docs/guides")
|
|
215
|
+
Empty string returns all entities (root directory)
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Sequence of entities in the specified directory and subdirectories
|
|
219
|
+
"""
|
|
220
|
+
# Build SQL LIKE pattern
|
|
221
|
+
if directory_prefix == "" or directory_prefix == "/":
|
|
222
|
+
# Root directory - return all entities
|
|
223
|
+
return await self.find_all()
|
|
224
|
+
|
|
225
|
+
# Remove leading/trailing slashes for consistency
|
|
226
|
+
directory_prefix = directory_prefix.strip("/")
|
|
227
|
+
|
|
228
|
+
# Query entities with file_path starting with prefix
|
|
229
|
+
# Pattern matches "prefix/" to ensure we get files IN the directory,
|
|
230
|
+
# not just files whose names start with the prefix
|
|
231
|
+
pattern = f"{directory_prefix}/%"
|
|
232
|
+
|
|
233
|
+
query = self.select().where(Entity.file_path.like(pattern))
|
|
234
|
+
|
|
235
|
+
# Skip eager loading - we only need basic entity fields for directory trees
|
|
236
|
+
result = await self.execute_query(query, use_query_options=False)
|
|
237
|
+
return list(result.scalars().all())
|
|
220
238
|
|
|
221
239
|
async def _handle_permalink_conflict(self, entity: Entity, session: AsyncSession) -> Entity:
|
|
222
240
|
"""Handle permalink conflicts by generating a unique permalink."""
|
|
@@ -237,18 +255,7 @@ class EntityRepository(Repository[Entity]):
|
|
|
237
255
|
break
|
|
238
256
|
suffix += 1
|
|
239
257
|
|
|
240
|
-
# Insert with unique permalink
|
|
258
|
+
# Insert with unique permalink
|
|
241
259
|
session.add(entity)
|
|
242
260
|
await session.flush()
|
|
243
|
-
|
|
244
|
-
# Return the inserted entity with relationships loaded
|
|
245
|
-
query = (
|
|
246
|
-
self.select()
|
|
247
|
-
.where(Entity.file_path == entity.file_path)
|
|
248
|
-
.options(*self.get_load_options())
|
|
249
|
-
)
|
|
250
|
-
result = await session.execute(query)
|
|
251
|
-
found = result.scalar_one_or_none()
|
|
252
|
-
if not found: # pragma: no cover
|
|
253
|
-
raise RuntimeError(f"Failed to retrieve entity after insert: {entity.file_path}")
|
|
254
|
-
return found
|
|
261
|
+
return entity
|
|
@@ -73,5 +73,18 @@ class RelationRepository(Repository[Relation]):
|
|
|
73
73
|
result = await self.execute_query(query)
|
|
74
74
|
return result.scalars().all()
|
|
75
75
|
|
|
76
|
+
async def find_unresolved_relations_for_entity(self, entity_id: int) -> Sequence[Relation]:
|
|
77
|
+
"""Find unresolved relations for a specific entity.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
entity_id: The entity whose unresolved outgoing relations to find.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List of unresolved relations where this entity is the source.
|
|
84
|
+
"""
|
|
85
|
+
query = select(Relation).filter(Relation.from_id == entity_id, Relation.to_id.is_(None))
|
|
86
|
+
result = await self.execute_query(query)
|
|
87
|
+
return result.scalars().all()
|
|
88
|
+
|
|
76
89
|
def get_load_options(self) -> List[LoaderOption]:
|
|
77
90
|
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
|
|
@@ -10,13 +10,13 @@ from sqlalchemy import (
|
|
|
10
10
|
Executable,
|
|
11
11
|
inspect,
|
|
12
12
|
Result,
|
|
13
|
-
Column,
|
|
14
13
|
and_,
|
|
15
14
|
delete,
|
|
16
15
|
)
|
|
17
16
|
from sqlalchemy.exc import NoResultFound
|
|
18
17
|
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
|
19
18
|
from sqlalchemy.orm.interfaces import LoaderOption
|
|
19
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
20
20
|
|
|
21
21
|
from basic_memory import db
|
|
22
22
|
from basic_memory.models import Base
|
|
@@ -38,7 +38,7 @@ class Repository[T: Base]:
|
|
|
38
38
|
if Model:
|
|
39
39
|
self.Model = Model
|
|
40
40
|
self.mapper = inspect(self.Model).mapper
|
|
41
|
-
self.primary_key:
|
|
41
|
+
self.primary_key: ColumnElement[Any] = self.mapper.primary_key[0]
|
|
42
42
|
self.valid_columns = [column.key for column in self.mapper.columns]
|
|
43
43
|
# Check if this model has a project_id column
|
|
44
44
|
self.has_project_id = "project_id" in self.valid_columns
|
|
@@ -152,12 +152,25 @@ class Repository[T: Base]:
|
|
|
152
152
|
# Add project filter if applicable
|
|
153
153
|
return self._add_project_filter(query)
|
|
154
154
|
|
|
155
|
-
async def find_all(
|
|
156
|
-
|
|
155
|
+
async def find_all(
|
|
156
|
+
self, skip: int = 0, limit: Optional[int] = None, use_load_options: bool = True
|
|
157
|
+
) -> Sequence[T]:
|
|
158
|
+
"""Fetch records from the database with pagination.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
skip: Number of records to skip
|
|
162
|
+
limit: Maximum number of records to return
|
|
163
|
+
use_load_options: Whether to apply eager loading options (default: True)
|
|
164
|
+
"""
|
|
157
165
|
logger.debug(f"Finding all {self.Model.__name__} (skip={skip}, limit={limit})")
|
|
158
166
|
|
|
159
167
|
async with db.scoped_session(self.session_maker) as session:
|
|
160
|
-
query = select(self.Model).offset(skip)
|
|
168
|
+
query = select(self.Model).offset(skip)
|
|
169
|
+
|
|
170
|
+
# Only apply load options if requested
|
|
171
|
+
if use_load_options:
|
|
172
|
+
query = query.options(*self.get_load_options())
|
|
173
|
+
|
|
161
174
|
# Add project filter if applicable
|
|
162
175
|
query = self._add_project_filter(query)
|
|
163
176
|
|