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.

Files changed (84) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/directory_router.py +23 -2
  5. basic_memory/api/routers/knowledge_router.py +25 -8
  6. basic_memory/api/routers/project_router.py +100 -4
  7. basic_memory/cli/app.py +9 -28
  8. basic_memory/cli/auth.py +277 -0
  9. basic_memory/cli/commands/cloud/__init__.py +5 -0
  10. basic_memory/cli/commands/cloud/api_client.py +112 -0
  11. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  12. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  13. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  14. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  15. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  16. basic_memory/cli/commands/command_utils.py +43 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +77 -60
  19. basic_memory/cli/commands/project.py +154 -152
  20. basic_memory/cli/commands/status.py +25 -22
  21. basic_memory/cli/commands/sync.py +45 -228
  22. basic_memory/cli/commands/tool.py +87 -16
  23. basic_memory/cli/main.py +1 -0
  24. basic_memory/config.py +131 -21
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +27 -8
  27. basic_memory/file_utils.py +37 -13
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/markdown/plugins.py +9 -7
  30. basic_memory/mcp/async_client.py +124 -14
  31. basic_memory/mcp/project_context.py +141 -0
  32. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  33. basic_memory/mcp/prompts/continue_conversation.py +17 -16
  34. basic_memory/mcp/prompts/recent_activity.py +116 -32
  35. basic_memory/mcp/prompts/search.py +13 -12
  36. basic_memory/mcp/prompts/utils.py +11 -4
  37. basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
  38. basic_memory/mcp/resources/project_info.py +27 -11
  39. basic_memory/mcp/server.py +0 -37
  40. basic_memory/mcp/tools/__init__.py +5 -6
  41. basic_memory/mcp/tools/build_context.py +67 -56
  42. basic_memory/mcp/tools/canvas.py +38 -26
  43. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  44. basic_memory/mcp/tools/delete_note.py +81 -47
  45. basic_memory/mcp/tools/edit_note.py +155 -138
  46. basic_memory/mcp/tools/list_directory.py +112 -99
  47. basic_memory/mcp/tools/move_note.py +181 -101
  48. basic_memory/mcp/tools/project_management.py +113 -277
  49. basic_memory/mcp/tools/read_content.py +91 -74
  50. basic_memory/mcp/tools/read_note.py +152 -115
  51. basic_memory/mcp/tools/recent_activity.py +471 -68
  52. basic_memory/mcp/tools/search.py +105 -92
  53. basic_memory/mcp/tools/sync_status.py +136 -130
  54. basic_memory/mcp/tools/utils.py +4 -0
  55. basic_memory/mcp/tools/view_note.py +44 -33
  56. basic_memory/mcp/tools/write_note.py +151 -90
  57. basic_memory/models/knowledge.py +12 -6
  58. basic_memory/models/project.py +6 -2
  59. basic_memory/repository/entity_repository.py +89 -82
  60. basic_memory/repository/relation_repository.py +13 -0
  61. basic_memory/repository/repository.py +18 -5
  62. basic_memory/repository/search_repository.py +46 -2
  63. basic_memory/schemas/__init__.py +6 -0
  64. basic_memory/schemas/base.py +39 -11
  65. basic_memory/schemas/cloud.py +46 -0
  66. basic_memory/schemas/memory.py +90 -21
  67. basic_memory/schemas/project_info.py +9 -10
  68. basic_memory/schemas/sync_report.py +48 -0
  69. basic_memory/services/context_service.py +25 -11
  70. basic_memory/services/directory_service.py +124 -3
  71. basic_memory/services/entity_service.py +100 -48
  72. basic_memory/services/initialization.py +30 -11
  73. basic_memory/services/project_service.py +101 -24
  74. basic_memory/services/search_service.py +16 -8
  75. basic_memory/sync/sync_service.py +173 -34
  76. basic_memory/sync/watch_service.py +101 -40
  77. basic_memory/utils.py +14 -4
  78. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
  79. basic_memory-0.15.1.dist-info/RECORD +146 -0
  80. basic_memory/mcp/project_session.py +0 -120
  81. basic_memory-0.14.4.dist-info/RECORD +0 -133
  82. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  83. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  84. {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 client
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
- The content can include semantic observations and relations using markdown syntax.
36
- Relations can be specified either explicitly or through inline wiki-style links:
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]] andst uses [[Core Utils]]`
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. Examples: "notes", "projects/2025", "research/ml"
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
- project: Optional project name to write to. If not provided, uses current active project.
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
- logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")
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
- # Get the active project first to check project-specific sync status
76
- active_project = get_active_project(project)
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
- # Validate folder path to prevent path traversal attacks
79
- project_path = active_project.home
80
- if folder and not validate_project_path(folder, project_path):
81
- logger.warning(
82
- "Attempted path traversal attack blocked", folder=folder, project=active_project.name
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
- return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
85
-
86
- # Check migration status and wait briefly if needed
87
- from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
88
-
89
- migration_status = await wait_for_migration_or_return_status(
90
- timeout=5.0, project_name=active_project.name
91
- )
92
- if migration_status: # pragma: no cover
93
- return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
94
-
95
- # Process tags using the helper function
96
- tag_list = parse_tags(tags)
97
- # Create the entity request
98
- metadata = {"tags": tag_list} if tag_list else None
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
- if tag_list:
151
- summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
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
- # Log the response with structured data
154
- logger.info(
155
- f"MCP tool response: tool=write_note 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}"
156
- )
157
- return "\n".join(summary)
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)
@@ -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(DateTime(timezone=True), default=lambda: datetime.now().astimezone())
78
- updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone(), onupdate=lambda: datetime.now().astimezone())
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 ('created_at', 'updated_at') and isinstance(value, datetime):
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:
@@ -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(DateTime(timezone=True), default=lambda: datetime.now(UTC))
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), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
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 a hybrid approach.
104
+ """Insert or update entity using simple try/catch with database-level conflict resolution.
105
105
 
106
- This method provides a cleaner alternative to the try/catch approach
107
- for handling permalink and file_path conflicts. It first tries direct
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
- # Check for existing entity with same file_path first
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
- # Check if it's a file_path conflict (race condition)
182
- existing_by_path_check = await session.execute(
183
- select(Entity).where(
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
- race_condition_entity = existing_by_path_check.scalar_one_or_none()
149
+ existing_entity = existing_result.scalar_one_or_none()
188
150
 
189
- if race_condition_entity:
190
- # Race condition: file_path conflict detected after our initial check
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(race_condition_entity, key, value)
202
-
203
- await session.flush()
204
- # Return the updated entity with relationships loaded
205
- query = (
206
- self.select()
207
- .where(Entity.file_path == entity.file_path)
208
- .options(*self.get_load_options())
209
- )
210
- result = await session.execute(query)
211
- found = result.scalar_one_or_none()
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
- # Must be permalink conflict - generate unique permalink
219
- return await self._handle_permalink_conflict(entity, session)
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 (no conflict possible now)
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: Column[Any] = self.mapper.primary_key[0]
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(self, skip: int = 0, limit: Optional[int] = None) -> Sequence[T]:
156
- """Fetch records from the database with pagination."""
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).options(*self.get_load_options())
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