basic-memory 0.8.0__py3-none-any.whl → 0.10.0__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 (76) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/migrations.py +4 -9
  3. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +106 -0
  4. basic_memory/api/app.py +9 -6
  5. basic_memory/api/routers/__init__.py +2 -1
  6. basic_memory/api/routers/knowledge_router.py +30 -4
  7. basic_memory/api/routers/memory_router.py +3 -2
  8. basic_memory/api/routers/project_info_router.py +274 -0
  9. basic_memory/api/routers/search_router.py +22 -4
  10. basic_memory/cli/app.py +54 -3
  11. basic_memory/cli/commands/__init__.py +15 -2
  12. basic_memory/cli/commands/db.py +9 -13
  13. basic_memory/cli/commands/import_chatgpt.py +31 -36
  14. basic_memory/cli/commands/import_claude_conversations.py +32 -35
  15. basic_memory/cli/commands/import_claude_projects.py +34 -37
  16. basic_memory/cli/commands/import_memory_json.py +26 -28
  17. basic_memory/cli/commands/mcp.py +7 -1
  18. basic_memory/cli/commands/project.py +119 -0
  19. basic_memory/cli/commands/project_info.py +167 -0
  20. basic_memory/cli/commands/status.py +7 -9
  21. basic_memory/cli/commands/sync.py +54 -9
  22. basic_memory/cli/commands/{tools.py → tool.py} +92 -19
  23. basic_memory/cli/main.py +40 -1
  24. basic_memory/config.py +157 -10
  25. basic_memory/db.py +19 -4
  26. basic_memory/deps.py +10 -3
  27. basic_memory/file_utils.py +34 -18
  28. basic_memory/markdown/markdown_processor.py +1 -1
  29. basic_memory/markdown/utils.py +5 -0
  30. basic_memory/mcp/main.py +1 -2
  31. basic_memory/mcp/prompts/__init__.py +6 -2
  32. basic_memory/mcp/prompts/ai_assistant_guide.py +9 -10
  33. basic_memory/mcp/prompts/continue_conversation.py +65 -126
  34. basic_memory/mcp/prompts/recent_activity.py +55 -13
  35. basic_memory/mcp/prompts/search.py +72 -17
  36. basic_memory/mcp/prompts/utils.py +139 -82
  37. basic_memory/mcp/server.py +1 -1
  38. basic_memory/mcp/tools/__init__.py +11 -22
  39. basic_memory/mcp/tools/build_context.py +85 -0
  40. basic_memory/mcp/tools/canvas.py +17 -19
  41. basic_memory/mcp/tools/delete_note.py +28 -0
  42. basic_memory/mcp/tools/project_info.py +51 -0
  43. basic_memory/mcp/tools/{resource.py → read_content.py} +42 -5
  44. basic_memory/mcp/tools/read_note.py +190 -0
  45. basic_memory/mcp/tools/recent_activity.py +100 -0
  46. basic_memory/mcp/tools/search.py +56 -17
  47. basic_memory/mcp/tools/utils.py +245 -17
  48. basic_memory/mcp/tools/write_note.py +124 -0
  49. basic_memory/models/search.py +2 -1
  50. basic_memory/repository/entity_repository.py +3 -2
  51. basic_memory/repository/project_info_repository.py +9 -0
  52. basic_memory/repository/repository.py +23 -6
  53. basic_memory/repository/search_repository.py +33 -10
  54. basic_memory/schemas/__init__.py +12 -0
  55. basic_memory/schemas/memory.py +3 -2
  56. basic_memory/schemas/project_info.py +96 -0
  57. basic_memory/schemas/search.py +27 -32
  58. basic_memory/services/context_service.py +3 -3
  59. basic_memory/services/entity_service.py +8 -2
  60. basic_memory/services/file_service.py +107 -57
  61. basic_memory/services/link_resolver.py +5 -45
  62. basic_memory/services/search_service.py +45 -16
  63. basic_memory/sync/sync_service.py +274 -39
  64. basic_memory/sync/watch_service.py +174 -34
  65. basic_memory/utils.py +40 -40
  66. basic_memory-0.10.0.dist-info/METADATA +386 -0
  67. basic_memory-0.10.0.dist-info/RECORD +99 -0
  68. basic_memory/mcp/prompts/json_canvas_spec.py +0 -25
  69. basic_memory/mcp/tools/knowledge.py +0 -68
  70. basic_memory/mcp/tools/memory.py +0 -177
  71. basic_memory/mcp/tools/notes.py +0 -201
  72. basic_memory-0.8.0.dist-info/METADATA +0 -379
  73. basic_memory-0.8.0.dist-info/RECORD +0 -91
  74. {basic_memory-0.8.0.dist-info → basic_memory-0.10.0.dist-info}/WHEEL +0 -0
  75. {basic_memory-0.8.0.dist-info → basic_memory-0.10.0.dist-info}/entry_points.txt +0 -0
  76. {basic_memory-0.8.0.dist-info → basic_memory-0.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -64,6 +64,7 @@ class EntitySummary(BaseModel):
64
64
  type: str = "entity"
65
65
  permalink: Optional[str]
66
66
  title: str
67
+ content: Optional[str] = None
67
68
  file_path: str
68
69
  created_at: datetime
69
70
 
@@ -76,8 +77,8 @@ class RelationSummary(BaseModel):
76
77
  file_path: str
77
78
  permalink: str
78
79
  relation_type: str
79
- from_id: str
80
- to_id: Optional[str] = None
80
+ from_entity: str
81
+ to_entity: Optional[str] = None
81
82
  created_at: datetime
82
83
 
83
84
 
@@ -0,0 +1,96 @@
1
+ """Schema for project info response."""
2
+
3
+ from datetime import datetime
4
+ from typing import Dict, List, Optional, Any
5
+
6
+ from pydantic import Field, BaseModel
7
+
8
+
9
+ class ProjectStatistics(BaseModel):
10
+ """Statistics about the current project."""
11
+
12
+ # Basic counts
13
+ total_entities: int = Field(description="Total number of entities in the knowledge base")
14
+ total_observations: int = Field(description="Total number of observations across all entities")
15
+ total_relations: int = Field(description="Total number of relations between entities")
16
+ total_unresolved_relations: int = Field(
17
+ description="Number of relations with unresolved targets"
18
+ )
19
+
20
+ # Entity counts by type
21
+ entity_types: Dict[str, int] = Field(
22
+ description="Count of entities by type (e.g., note, conversation)"
23
+ )
24
+
25
+ # Observation counts by category
26
+ observation_categories: Dict[str, int] = Field(
27
+ description="Count of observations by category (e.g., tech, decision)"
28
+ )
29
+
30
+ # Relation counts by type
31
+ relation_types: Dict[str, int] = Field(
32
+ description="Count of relations by type (e.g., implements, relates_to)"
33
+ )
34
+
35
+ # Graph metrics
36
+ most_connected_entities: List[Dict[str, Any]] = Field(
37
+ description="Entities with the most relations, including their titles and permalinks"
38
+ )
39
+ isolated_entities: int = Field(description="Number of entities with no relations")
40
+
41
+
42
+ class ActivityMetrics(BaseModel):
43
+ """Activity metrics for the current project."""
44
+
45
+ # Recent activity
46
+ recently_created: List[Dict[str, Any]] = Field(
47
+ description="Recently created entities with timestamps"
48
+ )
49
+ recently_updated: List[Dict[str, Any]] = Field(
50
+ description="Recently updated entities with timestamps"
51
+ )
52
+
53
+ # Growth over time (last 6 months)
54
+ monthly_growth: Dict[str, Dict[str, int]] = Field(
55
+ description="Monthly growth statistics for entities, observations, and relations"
56
+ )
57
+
58
+
59
+ class SystemStatus(BaseModel):
60
+ """System status information."""
61
+
62
+ # Version information
63
+ version: str = Field(description="Basic Memory version")
64
+
65
+ # Database status
66
+ database_path: str = Field(description="Path to the SQLite database")
67
+ database_size: str = Field(description="Size of the database in human-readable format")
68
+
69
+ # Watch service status
70
+ watch_status: Optional[Dict[str, Any]] = Field(
71
+ default=None, description="Watch service status information (if running)"
72
+ )
73
+
74
+ # System information
75
+ timestamp: datetime = Field(description="Timestamp when the information was collected")
76
+
77
+
78
+ class ProjectInfoResponse(BaseModel):
79
+ """Response for the project_info tool."""
80
+
81
+ # Project configuration
82
+ project_name: str = Field(description="Name of the current project")
83
+ project_path: str = Field(description="Path to the current project files")
84
+ available_projects: Dict[str, str] = Field(
85
+ description="Map of configured project names to paths"
86
+ )
87
+ default_project: str = Field(description="Name of the default project")
88
+
89
+ # Statistics
90
+ statistics: ProjectStatistics = Field(description="Statistics about the knowledge base")
91
+
92
+ # Activity metrics
93
+ activity: ActivityMetrics = Field(description="Activity and growth metrics")
94
+
95
+ # System status
96
+ system: SystemStatus = Field(description="System and service status information")
@@ -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,18 +28,24 @@ 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
@@ -57,61 +65,48 @@ 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
71
90
  title: str
72
91
  type: SearchItemType
73
92
  score: float
93
+ entity: Optional[Permalink]
74
94
  permalink: Optional[str]
95
+ content: Optional[str] = None
75
96
  file_path: str
76
97
 
77
98
  metadata: Optional[dict] = None
78
99
 
79
100
  # Type-specific fields
80
- entity_id: Optional[int] = None # For observations
81
101
  category: Optional[str] = None # For observations
82
- from_id: Optional[int] = None # For relations
83
- to_id: Optional[int] = None # For relations
102
+ from_entity: Optional[Permalink] = None # For relations
103
+ to_entity: Optional[Permalink] = None # For relations
84
104
  relation_type: Optional[str] = None # For relations
85
105
 
86
106
 
87
- class RelatedResult(BaseModel):
88
- type: SearchItemType
89
- id: int
90
- title: str
91
- permalink: str
92
- depth: int
93
- root_id: int
94
- created_at: datetime
95
- from_id: Optional[int] = None
96
- to_id: Optional[int] = None
97
- relation_type: Optional[str] = None
98
- category: Optional[str] = None
99
- entity_id: Optional[int] = None
100
-
101
-
102
107
  class SearchResponse(BaseModel):
103
108
  """Wrapper for search results."""
104
109
 
105
110
  results: List[SearchResult]
106
111
  current_page: int
107
112
  page_size: int
108
-
109
-
110
- # Schema for future advanced search endpoint
111
- class AdvancedSearchQuery(BaseModel):
112
- """Advanced full-text search with explicit FTS5 syntax."""
113
-
114
- query: str # Raw FTS5 query (e.g., "foo AND bar")
115
- types: Optional[List[SearchItemType]] = None
116
- entity_types: Optional[List[str]] = None
117
- after_date: Optional[Union[datetime, str]] = None
@@ -165,7 +165,7 @@ class ContextService:
165
165
  from_id,
166
166
  to_id,
167
167
  relation_type,
168
- content,
168
+ content_snippet as content,
169
169
  category,
170
170
  entity_id,
171
171
  0 as depth,
@@ -189,7 +189,7 @@ class ContextService:
189
189
  r.from_id,
190
190
  r.to_id,
191
191
  r.relation_type,
192
- r.content,
192
+ r.content_snippet as content,
193
193
  r.category,
194
194
  r.entity_id,
195
195
  cg.depth + 1,
@@ -218,7 +218,7 @@ class ContextService:
218
218
  e.from_id,
219
219
  e.to_id,
220
220
  e.relation_type,
221
- e.content,
221
+ e.content_snippet as content,
222
222
  e.category,
223
223
  e.entity_id,
224
224
  cg.depth + 1, -- Increment depth for entities
@@ -144,7 +144,7 @@ class EntityService(BaseService[EntityModel]):
144
144
  post = await schema_to_markdown(schema)
145
145
 
146
146
  # write file
147
- final_content = frontmatter.dumps(post)
147
+ final_content = frontmatter.dumps(post, sort_keys=False)
148
148
  checksum = await self.file_service.write_file(file_path, final_content)
149
149
 
150
150
  # parse entity from file
@@ -171,7 +171,13 @@ class EntityService(BaseService[EntityModel]):
171
171
  entity = await self.get_by_permalink(permalink_or_id)
172
172
  else:
173
173
  entities = await self.get_entities_by_id([permalink_or_id])
174
- assert len(entities) == 1, f"Expected 1 entity, got {len(entities)}"
174
+ if len(entities) != 1: # pragma: no cover
175
+ logger.error(
176
+ "Entity lookup error", entity_id=permalink_or_id, found_count=len(entities)
177
+ )
178
+ raise ValueError(
179
+ f"Expected 1 entity with ID {permalink_or_id}, got {len(entities)}"
180
+ )
175
181
  entity = entities[0]
176
182
 
177
183
  # Delete file first
@@ -3,9 +3,7 @@
3
3
  import mimetypes
4
4
  from os import stat_result
5
5
  from pathlib import Path
6
- from typing import Tuple, Union, Dict, Any
7
-
8
- from loguru import logger
6
+ from typing import Any, Dict, Tuple, Union
9
7
 
10
8
  from basic_memory import file_utils
11
9
  from basic_memory.file_utils import FileError
@@ -13,6 +11,8 @@ from basic_memory.markdown.markdown_processor import MarkdownProcessor
13
11
  from basic_memory.models import Entity as EntityModel
14
12
  from basic_memory.schemas import Entity as EntitySchema
15
13
  from basic_memory.services.exceptions import FileOperationError
14
+ from basic_memory.utils import FilePath
15
+ from loguru import logger
16
16
 
17
17
 
18
18
  class FileService:
@@ -60,7 +60,7 @@ class FileService:
60
60
  Returns:
61
61
  Raw content string without metadata sections
62
62
  """
63
- logger.debug(f"Reading entity with permalink: {entity.permalink}")
63
+ logger.debug("Reading entity content", entity_id=entity.id, permalink=entity.permalink)
64
64
 
65
65
  file_path = self.get_entity_path(entity)
66
66
  markdown = await self.markdown_processor.read_file(file_path)
@@ -78,13 +78,13 @@ class FileService:
78
78
  path = self.get_entity_path(entity)
79
79
  await self.delete_file(path)
80
80
 
81
- async def exists(self, path: Union[Path, str]) -> bool:
81
+ async def exists(self, path: FilePath) -> bool:
82
82
  """Check if file exists at the provided path.
83
83
 
84
84
  If path is relative, it is assumed to be relative to base_path.
85
85
 
86
86
  Args:
87
- path: Path to check (Path object or string)
87
+ path: Path to check (Path or string)
88
88
 
89
89
  Returns:
90
90
  True if file exists, False otherwise
@@ -93,23 +93,25 @@ class FileService:
93
93
  FileOperationError: If check fails
94
94
  """
95
95
  try:
96
- path = Path(path)
97
- if path.is_absolute():
98
- return path.exists()
96
+ # Convert string to Path if needed
97
+ path_obj = Path(path) if isinstance(path, str) else path
98
+
99
+ if path_obj.is_absolute():
100
+ return path_obj.exists()
99
101
  else:
100
- return (self.base_path / path).exists()
102
+ return (self.base_path / path_obj).exists()
101
103
  except Exception as e:
102
- logger.error(f"Failed to check file existence {path}: {e}")
104
+ logger.error("Failed to check file existence", path=str(path), error=str(e))
103
105
  raise FileOperationError(f"Failed to check file existence: {e}")
104
106
 
105
- async def write_file(self, path: Union[Path, str], content: str) -> str:
107
+ async def write_file(self, path: FilePath, content: str) -> str:
106
108
  """Write content to file and return checksum.
107
109
 
108
110
  Handles both absolute and relative paths. Relative paths are resolved
109
111
  against base_path.
110
112
 
111
113
  Args:
112
- path: Where to write (Path object or string)
114
+ path: Where to write (Path or string)
113
115
  content: Content to write
114
116
 
115
117
  Returns:
@@ -118,34 +120,43 @@ class FileService:
118
120
  Raises:
119
121
  FileOperationError: If write fails
120
122
  """
121
- path = Path(path)
122
- full_path = path if path.is_absolute() else self.base_path / path
123
+ # Convert string to Path if needed
124
+ path_obj = Path(path) if isinstance(path, str) else path
125
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
123
126
 
124
127
  try:
125
128
  # Ensure parent directory exists
126
129
  await file_utils.ensure_directory(full_path.parent)
127
130
 
128
131
  # Write content atomically
132
+ logger.info(
133
+ "Writing file",
134
+ operation="write_file",
135
+ path=str(full_path),
136
+ content_length=len(content),
137
+ is_markdown=full_path.suffix.lower() == ".md",
138
+ )
139
+
129
140
  await file_utils.write_file_atomic(full_path, content)
130
141
 
131
142
  # Compute and return checksum
132
143
  checksum = await file_utils.compute_checksum(content)
133
- logger.debug(f"wrote file: {full_path}, checksum: {checksum}")
144
+ logger.debug("File write completed", path=str(full_path), checksum=checksum)
134
145
  return checksum
135
146
 
136
147
  except Exception as e:
137
- logger.error(f"Failed to write file {full_path}: {e}")
148
+ logger.exception("File write error", path=str(full_path), error=str(e))
138
149
  raise FileOperationError(f"Failed to write file: {e}")
139
150
 
140
151
  # TODO remove read_file
141
- async def read_file(self, path: Union[Path, str]) -> Tuple[str, str]:
152
+ async def read_file(self, path: FilePath) -> Tuple[str, str]:
142
153
  """Read file and compute checksum.
143
154
 
144
155
  Handles both absolute and relative paths. Relative paths are resolved
145
156
  against base_path.
146
157
 
147
158
  Args:
148
- path: Path to read (Path object or string)
159
+ path: Path to read (Path or string)
149
160
 
150
161
  Returns:
151
162
  Tuple of (content, checksum)
@@ -153,77 +164,113 @@ class FileService:
153
164
  Raises:
154
165
  FileOperationError: If read fails
155
166
  """
156
- path = Path(path)
157
- full_path = path if path.is_absolute() else self.base_path / path
167
+ # Convert string to Path if needed
168
+ path_obj = Path(path) if isinstance(path, str) else path
169
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
158
170
 
159
171
  try:
160
- content = full_path.read_text()
172
+ logger.debug("Reading file", operation="read_file", path=str(full_path))
173
+ content = full_path.read_text(encoding="utf-8")
161
174
  checksum = await file_utils.compute_checksum(content)
162
- logger.debug(f"read file: {full_path}, checksum: {checksum}")
175
+
176
+ logger.debug(
177
+ "File read completed",
178
+ path=str(full_path),
179
+ checksum=checksum,
180
+ content_length=len(content),
181
+ )
163
182
  return content, checksum
164
183
 
165
184
  except Exception as e:
166
- logger.error(f"Failed to read file {full_path}: {e}")
185
+ logger.exception("File read error", path=str(full_path), error=str(e))
167
186
  raise FileOperationError(f"Failed to read file: {e}")
168
187
 
169
- async def delete_file(self, path: Union[Path, str]) -> None:
188
+ async def delete_file(self, path: FilePath) -> None:
170
189
  """Delete file if it exists.
171
190
 
172
191
  Handles both absolute and relative paths. Relative paths are resolved
173
192
  against base_path.
174
193
 
175
194
  Args:
176
- path: Path to delete (Path object or string)
195
+ path: Path to delete (Path or string)
177
196
  """
178
- path = Path(path)
179
- full_path = path if path.is_absolute() else self.base_path / path
197
+ # Convert string to Path if needed
198
+ path_obj = Path(path) if isinstance(path, str) else path
199
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
180
200
  full_path.unlink(missing_ok=True)
181
201
 
182
- async def update_frontmatter(self, path: Union[Path, str], updates: Dict[str, Any]) -> str:
202
+ async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
183
203
  """
184
204
  Update frontmatter fields in a file while preserving all content.
185
- """
186
205
 
187
- path = Path(path)
188
- full_path = path if path.is_absolute() else self.base_path / path
206
+ Args:
207
+ path: Path to the file (Path or string)
208
+ updates: Dictionary of frontmatter fields to update
209
+
210
+ Returns:
211
+ Checksum of updated file
212
+ """
213
+ # Convert string to Path if needed
214
+ path_obj = Path(path) if isinstance(path, str) else path
215
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
189
216
  return await file_utils.update_frontmatter(full_path, updates)
190
217
 
191
- async def compute_checksum(self, path: Union[str, Path]) -> str:
192
- """Compute checksum for a file."""
193
- path = Path(path)
194
- full_path = path if path.is_absolute() else self.base_path / path
218
+ async def compute_checksum(self, path: FilePath) -> str:
219
+ """Compute checksum for a file.
220
+
221
+ Args:
222
+ path: Path to the file (Path or string)
223
+
224
+ Returns:
225
+ Checksum of the file content
226
+
227
+ Raises:
228
+ FileError: If checksum computation fails
229
+ """
230
+ # Convert string to Path if needed
231
+ path_obj = Path(path) if isinstance(path, str) else path
232
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
233
+
195
234
  try:
196
235
  if self.is_markdown(path):
197
236
  # read str
198
- content = full_path.read_text()
237
+ content = full_path.read_text(encoding="utf-8")
199
238
  else:
200
239
  # read bytes
201
240
  content = full_path.read_bytes()
202
241
  return await file_utils.compute_checksum(content)
203
242
 
204
243
  except Exception as e: # pragma: no cover
205
- logger.error(f"Failed to compute checksum for {path}: {e}")
244
+ logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
206
245
  raise FileError(f"Failed to compute checksum for {path}: {e}")
207
246
 
208
- def file_stats(self, path: Union[Path, str]) -> stat_result:
209
- """
210
- Return file stats for a given path.
211
- :param path:
212
- :return:
247
+ def file_stats(self, path: FilePath) -> stat_result:
248
+ """Return file stats for a given path.
249
+
250
+ Args:
251
+ path: Path to the file (Path or string)
252
+
253
+ Returns:
254
+ File statistics
213
255
  """
214
- path = Path(path)
215
- full_path = path if path.is_absolute() else self.base_path / path
256
+ # Convert string to Path if needed
257
+ path_obj = Path(path) if isinstance(path, str) else path
258
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
216
259
  # get file timestamps
217
260
  return full_path.stat()
218
261
 
219
- def content_type(self, path: Union[Path, str]) -> str:
220
- """
221
- Return content_type for a given path.
222
- :param path:
223
- :return:
262
+ def content_type(self, path: FilePath) -> str:
263
+ """Return content_type for a given path.
264
+
265
+ Args:
266
+ path: Path to the file (Path or string)
267
+
268
+ Returns:
269
+ MIME type of the file
224
270
  """
225
- path = Path(path)
226
- full_path = path if path.is_absolute() else self.base_path / path
271
+ # Convert string to Path if needed
272
+ path_obj = Path(path) if isinstance(path, str) else path
273
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
227
274
  # get file timestamps
228
275
  mime_type, _ = mimetypes.guess_type(full_path.name)
229
276
 
@@ -234,10 +281,13 @@ class FileService:
234
281
  content_type = mime_type or "text/plain"
235
282
  return content_type
236
283
 
237
- def is_markdown(self, path: Union[Path, str]) -> bool:
238
- """
239
- Return content_type for a given path.
240
- :param path:
241
- :return:
284
+ def is_markdown(self, path: FilePath) -> bool:
285
+ """Check if a file is a markdown file.
286
+
287
+ Args:
288
+ path: Path to the file (Path or string)
289
+
290
+ Returns:
291
+ True if the file is a markdown file, False otherwise
242
292
  """
243
293
  return self.content_type(path) == "text/markdown"
@@ -1,12 +1,11 @@
1
1
  """Service for resolving markdown links to permalinks."""
2
2
 
3
- from typing import Optional, Tuple, List
3
+ from typing import Optional, Tuple
4
4
 
5
5
  from loguru import logger
6
6
 
7
7
  from basic_memory.models import Entity
8
8
  from basic_memory.repository.entity_repository import EntityRepository
9
- from basic_memory.repository.search_repository import SearchIndexRow
10
9
  from basic_memory.schemas.search import SearchQuery, SearchItemType
11
10
  from basic_memory.services.search_service import SearchService
12
11
 
@@ -41,8 +40,9 @@ class LinkResolver:
41
40
  return entity
42
41
 
43
42
  # 2. Try exact title match
44
- entity = await self.entity_repository.get_by_title(clean_text)
45
- if entity:
43
+ found = await self.entity_repository.get_by_title(clean_text)
44
+ if found and len(found) == 1:
45
+ entity = found[0]
46
46
  logger.debug(f"Found title match: {entity.title}")
47
47
  return entity
48
48
 
@@ -54,7 +54,7 @@ class LinkResolver:
54
54
 
55
55
  if results:
56
56
  # Look for best match
57
- best_match = self._select_best_match(clean_text, results)
57
+ best_match = min(results, key=lambda x: x.score) # pyright: ignore
58
58
  logger.debug(
59
59
  f"Selected best match from {len(results)} results: {best_match.permalink}"
60
60
  )
@@ -88,43 +88,3 @@ class LinkResolver:
88
88
  alias = alias.strip()
89
89
 
90
90
  return text, alias
91
-
92
- def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> SearchIndexRow:
93
- """Select best match from search results.
94
-
95
- Uses multiple criteria:
96
- 1. Word matches in title field
97
- 2. Word matches in path
98
- 3. Overall search score
99
- """
100
- # Get search terms for matching
101
- terms = search_text.lower().split()
102
-
103
- # Score each result
104
- scored_results = []
105
- for result in results:
106
- # Start with base score (lower is better)
107
- score = result.score or 0
108
-
109
- if result.permalink:
110
- # Parse path components
111
- path_parts = result.permalink.lower().split("/")
112
- last_part = path_parts[-1] if path_parts else ""
113
- else:
114
- last_part = "" # pragma: no cover
115
-
116
- # Title word match boosts
117
- term_matches = [term for term in terms if term in last_part]
118
- if term_matches:
119
- score *= 0.5 # Boost for each matching term
120
-
121
- # Exact title match is best
122
- if last_part == search_text.lower():
123
- score *= 0.2
124
-
125
- scored_results.append((score, result))
126
-
127
- # Sort by score (lowest first) and return best
128
- scored_results.sort(key=lambda x: x[0], reverse=True)
129
-
130
- return scored_results[0][1]