basic-memory 0.1.1__py3-none-any.whl → 0.2.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 (77) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/README +1 -0
  3. basic_memory/alembic/env.py +75 -0
  4. basic_memory/alembic/migrations.py +29 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  7. basic_memory/api/__init__.py +2 -1
  8. basic_memory/api/app.py +26 -24
  9. basic_memory/api/routers/knowledge_router.py +28 -26
  10. basic_memory/api/routers/memory_router.py +17 -11
  11. basic_memory/api/routers/search_router.py +6 -12
  12. basic_memory/cli/__init__.py +1 -1
  13. basic_memory/cli/app.py +0 -1
  14. basic_memory/cli/commands/__init__.py +3 -3
  15. basic_memory/cli/commands/db.py +25 -0
  16. basic_memory/cli/commands/import_memory_json.py +35 -31
  17. basic_memory/cli/commands/mcp.py +20 -0
  18. basic_memory/cli/commands/status.py +10 -6
  19. basic_memory/cli/commands/sync.py +5 -56
  20. basic_memory/cli/main.py +5 -38
  21. basic_memory/config.py +3 -3
  22. basic_memory/db.py +15 -22
  23. basic_memory/deps.py +3 -4
  24. basic_memory/file_utils.py +36 -35
  25. basic_memory/markdown/entity_parser.py +13 -30
  26. basic_memory/markdown/markdown_processor.py +7 -7
  27. basic_memory/markdown/plugins.py +109 -123
  28. basic_memory/markdown/schemas.py +7 -8
  29. basic_memory/markdown/utils.py +70 -121
  30. basic_memory/mcp/__init__.py +1 -1
  31. basic_memory/mcp/async_client.py +0 -2
  32. basic_memory/mcp/server.py +3 -27
  33. basic_memory/mcp/tools/__init__.py +5 -3
  34. basic_memory/mcp/tools/knowledge.py +2 -2
  35. basic_memory/mcp/tools/memory.py +8 -4
  36. basic_memory/mcp/tools/search.py +2 -1
  37. basic_memory/mcp/tools/utils.py +1 -1
  38. basic_memory/models/__init__.py +1 -2
  39. basic_memory/models/base.py +3 -3
  40. basic_memory/models/knowledge.py +23 -60
  41. basic_memory/models/search.py +1 -1
  42. basic_memory/repository/__init__.py +5 -3
  43. basic_memory/repository/entity_repository.py +34 -98
  44. basic_memory/repository/relation_repository.py +0 -7
  45. basic_memory/repository/repository.py +2 -39
  46. basic_memory/repository/search_repository.py +20 -25
  47. basic_memory/schemas/__init__.py +4 -4
  48. basic_memory/schemas/base.py +21 -62
  49. basic_memory/schemas/delete.py +2 -3
  50. basic_memory/schemas/discovery.py +4 -1
  51. basic_memory/schemas/memory.py +12 -13
  52. basic_memory/schemas/request.py +4 -23
  53. basic_memory/schemas/response.py +10 -9
  54. basic_memory/schemas/search.py +4 -7
  55. basic_memory/services/__init__.py +2 -7
  56. basic_memory/services/context_service.py +116 -110
  57. basic_memory/services/entity_service.py +25 -62
  58. basic_memory/services/exceptions.py +1 -0
  59. basic_memory/services/file_service.py +73 -109
  60. basic_memory/services/link_resolver.py +9 -9
  61. basic_memory/services/search_service.py +22 -15
  62. basic_memory/services/service.py +3 -24
  63. basic_memory/sync/__init__.py +2 -2
  64. basic_memory/sync/file_change_scanner.py +3 -7
  65. basic_memory/sync/sync_service.py +35 -40
  66. basic_memory/sync/utils.py +6 -38
  67. basic_memory/sync/watch_service.py +26 -5
  68. basic_memory/utils.py +42 -33
  69. {basic_memory-0.1.1.dist-info → basic_memory-0.2.0.dist-info}/METADATA +2 -7
  70. basic_memory-0.2.0.dist-info/RECORD +78 -0
  71. basic_memory/mcp/main.py +0 -21
  72. basic_memory/mcp/tools/ai_edit.py +0 -84
  73. basic_memory/services/database_service.py +0 -159
  74. basic_memory-0.1.1.dist-info/RECORD +0 -74
  75. {basic_memory-0.1.1.dist-info → basic_memory-0.2.0.dist-info}/WHEEL +0 -0
  76. {basic_memory-0.1.1.dist-info → basic_memory-0.2.0.dist-info}/entry_points.txt +0 -0
  77. {basic_memory-0.1.1.dist-info → basic_memory-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,10 @@
1
1
  """Schemas for memory context."""
2
2
 
3
3
  from datetime import datetime
4
- from typing import Dict, List, Any, Optional, Annotated
4
+ from typing import List, Optional, Annotated, Sequence
5
5
 
6
- import pydantic
7
6
  from annotated_types import MinLen, MaxLen
8
- from pydantic import BaseModel, field_validator, Field, BeforeValidator, TypeAdapter, AnyUrl
7
+ from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter
9
8
 
10
9
  from basic_memory.schemas.search import SearchItemType
11
10
 
@@ -32,19 +31,20 @@ def normalize_memory_url(url: str) -> str:
32
31
  MemoryUrl = Annotated[
33
32
  str,
34
33
  BeforeValidator(str.strip), # Clean whitespace
35
- MinLen(1),
36
- MaxLen(2028),
34
+ MinLen(1),
35
+ MaxLen(2028),
37
36
  ]
38
37
 
39
- memory_url = TypeAdapter(MemoryUrl)
38
+ memory_url = TypeAdapter(MemoryUrl)
40
39
 
41
- def memory_url_path(url: memory_url) -> str:
40
+
41
+ def memory_url_path(url: memory_url) -> str: # pyright: ignore
42
42
  """
43
43
  Returns the uri for a url value by removing the prefix "memory://" from a given MemoryUrl.
44
44
 
45
45
  This function processes a given MemoryUrl by removing the "memory://"
46
- prefix and returns the resulting string. If the provided url does not
47
- begin with "memory://", the function will simply return the input url
46
+ prefix and returns the resulting string. If the provided url does not
47
+ begin with "memory://", the function will simply return the input url
48
48
  unchanged.
49
49
 
50
50
  :param url: A MemoryUrl object representing the URL with a "memory://" prefix.
@@ -55,7 +55,6 @@ def memory_url_path(url: memory_url) -> str:
55
55
  return url.removeprefix("memory://")
56
56
 
57
57
 
58
-
59
58
  class EntitySummary(BaseModel):
60
59
  """Simplified entity representation."""
61
60
 
@@ -101,14 +100,14 @@ class GraphContext(BaseModel):
101
100
  """Complete context response."""
102
101
 
103
102
  # Direct matches
104
- primary_results: List[EntitySummary | RelationSummary | ObservationSummary] = Field(
103
+ primary_results: Sequence[EntitySummary | RelationSummary | ObservationSummary] = Field(
105
104
  description="results directly matching URI"
106
105
  )
107
106
 
108
107
  # Related entities
109
- related_results: List[EntitySummary | RelationSummary | ObservationSummary] = Field(
108
+ related_results: Sequence[EntitySummary | RelationSummary | ObservationSummary] = Field(
110
109
  description="related results"
111
110
  )
112
111
 
113
112
  # Context metadata
114
- metadata: MemoryMetadata
113
+ metadata: MemoryMetadata
@@ -1,23 +1,16 @@
1
1
  """Request schemas for interacting with the knowledge graph."""
2
2
 
3
- from typing import List, Optional, Annotated, Dict, Any
3
+ from typing import List, Optional, Annotated
4
4
  from annotated_types import MaxLen, MinLen
5
5
 
6
6
  from pydantic import BaseModel
7
7
 
8
8
  from basic_memory.schemas.base import (
9
- Observation,
10
- Entity,
11
9
  Relation,
12
- PathId,
13
- ObservationCategory,
14
- EntityType,
10
+ Permalink,
15
11
  )
16
12
 
17
13
 
18
-
19
-
20
-
21
14
  class SearchNodesRequest(BaseModel):
22
15
  """Search for entities in the knowledge graph.
23
16
 
@@ -47,7 +40,7 @@ class SearchNodesRequest(BaseModel):
47
40
  """
48
41
 
49
42
  query: Annotated[str, MinLen(1), MaxLen(200)]
50
- category: Optional[ObservationCategory] = None
43
+ category: Optional[str] = None
51
44
 
52
45
 
53
46
  class GetEntitiesRequest(BaseModel):
@@ -58,20 +51,8 @@ class GetEntitiesRequest(BaseModel):
58
51
  discovered through search.
59
52
  """
60
53
 
61
- permalinks: Annotated[List[PathId], MinLen(1)]
54
+ permalinks: Annotated[List[Permalink], MinLen(1)]
62
55
 
63
56
 
64
57
  class CreateRelationsRequest(BaseModel):
65
58
  relations: List[Relation]
66
-
67
-
68
- ## update
69
-
70
- # TODO remove UpdateEntityRequest
71
- class UpdateEntityRequest(BaseModel):
72
- """Request to update an existing entity."""
73
-
74
- title: Optional[str] = None
75
- entity_type: Optional[EntityType] = None
76
- content: Optional[str] = None
77
- entity_metadata: Optional[Dict[str, Any]] = None
@@ -15,7 +15,7 @@ from typing import List, Optional, Dict
15
15
 
16
16
  from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices
17
17
 
18
- from basic_memory.schemas.base import Relation, PathId, EntityType, ContentType, Observation
18
+ from basic_memory.schemas.base import Relation, Permalink, EntityType, ContentType, Observation
19
19
 
20
20
 
21
21
  class SQLAlchemyModel(BaseModel):
@@ -28,7 +28,7 @@ class SQLAlchemyModel(BaseModel):
28
28
 
29
29
  model_config = ConfigDict(from_attributes=True)
30
30
 
31
-
31
+
32
32
  class ObservationResponse(Observation, SQLAlchemyModel):
33
33
  """Schema for observation data returned from the service.
34
34
 
@@ -59,7 +59,7 @@ class RelationResponse(Relation, SQLAlchemyModel):
59
59
  }
60
60
  """
61
61
 
62
- from_id: PathId = Field(
62
+ from_id: Permalink = Field(
63
63
  # use the permalink from the associated Entity
64
64
  # or the from_id value
65
65
  validation_alias=AliasChoices(
@@ -67,25 +67,26 @@ class RelationResponse(Relation, SQLAlchemyModel):
67
67
  "from_id",
68
68
  )
69
69
  )
70
- to_id: Optional[PathId] = Field(
70
+ to_id: Optional[Permalink] = Field( # pyright: ignore
71
71
  # use the permalink from the associated Entity
72
72
  # or the to_id value
73
73
  validation_alias=AliasChoices(
74
74
  AliasPath("to_entity", "permalink"),
75
75
  "to_id",
76
- ), default=None
76
+ ),
77
+ default=None,
77
78
  )
78
- to_name: Optional[PathId] = Field(
79
+ to_name: Optional[Permalink] = Field(
79
80
  # use the permalink from the associated Entity
80
81
  # or the to_id value
81
82
  validation_alias=AliasChoices(
82
83
  AliasPath("to_entity", "title"),
83
84
  "to_name",
84
- ), default=None
85
+ ),
86
+ default=None,
85
87
  )
86
88
 
87
89
 
88
-
89
90
  class EntityResponse(SQLAlchemyModel):
90
91
  """Complete entity data returned from the service.
91
92
 
@@ -125,7 +126,7 @@ class EntityResponse(SQLAlchemyModel):
125
126
  }
126
127
  """
127
128
 
128
- permalink: PathId
129
+ permalink: Permalink
129
130
  title: str
130
131
  file_path: str
131
132
  entity_type: EntityType
@@ -49,8 +49,6 @@ class SearchQuery(BaseModel):
49
49
  @classmethod
50
50
  def validate_date(cls, v: Optional[Union[datetime, str]]) -> Optional[str]:
51
51
  """Convert datetime to ISO format if needed."""
52
- if v is None:
53
- return None
54
52
  if isinstance(v, datetime):
55
53
  return v.isoformat()
56
54
  return v
@@ -71,12 +69,11 @@ class SearchResult(BaseModel):
71
69
 
72
70
  id: int
73
71
  type: SearchItemType
74
- score: Optional[float] = None
75
- metadata: Optional[dict] = None
72
+ score: float
73
+ permalink: str
74
+ file_path: str
76
75
 
77
- # Common fields
78
- permalink: Optional[str] = None
79
- file_path: Optional[str] = None
76
+ metadata: Optional[dict] = None
80
77
 
81
78
  # Type-specific fields
82
79
  entity_id: Optional[int] = None # For observations
@@ -1,12 +1,7 @@
1
1
  """Services package."""
2
- from .database_service import DatabaseService
2
+
3
3
  from .service import BaseService
4
4
  from .file_service import FileService
5
5
  from .entity_service import EntityService
6
6
 
7
- __all__ = [
8
- "BaseService",
9
- "FileService",
10
- "EntityService",
11
- "DatabaseService"
12
- ]
7
+ __all__ = ["BaseService", "FileService", "EntityService"]
@@ -50,8 +50,8 @@ class ContextService:
50
50
 
51
51
  async def build_context(
52
52
  self,
53
- memory_url: MemoryUrl = None,
54
- types: List[SearchItemType] = None,
53
+ memory_url: Optional[MemoryUrl] = None,
54
+ types: Optional[List[SearchItemType]] = None,
55
55
  depth: int = 1,
56
56
  since: Optional[datetime] = None,
57
57
  max_results: int = 10,
@@ -66,9 +66,7 @@ class ContextService:
66
66
  # Pattern matching - use search
67
67
  if "*" in path:
68
68
  logger.debug(f"Pattern search for '{path}'")
69
- primary = await self.search_repository.search(
70
- permalink_match=path
71
- )
69
+ primary = await self.search_repository.search(permalink_match=path)
72
70
 
73
71
  # Direct lookup for exact path
74
72
  else:
@@ -120,11 +118,19 @@ class ContextService:
120
118
  - Connected entities
121
119
  - Their observations
122
120
  - Relations that connect them
121
+
122
+ Note on depth:
123
+ Each traversal step requires two depth levels - one to find the relation,
124
+ and another to follow that relation to an entity. So a max_depth of 4 allows
125
+ traversal through two entities (relation->entity->relation->entity), while reaching
126
+ an entity three steps away requires max_depth=6 (relation->entity->relation->entity->relation->entity).
123
127
  """
128
+ max_depth = max_depth * 2
129
+
124
130
  if not type_id_pairs:
125
131
  return []
126
132
 
127
- logger.debug(f"Finding connected items for {len(type_id_pairs)} with depth {max_depth}")
133
+ logger.debug(f"Finding connected items for {type_id_pairs} with depth {max_depth}")
128
134
 
129
135
  # Build the VALUES clause directly since SQLite doesn't handle parameterized IN well
130
136
  values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
@@ -132,7 +138,7 @@ class ContextService:
132
138
  # Parameters for bindings
133
139
  params = {"max_depth": max_depth, "max_results": max_results}
134
140
  if since:
135
- params["since_date"] = since.isoformat()
141
+ params["since_date"] = since.isoformat() # pyright: ignore
136
142
 
137
143
  # Build date filter
138
144
  date_filter = "AND base.created_at >= :since_date" if since else ""
@@ -140,113 +146,113 @@ class ContextService:
140
146
  related_date_filter = "AND e.created_at >= :since_date" if since else ""
141
147
 
142
148
  query = text(f"""
143
- WITH RECURSIVE context_graph AS (
144
- -- Base case: seed items (unchanged)
145
- SELECT
146
- id,
147
- type,
148
- title,
149
- permalink,
150
- file_path,
151
- from_id,
152
- to_id,
153
- relation_type,
154
- content,
155
- category,
156
- entity_id,
157
- 0 as depth,
158
- id as root_id,
159
- created_at,
160
- created_at as relation_date,
161
- 0 as is_incoming
162
- FROM search_index base
163
- WHERE (base.type, base.id) IN ({values})
164
- {date_filter}
149
+ WITH RECURSIVE context_graph AS (
150
+ -- Base case: seed items
151
+ SELECT
152
+ id,
153
+ type,
154
+ title,
155
+ permalink,
156
+ file_path,
157
+ from_id,
158
+ to_id,
159
+ relation_type,
160
+ content,
161
+ category,
162
+ entity_id,
163
+ 0 as depth,
164
+ id as root_id,
165
+ created_at,
166
+ created_at as relation_date,
167
+ 0 as is_incoming
168
+ FROM search_index base
169
+ WHERE (base.type, base.id) IN ({values})
170
+ {date_filter}
165
171
 
166
- UNION -- Changed from UNION ALL
172
+ UNION ALL -- Allow same paths at different depths
167
173
 
168
- -- Get relations from current entities
169
- SELECT DISTINCT
170
- r.id,
171
- r.type,
172
- r.title,
173
- r.permalink,
174
- r.file_path,
175
- r.from_id,
176
- r.to_id,
177
- r.relation_type,
178
- r.content,
179
- r.category,
180
- r.entity_id,
181
- cg.depth + 1,
182
- cg.root_id,
183
- r.created_at,
184
- r.created_at as relation_date,
185
- CASE WHEN r.from_id = cg.id THEN 0 ELSE 1 END as is_incoming
186
- FROM context_graph cg
187
- JOIN search_index r ON (
188
- cg.type = 'entity' AND
189
- r.type = 'relation' AND
190
- (r.from_id = cg.id OR r.to_id = cg.id)
191
- {r1_date_filter}
192
- )
193
- WHERE cg.depth < :max_depth
174
+ -- Get relations from current entities
175
+ SELECT DISTINCT
176
+ r.id,
177
+ r.type,
178
+ r.title,
179
+ r.permalink,
180
+ r.file_path,
181
+ r.from_id,
182
+ r.to_id,
183
+ r.relation_type,
184
+ r.content,
185
+ r.category,
186
+ r.entity_id,
187
+ cg.depth + 1,
188
+ cg.root_id,
189
+ r.created_at,
190
+ r.created_at as relation_date,
191
+ CASE WHEN r.from_id = cg.id THEN 0 ELSE 1 END as is_incoming
192
+ FROM context_graph cg
193
+ JOIN search_index r ON (
194
+ cg.type = 'entity' AND
195
+ r.type = 'relation' AND
196
+ (r.from_id = cg.id OR r.to_id = cg.id)
197
+ {r1_date_filter}
198
+ )
199
+ WHERE cg.depth < :max_depth
194
200
 
195
- UNION -- Changed from UNION ALL
201
+ UNION ALL
196
202
 
197
- -- Get entities connected by relations
198
- SELECT DISTINCT
199
- e.id,
200
- e.type,
201
- e.title,
202
- e.permalink,
203
- e.file_path,
204
- e.from_id,
205
- e.to_id,
206
- e.relation_type,
207
- e.content,
208
- e.category,
209
- e.entity_id,
210
- cg.depth,
211
- cg.root_id,
212
- e.created_at,
213
- cg.relation_date,
214
- cg.is_incoming
215
- FROM context_graph cg
216
- JOIN search_index e ON (
217
- cg.type = 'relation' AND
218
- e.type = 'entity' AND
219
- e.id = CASE
220
- WHEN cg.from_id = cg.id THEN cg.to_id
221
- ELSE cg.from_id
222
- END
223
- {related_date_filter}
224
- )
225
- WHERE cg.depth < :max_depth
226
- )
227
- SELECT DISTINCT
228
- type,
229
- id,
230
- title,
231
- permalink,
232
- file_path,
233
- from_id,
234
- to_id,
235
- relation_type,
236
- content,
237
- category,
238
- entity_id,
239
- MIN(depth) as depth,
240
- root_id,
241
- created_at
242
- FROM context_graph
243
- WHERE (type, id) NOT IN ({values})
244
- GROUP BY
245
- type, id, title, permalink, from_id, to_id,
246
- relation_type, category, entity_id,
247
- root_id, created_at
248
- ORDER BY depth, type, id
249
- LIMIT :max_results
203
+ -- Get entities connected by relations
204
+ SELECT DISTINCT
205
+ e.id,
206
+ e.type,
207
+ e.title,
208
+ e.permalink,
209
+ e.file_path,
210
+ e.from_id,
211
+ e.to_id,
212
+ e.relation_type,
213
+ e.content,
214
+ e.category,
215
+ e.entity_id,
216
+ cg.depth + 1, -- Increment depth for entities
217
+ cg.root_id,
218
+ e.created_at,
219
+ cg.relation_date,
220
+ cg.is_incoming
221
+ FROM context_graph cg
222
+ JOIN search_index e ON (
223
+ cg.type = 'relation' AND
224
+ e.type = 'entity' AND
225
+ e.id = CASE
226
+ WHEN cg.is_incoming = 0 THEN cg.to_id -- Fixed entity lookup
227
+ ELSE cg.from_id
228
+ END
229
+ {related_date_filter}
230
+ )
231
+ WHERE cg.depth < :max_depth
232
+ )
233
+ SELECT DISTINCT
234
+ type,
235
+ id,
236
+ title,
237
+ permalink,
238
+ file_path,
239
+ from_id,
240
+ to_id,
241
+ relation_type,
242
+ content,
243
+ category,
244
+ entity_id,
245
+ MIN(depth) as depth,
246
+ root_id,
247
+ created_at
248
+ FROM context_graph
249
+ WHERE (type, id) NOT IN ({values})
250
+ GROUP BY
251
+ type, id, title, permalink, from_id, to_id,
252
+ relation_type, category, entity_id,
253
+ root_id, created_at
254
+ ORDER BY depth, type, id
255
+ LIMIT :max_results
250
256
  """)
251
257
 
252
258
  result = await self.search_repository.execute_query(query, params=params)