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.
- 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 +274 -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 +31 -36
- basic_memory/cli/commands/import_claude_conversations.py +32 -35
- basic_memory/cli/commands/import_claude_projects.py +34 -37
- 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 +157 -10
- basic_memory/db.py +19 -4
- basic_memory/deps.py +10 -3
- basic_memory/file_utils.py +34 -18
- basic_memory/markdown/markdown_processor.py +1 -1
- 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 +9 -10
- 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 +107 -57
- 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 +174 -34
- basic_memory/utils.py +40 -40
- basic_memory-0.10.0.dist-info/METADATA +386 -0
- basic_memory-0.10.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.10.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.8.0.dist-info → basic_memory-0.10.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.8.0.dist-info → basic_memory-0.10.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,9 +3,7 @@
|
|
|
3
3
|
import mimetypes
|
|
4
4
|
from os import stat_result
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import
|
|
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(
|
|
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:
|
|
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
|
|
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
|
-
|
|
97
|
-
if path
|
|
98
|
-
|
|
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 /
|
|
102
|
+
return (self.base_path / path_obj).exists()
|
|
101
103
|
except Exception as e:
|
|
102
|
-
logger.error(
|
|
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:
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
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(
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
195
|
+
path: Path to delete (Path or string)
|
|
177
196
|
"""
|
|
178
|
-
|
|
179
|
-
|
|
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:
|
|
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
|
-
|
|
188
|
-
|
|
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:
|
|
192
|
-
"""Compute checksum for a file.
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
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:
|
|
209
|
-
"""
|
|
210
|
-
|
|
211
|
-
:
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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:
|
|
220
|
-
"""
|
|
221
|
-
|
|
222
|
-
:
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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:
|
|
238
|
-
"""
|
|
239
|
-
|
|
240
|
-
:
|
|
241
|
-
|
|
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
|
|
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]
|