basic-memory 0.8.0__py3-none-any.whl → 0.9.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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +106 -0
- basic_memory/api/app.py +9 -6
- basic_memory/api/routers/__init__.py +2 -1
- basic_memory/api/routers/knowledge_router.py +30 -4
- basic_memory/api/routers/memory_router.py +3 -2
- basic_memory/api/routers/project_info_router.py +275 -0
- basic_memory/api/routers/search_router.py +22 -4
- basic_memory/cli/app.py +54 -3
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/db.py +9 -13
- basic_memory/cli/commands/import_chatgpt.py +26 -30
- basic_memory/cli/commands/import_claude_conversations.py +27 -29
- basic_memory/cli/commands/import_claude_projects.py +29 -31
- basic_memory/cli/commands/import_memory_json.py +26 -28
- basic_memory/cli/commands/mcp.py +7 -1
- basic_memory/cli/commands/project.py +119 -0
- basic_memory/cli/commands/project_info.py +167 -0
- basic_memory/cli/commands/status.py +7 -9
- basic_memory/cli/commands/sync.py +54 -9
- basic_memory/cli/commands/{tools.py → tool.py} +92 -19
- basic_memory/cli/main.py +40 -1
- basic_memory/config.py +155 -7
- basic_memory/db.py +19 -4
- basic_memory/deps.py +10 -3
- basic_memory/file_utils.py +32 -16
- basic_memory/markdown/utils.py +5 -0
- basic_memory/mcp/main.py +1 -2
- basic_memory/mcp/prompts/__init__.py +6 -2
- basic_memory/mcp/prompts/ai_assistant_guide.py +6 -8
- basic_memory/mcp/prompts/continue_conversation.py +65 -126
- basic_memory/mcp/prompts/recent_activity.py +55 -13
- basic_memory/mcp/prompts/search.py +72 -17
- basic_memory/mcp/prompts/utils.py +139 -82
- basic_memory/mcp/server.py +1 -1
- basic_memory/mcp/tools/__init__.py +11 -22
- basic_memory/mcp/tools/build_context.py +85 -0
- basic_memory/mcp/tools/canvas.py +17 -19
- basic_memory/mcp/tools/delete_note.py +28 -0
- basic_memory/mcp/tools/project_info.py +51 -0
- basic_memory/mcp/tools/{resource.py → read_content.py} +42 -5
- basic_memory/mcp/tools/read_note.py +190 -0
- basic_memory/mcp/tools/recent_activity.py +100 -0
- basic_memory/mcp/tools/search.py +56 -17
- basic_memory/mcp/tools/utils.py +245 -17
- basic_memory/mcp/tools/write_note.py +124 -0
- basic_memory/models/search.py +2 -1
- basic_memory/repository/entity_repository.py +3 -2
- basic_memory/repository/project_info_repository.py +9 -0
- basic_memory/repository/repository.py +23 -6
- basic_memory/repository/search_repository.py +33 -10
- basic_memory/schemas/__init__.py +12 -0
- basic_memory/schemas/memory.py +3 -2
- basic_memory/schemas/project_info.py +96 -0
- basic_memory/schemas/search.py +27 -32
- basic_memory/services/context_service.py +3 -3
- basic_memory/services/entity_service.py +8 -2
- basic_memory/services/file_service.py +105 -53
- basic_memory/services/link_resolver.py +5 -45
- basic_memory/services/search_service.py +45 -16
- basic_memory/sync/sync_service.py +274 -39
- basic_memory/sync/watch_service.py +160 -30
- basic_memory/utils.py +40 -40
- basic_memory-0.9.0.dist-info/METADATA +736 -0
- basic_memory-0.9.0.dist-info/RECORD +99 -0
- basic_memory/mcp/prompts/json_canvas_spec.py +0 -25
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -177
- basic_memory/mcp/tools/notes.py +0 -201
- basic_memory-0.8.0.dist-info/METADATA +0 -379
- basic_memory-0.8.0.dist-info/RECORD +0 -91
- {basic_memory-0.8.0.dist-info → basic_memory-0.9.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.8.0.dist-info → basic_memory-0.9.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.8.0.dist-info → basic_memory-0.9.0.dist-info}/licenses/LICENSE +0 -0
basic_memory/schemas/memory.py
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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")
|
basic_memory/schemas/search.py
CHANGED
|
@@ -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 #
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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,7 +3,7 @@
|
|
|
3
3
|
import mimetypes
|
|
4
4
|
from os import stat_result
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import
|
|
6
|
+
from typing import Any, Dict, Tuple, Union
|
|
7
7
|
|
|
8
8
|
from loguru import logger
|
|
9
9
|
|
|
@@ -13,6 +13,7 @@ from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
|
|
13
13
|
from basic_memory.models import Entity as EntityModel
|
|
14
14
|
from basic_memory.schemas import Entity as EntitySchema
|
|
15
15
|
from basic_memory.services.exceptions import FileOperationError
|
|
16
|
+
from basic_memory.utils import FilePath
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class FileService:
|
|
@@ -60,7 +61,7 @@ class FileService:
|
|
|
60
61
|
Returns:
|
|
61
62
|
Raw content string without metadata sections
|
|
62
63
|
"""
|
|
63
|
-
logger.debug(
|
|
64
|
+
logger.debug("Reading entity content", entity_id=entity.id, permalink=entity.permalink)
|
|
64
65
|
|
|
65
66
|
file_path = self.get_entity_path(entity)
|
|
66
67
|
markdown = await self.markdown_processor.read_file(file_path)
|
|
@@ -78,13 +79,13 @@ class FileService:
|
|
|
78
79
|
path = self.get_entity_path(entity)
|
|
79
80
|
await self.delete_file(path)
|
|
80
81
|
|
|
81
|
-
async def exists(self, path:
|
|
82
|
+
async def exists(self, path: FilePath) -> bool:
|
|
82
83
|
"""Check if file exists at the provided path.
|
|
83
84
|
|
|
84
85
|
If path is relative, it is assumed to be relative to base_path.
|
|
85
86
|
|
|
86
87
|
Args:
|
|
87
|
-
path: Path to check (Path
|
|
88
|
+
path: Path to check (Path or string)
|
|
88
89
|
|
|
89
90
|
Returns:
|
|
90
91
|
True if file exists, False otherwise
|
|
@@ -93,23 +94,25 @@ class FileService:
|
|
|
93
94
|
FileOperationError: If check fails
|
|
94
95
|
"""
|
|
95
96
|
try:
|
|
96
|
-
|
|
97
|
-
if path
|
|
98
|
-
|
|
97
|
+
# Convert string to Path if needed
|
|
98
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
99
|
+
|
|
100
|
+
if path_obj.is_absolute():
|
|
101
|
+
return path_obj.exists()
|
|
99
102
|
else:
|
|
100
|
-
return (self.base_path /
|
|
103
|
+
return (self.base_path / path_obj).exists()
|
|
101
104
|
except Exception as e:
|
|
102
|
-
logger.error(
|
|
105
|
+
logger.error("Failed to check file existence", path=str(path), error=str(e))
|
|
103
106
|
raise FileOperationError(f"Failed to check file existence: {e}")
|
|
104
107
|
|
|
105
|
-
async def write_file(self, path:
|
|
108
|
+
async def write_file(self, path: FilePath, content: str) -> str:
|
|
106
109
|
"""Write content to file and return checksum.
|
|
107
110
|
|
|
108
111
|
Handles both absolute and relative paths. Relative paths are resolved
|
|
109
112
|
against base_path.
|
|
110
113
|
|
|
111
114
|
Args:
|
|
112
|
-
path: Where to write (Path
|
|
115
|
+
path: Where to write (Path or string)
|
|
113
116
|
content: Content to write
|
|
114
117
|
|
|
115
118
|
Returns:
|
|
@@ -118,34 +121,43 @@ class FileService:
|
|
|
118
121
|
Raises:
|
|
119
122
|
FileOperationError: If write fails
|
|
120
123
|
"""
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
# Convert string to Path if needed
|
|
125
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
126
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
123
127
|
|
|
124
128
|
try:
|
|
125
129
|
# Ensure parent directory exists
|
|
126
130
|
await file_utils.ensure_directory(full_path.parent)
|
|
127
131
|
|
|
128
132
|
# Write content atomically
|
|
133
|
+
logger.info(
|
|
134
|
+
"Writing file",
|
|
135
|
+
operation="write_file",
|
|
136
|
+
path=str(full_path),
|
|
137
|
+
content_length=len(content),
|
|
138
|
+
is_markdown=full_path.suffix.lower() == ".md",
|
|
139
|
+
)
|
|
140
|
+
|
|
129
141
|
await file_utils.write_file_atomic(full_path, content)
|
|
130
142
|
|
|
131
143
|
# Compute and return checksum
|
|
132
144
|
checksum = await file_utils.compute_checksum(content)
|
|
133
|
-
logger.debug(
|
|
145
|
+
logger.debug("File write completed", path=str(full_path), checksum=checksum)
|
|
134
146
|
return checksum
|
|
135
147
|
|
|
136
148
|
except Exception as e:
|
|
137
|
-
logger.
|
|
149
|
+
logger.exception("File write error", path=str(full_path), error=str(e))
|
|
138
150
|
raise FileOperationError(f"Failed to write file: {e}")
|
|
139
151
|
|
|
140
152
|
# TODO remove read_file
|
|
141
|
-
async def read_file(self, path:
|
|
153
|
+
async def read_file(self, path: FilePath) -> Tuple[str, str]:
|
|
142
154
|
"""Read file and compute checksum.
|
|
143
155
|
|
|
144
156
|
Handles both absolute and relative paths. Relative paths are resolved
|
|
145
157
|
against base_path.
|
|
146
158
|
|
|
147
159
|
Args:
|
|
148
|
-
path: Path to read (Path
|
|
160
|
+
path: Path to read (Path or string)
|
|
149
161
|
|
|
150
162
|
Returns:
|
|
151
163
|
Tuple of (content, checksum)
|
|
@@ -153,45 +165,74 @@ class FileService:
|
|
|
153
165
|
Raises:
|
|
154
166
|
FileOperationError: If read fails
|
|
155
167
|
"""
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
# Convert string to Path if needed
|
|
169
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
170
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
158
171
|
|
|
159
172
|
try:
|
|
173
|
+
logger.debug("Reading file", operation="read_file", path=str(full_path))
|
|
174
|
+
|
|
160
175
|
content = full_path.read_text()
|
|
161
176
|
checksum = await file_utils.compute_checksum(content)
|
|
162
|
-
|
|
177
|
+
|
|
178
|
+
logger.debug(
|
|
179
|
+
"File read completed",
|
|
180
|
+
path=str(full_path),
|
|
181
|
+
checksum=checksum,
|
|
182
|
+
content_length=len(content),
|
|
183
|
+
)
|
|
163
184
|
return content, checksum
|
|
164
185
|
|
|
165
186
|
except Exception as e:
|
|
166
|
-
logger.
|
|
187
|
+
logger.exception("File read error", path=str(full_path), error=str(e))
|
|
167
188
|
raise FileOperationError(f"Failed to read file: {e}")
|
|
168
189
|
|
|
169
|
-
async def delete_file(self, path:
|
|
190
|
+
async def delete_file(self, path: FilePath) -> None:
|
|
170
191
|
"""Delete file if it exists.
|
|
171
192
|
|
|
172
193
|
Handles both absolute and relative paths. Relative paths are resolved
|
|
173
194
|
against base_path.
|
|
174
195
|
|
|
175
196
|
Args:
|
|
176
|
-
path: Path to delete (Path
|
|
197
|
+
path: Path to delete (Path or string)
|
|
177
198
|
"""
|
|
178
|
-
|
|
179
|
-
|
|
199
|
+
# Convert string to Path if needed
|
|
200
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
201
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
180
202
|
full_path.unlink(missing_ok=True)
|
|
181
203
|
|
|
182
|
-
async def update_frontmatter(self, path:
|
|
204
|
+
async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
|
|
183
205
|
"""
|
|
184
206
|
Update frontmatter fields in a file while preserving all content.
|
|
185
|
-
"""
|
|
186
207
|
|
|
187
|
-
|
|
188
|
-
|
|
208
|
+
Args:
|
|
209
|
+
path: Path to the file (Path or string)
|
|
210
|
+
updates: Dictionary of frontmatter fields to update
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Checksum of updated file
|
|
214
|
+
"""
|
|
215
|
+
# Convert string to Path if needed
|
|
216
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
217
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
189
218
|
return await file_utils.update_frontmatter(full_path, updates)
|
|
190
219
|
|
|
191
|
-
async def compute_checksum(self, path:
|
|
192
|
-
"""Compute checksum for a file.
|
|
193
|
-
|
|
194
|
-
|
|
220
|
+
async def compute_checksum(self, path: FilePath) -> str:
|
|
221
|
+
"""Compute checksum for a file.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
path: Path to the file (Path or string)
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Checksum of the file content
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
FileError: If checksum computation fails
|
|
231
|
+
"""
|
|
232
|
+
# Convert string to Path if needed
|
|
233
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
234
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
235
|
+
|
|
195
236
|
try:
|
|
196
237
|
if self.is_markdown(path):
|
|
197
238
|
# read str
|
|
@@ -202,28 +243,36 @@ class FileService:
|
|
|
202
243
|
return await file_utils.compute_checksum(content)
|
|
203
244
|
|
|
204
245
|
except Exception as e: # pragma: no cover
|
|
205
|
-
logger.error(
|
|
246
|
+
logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
|
|
206
247
|
raise FileError(f"Failed to compute checksum for {path}: {e}")
|
|
207
248
|
|
|
208
|
-
def file_stats(self, path:
|
|
209
|
-
"""
|
|
210
|
-
|
|
211
|
-
:
|
|
212
|
-
|
|
249
|
+
def file_stats(self, path: FilePath) -> stat_result:
|
|
250
|
+
"""Return file stats for a given path.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
path: Path to the file (Path or string)
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
File statistics
|
|
213
257
|
"""
|
|
214
|
-
|
|
215
|
-
|
|
258
|
+
# Convert string to Path if needed
|
|
259
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
260
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
216
261
|
# get file timestamps
|
|
217
262
|
return full_path.stat()
|
|
218
263
|
|
|
219
|
-
def content_type(self, path:
|
|
220
|
-
"""
|
|
221
|
-
|
|
222
|
-
:
|
|
223
|
-
|
|
264
|
+
def content_type(self, path: FilePath) -> str:
|
|
265
|
+
"""Return content_type for a given path.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
path: Path to the file (Path or string)
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
MIME type of the file
|
|
224
272
|
"""
|
|
225
|
-
|
|
226
|
-
|
|
273
|
+
# Convert string to Path if needed
|
|
274
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
275
|
+
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
|
|
227
276
|
# get file timestamps
|
|
228
277
|
mime_type, _ = mimetypes.guess_type(full_path.name)
|
|
229
278
|
|
|
@@ -234,10 +283,13 @@ class FileService:
|
|
|
234
283
|
content_type = mime_type or "text/plain"
|
|
235
284
|
return content_type
|
|
236
285
|
|
|
237
|
-
def is_markdown(self, path:
|
|
238
|
-
"""
|
|
239
|
-
|
|
240
|
-
:
|
|
241
|
-
|
|
286
|
+
def is_markdown(self, path: FilePath) -> bool:
|
|
287
|
+
"""Check if a file is a markdown file.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
path: Path to the file (Path or string)
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
True if the file is a markdown file, False otherwise
|
|
242
294
|
"""
|
|
243
295
|
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
|
|
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
|
-
|
|
45
|
-
if
|
|
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 =
|
|
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]
|