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

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

Potentially problematic release.


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

Files changed (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,213 @@
1
+ """Schema for project info response."""
2
+
3
+ import os
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional, Any
7
+
8
+ from pydantic import Field, BaseModel
9
+
10
+ from basic_memory.utils import generate_permalink
11
+
12
+
13
+ class ProjectStatistics(BaseModel):
14
+ """Statistics about the current project."""
15
+
16
+ # Basic counts
17
+ total_entities: int = Field(description="Total number of entities in the knowledge base")
18
+ total_observations: int = Field(description="Total number of observations across all entities")
19
+ total_relations: int = Field(description="Total number of relations between entities")
20
+ total_unresolved_relations: int = Field(
21
+ description="Number of relations with unresolved targets"
22
+ )
23
+
24
+ # Entity counts by type
25
+ entity_types: Dict[str, int] = Field(
26
+ description="Count of entities by type (e.g., note, conversation)"
27
+ )
28
+
29
+ # Observation counts by category
30
+ observation_categories: Dict[str, int] = Field(
31
+ description="Count of observations by category (e.g., tech, decision)"
32
+ )
33
+
34
+ # Relation counts by type
35
+ relation_types: Dict[str, int] = Field(
36
+ description="Count of relations by type (e.g., implements, relates_to)"
37
+ )
38
+
39
+ # Graph metrics
40
+ most_connected_entities: List[Dict[str, Any]] = Field(
41
+ description="Entities with the most relations, including their titles and permalinks"
42
+ )
43
+ isolated_entities: int = Field(description="Number of entities with no relations")
44
+
45
+
46
+ class ActivityMetrics(BaseModel):
47
+ """Activity metrics for the current project."""
48
+
49
+ # Recent activity
50
+ recently_created: List[Dict[str, Any]] = Field(
51
+ description="Recently created entities with timestamps"
52
+ )
53
+ recently_updated: List[Dict[str, Any]] = Field(
54
+ description="Recently updated entities with timestamps"
55
+ )
56
+
57
+ # Growth over time (last 6 months)
58
+ monthly_growth: Dict[str, Dict[str, int]] = Field(
59
+ description="Monthly growth statistics for entities, observations, and relations"
60
+ )
61
+
62
+
63
+ class SystemStatus(BaseModel):
64
+ """System status information."""
65
+
66
+ # Version information
67
+ version: str = Field(description="Basic Memory version")
68
+
69
+ # Database status
70
+ database_path: str = Field(description="Path to the SQLite database")
71
+ database_size: str = Field(description="Size of the database in human-readable format")
72
+
73
+ # Watch service status
74
+ watch_status: Optional[Dict[str, Any]] = Field(
75
+ default=None, description="Watch service status information (if running)"
76
+ )
77
+
78
+ # System information
79
+ timestamp: datetime = Field(description="Timestamp when the information was collected")
80
+
81
+
82
+ class ProjectInfoResponse(BaseModel):
83
+ """Response for the project_info tool."""
84
+
85
+ # Project configuration
86
+ project_name: str = Field(description="Name of the current project")
87
+ project_path: str = Field(description="Path to the current project files")
88
+ available_projects: Dict[str, Dict[str, Any]] = Field(
89
+ description="Map of configured project names to detailed project information"
90
+ )
91
+ default_project: str = Field(description="Name of the default project")
92
+
93
+ # Statistics
94
+ statistics: ProjectStatistics = Field(description="Statistics about the knowledge base")
95
+
96
+ # Activity metrics
97
+ activity: ActivityMetrics = Field(description="Activity and growth metrics")
98
+
99
+ # System status
100
+ system: SystemStatus = Field(description="System and service status information")
101
+
102
+
103
+ class ProjectInfoRequest(BaseModel):
104
+ """Request model for switching projects."""
105
+
106
+ name: str = Field(..., description="Name of the project to switch to")
107
+ path: str = Field(..., description="Path to the project directory")
108
+ set_default: bool = Field(..., description="Set the project as the default")
109
+
110
+
111
+ class WatchEvent(BaseModel):
112
+ timestamp: datetime
113
+ path: str
114
+ action: str # new, delete, etc
115
+ status: str # success, error
116
+ checksum: Optional[str]
117
+ error: Optional[str] = None
118
+
119
+
120
+ class WatchServiceState(BaseModel):
121
+ # Service status
122
+ running: bool = False
123
+ start_time: datetime = datetime.now() # Use directly with Pydantic model
124
+ pid: int = os.getpid() # Use directly with Pydantic model
125
+
126
+ # Stats
127
+ error_count: int = 0
128
+ last_error: Optional[datetime] = None
129
+ last_scan: Optional[datetime] = None
130
+
131
+ # File counts
132
+ synced_files: int = 0
133
+
134
+ # Recent activity
135
+ recent_events: List[WatchEvent] = [] # Use directly with Pydantic model
136
+
137
+ def add_event(
138
+ self,
139
+ path: str,
140
+ action: str,
141
+ status: str,
142
+ checksum: Optional[str] = None,
143
+ error: Optional[str] = None,
144
+ ) -> WatchEvent: # pragma: no cover
145
+ event = WatchEvent(
146
+ timestamp=datetime.now(),
147
+ path=path,
148
+ action=action,
149
+ status=status,
150
+ checksum=checksum,
151
+ error=error,
152
+ )
153
+ self.recent_events.insert(0, event)
154
+ self.recent_events = self.recent_events[:100] # Keep last 100
155
+ return event
156
+
157
+ def record_error(self, error: str): # pragma: no cover
158
+ self.error_count += 1
159
+ self.add_event(path="", action="sync", status="error", error=error)
160
+ self.last_error = datetime.now()
161
+
162
+
163
+ class ProjectWatchStatus(BaseModel):
164
+ """Project with its watch status."""
165
+
166
+ name: str = Field(..., description="Name of the project")
167
+ path: str = Field(..., description="Path to the project")
168
+ watch_status: Optional[WatchServiceState] = Field(
169
+ None, description="Watch status information for the project"
170
+ )
171
+
172
+
173
+ class ProjectItem(BaseModel):
174
+ """Simple representation of a project."""
175
+
176
+ id: int
177
+ external_id: str # UUID string for API references (required after migration)
178
+ name: str
179
+ path: str
180
+ is_default: bool = False
181
+
182
+ @property
183
+ def permalink(self) -> str: # pragma: no cover
184
+ return generate_permalink(self.name)
185
+
186
+ @property
187
+ def home(self) -> Path: # pragma: no cover
188
+ return Path(self.path).expanduser()
189
+
190
+ @property
191
+ def project_url(self) -> str: # pragma: no cover
192
+ return f"/{generate_permalink(self.name)}"
193
+
194
+
195
+ class ProjectList(BaseModel):
196
+ """Response model for listing projects."""
197
+
198
+ projects: List[ProjectItem]
199
+ default_project: str
200
+
201
+
202
+ class ProjectStatusResponse(BaseModel):
203
+ """Response model for switching projects."""
204
+
205
+ message: str = Field(..., description="Status message about the project switch")
206
+ status: str = Field(..., description="Status of the switch (success or error)")
207
+ default: bool = Field(..., description="True if the project was set as the default")
208
+ old_project: Optional[ProjectItem] = Field(
209
+ None, description="Information about the project being switched from"
210
+ )
211
+ new_project: Optional[ProjectItem] = Field(
212
+ None, description="Information about the project being switched to"
213
+ )
@@ -0,0 +1,90 @@
1
+ """Request and response schemas for prompt-related operations."""
2
+
3
+ from typing import Optional, List, Any, Dict
4
+ from pydantic import BaseModel, Field
5
+
6
+ from basic_memory.schemas.base import TimeFrame
7
+ from basic_memory.schemas.memory import EntitySummary, ObservationSummary, RelationSummary
8
+
9
+
10
+ class PromptContextItem(BaseModel):
11
+ """Container for primary and related results to render in a prompt."""
12
+
13
+ primary_results: List[EntitySummary]
14
+ related_results: List[EntitySummary | ObservationSummary | RelationSummary]
15
+
16
+
17
+ class ContinueConversationRequest(BaseModel):
18
+ """Request for generating a continue conversation prompt.
19
+
20
+ Used to provide context for continuing a conversation on a specific topic
21
+ or with recent activity from a given timeframe.
22
+ """
23
+
24
+ topic: Optional[str] = Field(None, description="Topic or keyword to search for")
25
+ timeframe: Optional[TimeFrame] = Field(
26
+ None, description="How far back to look for activity (e.g. '1d', '1 week')"
27
+ )
28
+ # Limit depth to max 2 for performance reasons - higher values cause significant slowdown
29
+ search_items_limit: int = Field(
30
+ 5,
31
+ description="Maximum number of search results to include in context (max 10)",
32
+ ge=1,
33
+ le=10,
34
+ )
35
+ depth: int = Field(
36
+ 1,
37
+ description="How many relationship 'hops' to follow when building context (max 5)",
38
+ ge=1,
39
+ le=5,
40
+ )
41
+ # Limit related items to prevent overloading the context
42
+ related_items_limit: int = Field(
43
+ 5, description="Maximum number of related items to include in context (max 10)", ge=1, le=10
44
+ )
45
+
46
+
47
+ class SearchPromptRequest(BaseModel):
48
+ """Request for generating a search results prompt.
49
+
50
+ Used to format search results into a prompt with context and suggestions.
51
+ """
52
+
53
+ query: str = Field(..., description="The search query text")
54
+ timeframe: Optional[TimeFrame] = Field(
55
+ None, description="Optional timeframe to limit results (e.g. '1d', '1 week')"
56
+ )
57
+
58
+
59
+ class PromptMetadata(BaseModel):
60
+ """Metadata about a prompt response.
61
+
62
+ Contains statistical information about the prompt generation process
63
+ and results, useful for debugging and UI display.
64
+ """
65
+
66
+ query: Optional[str] = Field(None, description="The original query or topic")
67
+ timeframe: Optional[str] = Field(None, description="The timeframe used for filtering")
68
+ search_count: int = Field(0, description="Number of search results found")
69
+ context_count: int = Field(0, description="Number of context items retrieved")
70
+ observation_count: int = Field(0, description="Total number of observations included")
71
+ relation_count: int = Field(0, description="Total number of relations included")
72
+ total_items: int = Field(0, description="Total number of all items included in the prompt")
73
+ search_limit: int = Field(0, description="Maximum search results requested")
74
+ context_depth: int = Field(0, description="Context depth used")
75
+ related_limit: int = Field(0, description="Maximum related items requested")
76
+ generated_at: str = Field(..., description="ISO timestamp when this prompt was generated")
77
+
78
+
79
+ class PromptResponse(BaseModel):
80
+ """Response containing the rendered prompt.
81
+
82
+ Includes both the rendered prompt text and the context that was used
83
+ to render it, for potential client-side use.
84
+ """
85
+
86
+ prompt: str = Field(..., description="The rendered prompt text")
87
+ context: Dict[str, Any] = Field(..., description="The context used to render the prompt")
88
+ metadata: PromptMetadata = Field(
89
+ ..., description="Metadata about the prompt generation process"
90
+ )
@@ -1,9 +1,9 @@
1
1
  """Request schemas for interacting with the knowledge graph."""
2
2
 
3
- from typing import List, Optional, Annotated
3
+ from typing import List, Optional, Annotated, Literal
4
4
  from annotated_types import MaxLen, MinLen
5
5
 
6
- from pydantic import BaseModel
6
+ from pydantic import BaseModel, field_validator
7
7
 
8
8
  from basic_memory.schemas.base import (
9
9
  Relation,
@@ -56,3 +56,57 @@ class GetEntitiesRequest(BaseModel):
56
56
 
57
57
  class CreateRelationsRequest(BaseModel):
58
58
  relations: List[Relation]
59
+
60
+
61
+ class EditEntityRequest(BaseModel):
62
+ """Request schema for editing an existing entity's content.
63
+
64
+ This allows for targeted edits without requiring the full entity content.
65
+ Supports various operation types for different editing scenarios.
66
+ """
67
+
68
+ operation: Literal["append", "prepend", "find_replace", "replace_section"]
69
+ content: str
70
+ section: Optional[str] = None
71
+ find_text: Optional[str] = None
72
+ expected_replacements: int = 1
73
+
74
+ @field_validator("section")
75
+ @classmethod
76
+ def validate_section_for_replace_section(cls, v, info):
77
+ """Ensure section is provided for replace_section operation."""
78
+ if info.data.get("operation") == "replace_section" and not v:
79
+ raise ValueError("section parameter is required for replace_section operation")
80
+ return v
81
+
82
+ @field_validator("find_text")
83
+ @classmethod
84
+ def validate_find_text_for_find_replace(cls, v, info):
85
+ """Ensure find_text is provided for find_replace operation."""
86
+ if info.data.get("operation") == "find_replace" and not v:
87
+ raise ValueError("find_text parameter is required for find_replace operation")
88
+ return v
89
+
90
+
91
+ class MoveEntityRequest(BaseModel):
92
+ """Request schema for moving an entity to a new file location.
93
+
94
+ This allows moving notes to different paths while maintaining project
95
+ consistency and optionally updating permalinks based on configuration.
96
+ """
97
+
98
+ identifier: Annotated[str, MinLen(1), MaxLen(200)]
99
+ destination_path: Annotated[str, MinLen(1), MaxLen(500)]
100
+ project: Optional[str] = None
101
+
102
+ @field_validator("destination_path")
103
+ @classmethod
104
+ def validate_destination_path(cls, v):
105
+ """Ensure destination path is relative and valid."""
106
+ if v.startswith("/"):
107
+ raise ValueError("destination_path must be relative, not absolute")
108
+ if ".." in v:
109
+ raise ValueError("destination_path cannot contain '..' path components")
110
+ if not v.strip():
111
+ raise ValueError("destination_path cannot be empty or whitespace only")
112
+ return v.strip()
@@ -14,7 +14,7 @@ Key Features:
14
14
  from datetime import datetime
15
15
  from typing import List, Optional, Dict
16
16
 
17
- from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices
17
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
18
18
 
19
19
  from basic_memory.schemas.base import Relation, Permalink, EntityType, ContentType, Observation
20
20
 
@@ -64,32 +64,89 @@ class RelationResponse(Relation, SQLAlchemyModel):
64
64
 
65
65
  permalink: Permalink
66
66
 
67
- from_id: Permalink = Field(
68
- # use the permalink from the associated Entity
69
- # or the from_id value
70
- validation_alias=AliasChoices(
71
- AliasPath("from_entity", "permalink"),
72
- "from_id",
73
- )
74
- )
75
- to_id: Optional[Permalink] = Field( # pyright: ignore
76
- # use the permalink from the associated Entity
77
- # or the to_id value
78
- validation_alias=AliasChoices(
79
- AliasPath("to_entity", "permalink"),
80
- "to_id",
81
- ),
82
- default=None,
83
- )
84
- to_name: Optional[Permalink] = Field(
85
- # use the permalink from the associated Entity
86
- # or the to_id value
87
- validation_alias=AliasChoices(
88
- AliasPath("to_entity", "title"),
89
- "to_name",
90
- ),
91
- default=None,
92
- )
67
+ # Override base Relation fields to allow Optional values
68
+ from_id: Optional[Permalink] = Field(default=None) # pyright: ignore[reportIncompatibleVariableOverride]
69
+ to_id: Optional[Permalink] = Field(default=None) # pyright: ignore[reportIncompatibleVariableOverride]
70
+ to_name: Optional[str] = Field(default=None)
71
+
72
+ @model_validator(mode="before")
73
+ @classmethod
74
+ def resolve_entity_references(cls, data):
75
+ """Resolve from_id and to_id from joined entities, falling back to file_path.
76
+
77
+ When loading from SQLAlchemy models, the from_entity and to_entity relationships
78
+ are joined. We extract the permalink from these entities, falling back to
79
+ file_path when permalink is None.
80
+
81
+ We use file_path directly (not converted to permalink format) because if the
82
+ entity doesn't have a permalink, the system won't be able to find it by a
83
+ generated one anyway. Using the actual file_path preserves the real identifier.
84
+ """
85
+ # Handle dict input (e.g., from API or tests)
86
+ if isinstance(data, dict):
87
+ from_entity = data.get("from_entity")
88
+ to_entity = data.get("to_entity")
89
+
90
+ # Resolve from_id: prefer permalink, fall back to file_path
91
+ if from_entity and isinstance(from_entity, dict):
92
+ permalink = from_entity.get("permalink")
93
+ if permalink:
94
+ data["from_id"] = permalink
95
+ elif from_entity.get("file_path"):
96
+ data["from_id"] = from_entity["file_path"]
97
+
98
+ # Resolve to_id: prefer permalink, fall back to file_path
99
+ if to_entity and isinstance(to_entity, dict):
100
+ permalink = to_entity.get("permalink")
101
+ if permalink:
102
+ data["to_id"] = permalink
103
+ elif to_entity.get("file_path"):
104
+ data["to_id"] = to_entity["file_path"]
105
+
106
+ # Also resolve to_name from entity title
107
+ if to_entity.get("title") and not data.get("to_name"):
108
+ data["to_name"] = to_entity["title"]
109
+
110
+ return data
111
+
112
+ # Handle SQLAlchemy model input (from_attributes=True)
113
+ # Access attributes directly from the ORM model
114
+ from_entity = getattr(data, "from_entity", None)
115
+ to_entity = getattr(data, "to_entity", None)
116
+
117
+ # Build a dict from the model's attributes
118
+ result = {}
119
+
120
+ # Copy base fields
121
+ for field in ["permalink", "relation_type", "context", "to_name"]:
122
+ if hasattr(data, field):
123
+ result[field] = getattr(data, field)
124
+
125
+ # Resolve from_id: prefer permalink, fall back to file_path
126
+ if from_entity:
127
+ permalink = getattr(from_entity, "permalink", None)
128
+ file_path = getattr(from_entity, "file_path", None)
129
+ if permalink:
130
+ result["from_id"] = permalink
131
+ elif file_path:
132
+ result["from_id"] = file_path
133
+
134
+ # Resolve to_id: prefer permalink, fall back to file_path
135
+ if to_entity:
136
+ permalink = getattr(to_entity, "permalink", None)
137
+ file_path = getattr(to_entity, "file_path", None)
138
+ if permalink:
139
+ result["to_id"] = permalink
140
+ elif file_path:
141
+ result["to_id"] = file_path
142
+
143
+ # Also resolve to_name from entity title if not set
144
+ if not result.get("to_name"):
145
+ title = getattr(to_entity, "title", None)
146
+ if title:
147
+ result["to_name"] = title
148
+
149
+ return result
93
150
 
94
151
 
95
152
  class EntityResponse(SQLAlchemyModel):
@@ -131,7 +188,7 @@ class EntityResponse(SQLAlchemyModel):
131
188
  }
132
189
  """
133
190
 
134
- permalink: Permalink
191
+ permalink: Optional[Permalink]
135
192
  title: str
136
193
  file_path: str
137
194
  entity_type: EntityType
@@ -11,6 +11,8 @@ from datetime import datetime
11
11
  from enum import Enum
12
12
  from pydantic import BaseModel, field_validator
13
13
 
14
+ from basic_memory.schemas.base import Permalink
15
+
14
16
 
15
17
  class SearchItemType(str, Enum):
16
18
  """Types of searchable items."""
@@ -26,23 +28,29 @@ class SearchQuery(BaseModel):
26
28
  Use ONE of these primary search modes:
27
29
  - permalink: Exact permalink match
28
30
  - permalink_match: Path pattern with *
29
- - text: Full-text search of title/content
31
+ - text: Full-text search of title/content (supports boolean operators: AND, OR, NOT)
30
32
 
31
33
  Optionally filter results by:
32
34
  - types: Limit to specific item types
33
35
  - entity_types: Limit to specific entity types
34
36
  - after_date: Only items after date
37
+
38
+ Boolean search examples:
39
+ - "python AND flask" - Find items with both terms
40
+ - "python OR django" - Find items with either term
41
+ - "python NOT django" - Find items with python but not django
42
+ - "(python OR flask) AND web" - Use parentheses for grouping
35
43
  """
36
44
 
37
45
  # Primary search modes (use ONE of these)
38
46
  permalink: Optional[str] = None # Exact permalink match
39
- permalink_match: Optional[str] = None # Exact permalink match
40
- text: Optional[str] = None # Full-text search
47
+ permalink_match: Optional[str] = None # Glob permalink match
48
+ text: Optional[str] = None # Full-text search (now supports boolean operators)
41
49
  title: Optional[str] = None # title only search
42
50
 
43
51
  # Optional filters
44
- types: Optional[List[SearchItemType]] = None # Filter by item type
45
- entity_types: Optional[List[str]] = None # Filter by entity type
52
+ types: Optional[List[str]] = None # Filter by type
53
+ entity_types: Optional[List[SearchItemType]] = None # Filter by entity type
46
54
  after_date: Optional[Union[datetime, str]] = None # Time-based filter
47
55
 
48
56
  @field_validator("after_date")
@@ -57,60 +65,53 @@ class SearchQuery(BaseModel):
57
65
  return (
58
66
  self.permalink is None
59
67
  and self.permalink_match is None
68
+ and self.title is None
60
69
  and self.text is None
61
70
  and self.after_date is None
62
71
  and self.types is None
63
72
  and self.entity_types is None
64
73
  )
65
74
 
75
+ def has_boolean_operators(self) -> bool:
76
+ """Check if the text query contains boolean operators (AND, OR, NOT)."""
77
+ if not self.text: # pragma: no cover
78
+ return False
79
+
80
+ # Check for common boolean operators with correct word boundaries
81
+ # to avoid matching substrings like "GRAND" containing "AND"
82
+ boolean_patterns = [" AND ", " OR ", " NOT ", "(", ")"]
83
+ text = f" {self.text} " # Add spaces to ensure we match word boundaries
84
+ return any(pattern in text for pattern in boolean_patterns)
85
+
66
86
 
67
87
  class SearchResult(BaseModel):
68
88
  """Search result with score and metadata."""
69
89
 
70
- id: int
90
+ title: str
71
91
  type: SearchItemType
72
92
  score: float
73
- permalink: str
93
+ entity: Optional[Permalink] = None
94
+ permalink: Optional[str]
95
+ content: Optional[str] = None
74
96
  file_path: str
75
97
 
76
98
  metadata: Optional[dict] = None
77
99
 
100
+ # IDs for v2 API consistency
101
+ entity_id: Optional[int] = None # Entity ID (always present for entities)
102
+ observation_id: Optional[int] = None # Observation ID (for observation results)
103
+ relation_id: Optional[int] = None # Relation ID (for relation results)
104
+
78
105
  # Type-specific fields
79
- entity_id: Optional[int] = None # For observations
80
106
  category: Optional[str] = None # For observations
81
- from_id: Optional[int] = None # For relations
82
- to_id: Optional[int] = None # For relations
107
+ from_entity: Optional[Permalink] = None # For relations
108
+ to_entity: Optional[Permalink] = None # For relations
83
109
  relation_type: Optional[str] = None # For relations
84
110
 
85
111
 
86
- class RelatedResult(BaseModel):
87
- type: SearchItemType
88
- id: int
89
- title: str
90
- permalink: str
91
- depth: int
92
- root_id: int
93
- created_at: datetime
94
- from_id: Optional[int] = None
95
- to_id: Optional[int] = None
96
- relation_type: Optional[str] = None
97
- category: Optional[str] = None
98
- entity_id: Optional[int] = None
99
-
100
-
101
112
  class SearchResponse(BaseModel):
102
113
  """Wrapper for search results."""
103
114
 
104
115
  results: List[SearchResult]
105
116
  current_page: int
106
117
  page_size: int
107
-
108
-
109
- # Schema for future advanced search endpoint
110
- class AdvancedSearchQuery(BaseModel):
111
- """Advanced full-text search with explicit FTS5 syntax."""
112
-
113
- query: str # Raw FTS5 query (e.g., "foo AND bar")
114
- types: Optional[List[SearchItemType]] = None
115
- entity_types: Optional[List[str]] = None
116
- after_date: Optional[Union[datetime, str]] = None