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

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

Potentially problematic release.


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

Files changed (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -11,20 +11,48 @@ Key Concepts:
11
11
  4. Everything is stored in both SQLite and markdown files
12
12
  """
13
13
 
14
+ import os
14
15
  import mimetypes
15
16
  import re
16
- from datetime import datetime
17
+ from datetime import datetime, timedelta
17
18
  from pathlib import Path
18
19
  from typing import List, Optional, Annotated, Dict
19
20
 
20
21
  from annotated_types import MinLen, MaxLen
21
22
  from dateparser import parse
22
23
 
23
- from pydantic import BaseModel, BeforeValidator, Field, model_validator
24
+ from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field
24
25
 
26
+ from basic_memory.config import ConfigManager
27
+ from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder
25
28
  from basic_memory.utils import generate_permalink
26
29
 
27
30
 
31
+ def has_valid_file_extension(filename: str) -> bool:
32
+ """Check if a filename has a valid file extension recognized by mimetypes.
33
+
34
+ This is used to determine whether to split the extension when processing
35
+ titles in kebab_filenames mode. Prevents treating periods in version numbers
36
+ or decimals as file extensions.
37
+
38
+ Args:
39
+ filename: The filename to check
40
+
41
+ Returns:
42
+ True if the filename has a recognized file extension, False otherwise
43
+
44
+ Examples:
45
+ >>> has_valid_file_extension("document.md")
46
+ True
47
+ >>> has_valid_file_extension("Version 2.0.0")
48
+ False
49
+ >>> has_valid_file_extension("image.png")
50
+ True
51
+ """
52
+ mime_type, _ = mimetypes.guess_type(filename)
53
+ return mime_type is not None
54
+
55
+
28
56
  def to_snake_case(name: str) -> str:
29
57
  """Convert a string to snake_case.
30
58
 
@@ -46,20 +74,71 @@ def to_snake_case(name: str) -> str:
46
74
  return s2.lower()
47
75
 
48
76
 
77
+ def parse_timeframe(timeframe: str) -> datetime:
78
+ """Parse timeframe with special handling for 'today' and other natural language expressions.
79
+
80
+ Enforces a minimum 1-day lookback to handle timezone differences in distributed deployments.
81
+
82
+ Args:
83
+ timeframe: Natural language timeframe like 'today', '1d', '1 week ago', etc.
84
+
85
+ Returns:
86
+ datetime: The parsed datetime for the start of the timeframe, timezone-aware in local system timezone
87
+ Always returns at least 1 day ago to handle timezone differences.
88
+
89
+ Examples:
90
+ parse_timeframe('today') -> 2025-06-04 14:50:00-07:00 (1 day ago, not start of today)
91
+ parse_timeframe('1h') -> 2025-06-04 14:50:00-07:00 (1 day ago, not 1 hour ago)
92
+ parse_timeframe('1d') -> 2025-06-04 14:50:00-07:00 (24 hours ago with local timezone)
93
+ parse_timeframe('1 week ago') -> 2025-05-29 14:50:00-07:00 (1 week ago with local timezone)
94
+ """
95
+ if timeframe.lower() == "today":
96
+ # For "today", return 1 day ago to ensure we capture recent activity across timezones
97
+ # This handles the case where client and server are in different timezones
98
+ now = datetime.now()
99
+ one_day_ago = now - timedelta(days=1)
100
+ return one_day_ago.astimezone()
101
+ else:
102
+ # Use dateparser for other formats
103
+ parsed = parse(timeframe)
104
+ if not parsed:
105
+ raise ValueError(f"Could not parse timeframe: {timeframe}")
106
+
107
+ # If the parsed datetime is naive, make it timezone-aware in local system timezone
108
+ if parsed.tzinfo is None:
109
+ parsed = parsed.astimezone()
110
+ else:
111
+ parsed = parsed # pragma: no cover
112
+
113
+ # Enforce minimum 1-day lookback to handle timezone differences
114
+ # This ensures we don't miss recent activity due to client/server timezone mismatches
115
+ now = datetime.now().astimezone()
116
+ one_day_ago = now - timedelta(days=1)
117
+
118
+ # If the parsed time is more recent than 1 day ago, use 1 day ago instead
119
+ if parsed > one_day_ago:
120
+ return one_day_ago
121
+ else:
122
+ return parsed
123
+
124
+
49
125
  def validate_timeframe(timeframe: str) -> str:
50
126
  """Convert human readable timeframes to a duration relative to the current time."""
51
127
  if not isinstance(timeframe, str):
52
128
  raise ValueError("Timeframe must be a string")
53
129
 
54
- # Parse relative time expression
55
- parsed = parse(timeframe)
56
- if not parsed:
57
- raise ValueError(f"Could not parse timeframe: {timeframe}")
130
+ # Preserve special timeframe strings that need custom handling
131
+ special_timeframes = ["today"]
132
+ if timeframe.lower() in special_timeframes:
133
+ return timeframe.lower()
134
+
135
+ # Parse relative time expression using our enhanced parser
136
+ parsed = parse_timeframe(timeframe)
58
137
 
59
138
  # Convert to duration
60
- now = datetime.now()
139
+ now = datetime.now().astimezone()
61
140
  if parsed > now:
62
- raise ValueError("Timeframe cannot be in the future")
141
+ raise ValueError("Timeframe cannot be in the future") # pragma: no cover
63
142
 
64
143
  # Could format the duration back to our standard format
65
144
  days = (now - parsed).days
@@ -104,7 +183,7 @@ ObservationStr = Annotated[
104
183
  str,
105
184
  BeforeValidator(str.strip), # Clean whitespace
106
185
  MinLen(1), # Ensure non-empty after stripping
107
- MaxLen(1000), # Keep reasonable length
186
+ # No MaxLen - matches DB Text column which has no length restriction
108
187
  ]
109
188
 
110
189
 
@@ -143,6 +222,7 @@ class Entity(BaseModel):
143
222
  """
144
223
 
145
224
  # private field to override permalink
225
+ # Use empty string "" as sentinel to indicate permalinks are explicitly disabled
146
226
  _permalink: Optional[str] = None
147
227
 
148
228
  title: str
@@ -156,14 +236,53 @@ class Entity(BaseModel):
156
236
  default="text/markdown",
157
237
  )
158
238
 
239
+ def __init__(self, **data):
240
+ data["folder"] = sanitize_for_folder(data.get("folder", ""))
241
+ super().__init__(**data)
242
+
243
+ @property
244
+ def safe_title(self) -> str:
245
+ """
246
+ A sanitized version of the title, which is safe for use on the filesystem. For example,
247
+ a title of "Coupon Enable/Disable Feature" should create a the file as "Coupon Enable-Disable Feature.md"
248
+ instead of creating a file named "Disable Feature.md" beneath the "Coupon Enable" directory.
249
+
250
+ Replaces POSIX and/or Windows style slashes as well as a few other characters that are not safe for filenames.
251
+ If kebab_filenames is True, then behavior is consistent with transformation used when generating permalink
252
+ strings (e.g. "Coupon Enable/Disable Feature" -> "coupon-enable-disable-feature").
253
+ """
254
+ fixed_title = sanitize_for_filename(self.title)
255
+
256
+ app_config = ConfigManager().config
257
+ use_kebab_case = app_config.kebab_filenames
258
+
259
+ if use_kebab_case:
260
+ # Convert to kebab-case: lowercase with hyphens, preserving periods in version numbers
261
+ # generate_permalink() uses mimetypes to detect real file extensions and only splits
262
+ # them off, avoiding misinterpreting periods in version numbers as extensions
263
+ has_extension = has_valid_file_extension(fixed_title)
264
+ fixed_title = generate_permalink(file_path=fixed_title, split_extension=has_extension)
265
+
266
+ return fixed_title
267
+
268
+ @computed_field
159
269
  @property
160
- def file_path(self):
270
+ def file_path(self) -> str:
161
271
  """Get the file path for this entity based on its permalink."""
162
- return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
272
+ safe_title = self.safe_title
273
+ if self.content_type == "text/markdown":
274
+ return (
275
+ os.path.join(self.folder, f"{safe_title}.md") if self.folder else f"{safe_title}.md"
276
+ )
277
+ else:
278
+ return os.path.join(self.folder, safe_title) if self.folder else safe_title
163
279
 
164
280
  @property
165
- def permalink(self) -> Permalink:
281
+ def permalink(self) -> Optional[Permalink]:
166
282
  """Get a url friendly path}."""
283
+ # Empty string is a sentinel value indicating permalinks are disabled
284
+ if self._permalink == "":
285
+ return None
167
286
  return self._permalink or generate_permalink(self.file_path)
168
287
 
169
288
  @model_validator(mode="after")
@@ -0,0 +1,50 @@
1
+ """Schemas for cloud-related API responses."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class TenantMountInfo(BaseModel):
7
+ """Response from /tenant/mount/info endpoint."""
8
+
9
+ tenant_id: str = Field(..., description="Unique identifier for the tenant")
10
+ bucket_name: str = Field(..., description="S3 bucket name for the tenant")
11
+
12
+
13
+ class MountCredentials(BaseModel):
14
+ """Response from /tenant/mount/credentials endpoint."""
15
+
16
+ access_key: str = Field(..., description="S3 access key for mount")
17
+ secret_key: str = Field(..., description="S3 secret key for mount")
18
+
19
+
20
+ class CloudProject(BaseModel):
21
+ """Representation of a cloud project."""
22
+
23
+ name: str = Field(..., description="Project name")
24
+ path: str = Field(..., description="Project path on cloud")
25
+
26
+
27
+ class CloudProjectList(BaseModel):
28
+ """Response from /proxy/projects/projects endpoint."""
29
+
30
+ projects: list[CloudProject] = Field(default_factory=list, description="List of cloud projects")
31
+
32
+
33
+ class CloudProjectCreateRequest(BaseModel):
34
+ """Request to create a new cloud project."""
35
+
36
+ name: str = Field(..., description="Project name")
37
+ path: str = Field(..., description="Project path (permalink)")
38
+ set_default: bool = Field(default=False, description="Set as default project")
39
+
40
+
41
+ class CloudProjectCreateResponse(BaseModel):
42
+ """Response from creating a cloud project."""
43
+
44
+ message: str = Field(..., description="Status message about the project creation")
45
+ status: str = Field(..., description="Status of the creation (success or error)")
46
+ default: bool = Field(..., description="True if the project was set as the default")
47
+ old_project: dict | None = Field(None, description="Information about the previous project")
48
+ new_project: dict | None = Field(
49
+ None, description="Information about the newly created project"
50
+ )
@@ -0,0 +1,31 @@
1
+ """Schemas for directory tree operations."""
2
+
3
+ from datetime import datetime
4
+ from typing import List, Optional, Literal
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class DirectoryNode(BaseModel):
10
+ """Directory node in file system."""
11
+
12
+ name: str
13
+ file_path: Optional[str] = None # Original path without leading slash (matches DB)
14
+ directory_path: str # Path with leading slash for directory navigation
15
+ type: Literal["directory", "file"]
16
+ children: List["DirectoryNode"] = [] # Default to empty list
17
+ title: Optional[str] = None
18
+ permalink: Optional[str] = None
19
+ external_id: Optional[str] = None # UUID (primary API identifier for v2)
20
+ entity_id: Optional[int] = None # Internal numeric ID
21
+ entity_type: Optional[str] = None
22
+ content_type: Optional[str] = None
23
+ updated_at: Optional[datetime] = None
24
+
25
+ @property
26
+ def has_children(self) -> bool:
27
+ return bool(self.children)
28
+
29
+
30
+ # Support for recursive model
31
+ DirectoryNode.model_rebuild()
@@ -0,0 +1,35 @@
1
+ """Schemas for import services."""
2
+
3
+ from typing import Dict, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class ImportResult(BaseModel):
9
+ """Common import result schema."""
10
+
11
+ import_count: Dict[str, int]
12
+ success: bool
13
+ error_message: Optional[str] = None
14
+
15
+
16
+ class ChatImportResult(ImportResult):
17
+ """Result schema for chat imports."""
18
+
19
+ conversations: int = 0
20
+ messages: int = 0
21
+
22
+
23
+ class ProjectImportResult(ImportResult):
24
+ """Result schema for project imports."""
25
+
26
+ documents: int = 0
27
+ prompts: int = 0
28
+
29
+
30
+ class EntityImportResult(ImportResult):
31
+ """Result schema for entity imports."""
32
+
33
+ entities: int = 0
34
+ relations: int = 0
35
+ skipped_entities: int = 0
@@ -1,16 +1,53 @@
1
1
  """Schemas for memory context."""
2
2
 
3
3
  from datetime import datetime
4
- from typing import List, Optional, Annotated, Sequence
4
+ from typing import List, Optional, Annotated, Sequence, Literal, Union, Dict
5
5
 
6
6
  from annotated_types import MinLen, MaxLen
7
- from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter
7
+ from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter, field_serializer
8
8
 
9
9
  from basic_memory.schemas.search import SearchItemType
10
10
 
11
11
 
12
- def normalize_memory_url(url: str) -> str:
13
- """Normalize a MemoryUrl string.
12
+ def validate_memory_url_path(path: str) -> bool:
13
+ """Validate that a memory URL path is well-formed.
14
+
15
+ Args:
16
+ path: The path part of a memory URL (without memory:// prefix)
17
+
18
+ Returns:
19
+ True if the path is valid, False otherwise
20
+
21
+ Examples:
22
+ >>> validate_memory_url_path("specs/search")
23
+ True
24
+ >>> validate_memory_url_path("memory//test") # Double slash
25
+ False
26
+ >>> validate_memory_url_path("invalid://test") # Contains protocol
27
+ False
28
+ """
29
+ # Empty paths are not valid
30
+ if not path or not path.strip():
31
+ return False
32
+
33
+ # Check for invalid protocol schemes within the path first (more specific)
34
+ if "://" in path:
35
+ return False
36
+
37
+ # Check for double slashes (except at the beginning for absolute paths)
38
+ if "//" in path:
39
+ return False
40
+
41
+ # Check for invalid characters (excluding * which is used for pattern matching)
42
+ invalid_chars = {"<", ">", '"', "|", "?"}
43
+ if any(char in path for char in invalid_chars):
44
+ return False
45
+
46
+ return True
47
+
48
+
49
+ def normalize_memory_url(url: str | None) -> str:
50
+ """Normalize a MemoryUrl string with validation.
14
51
 
15
52
  Args:
16
53
  url: A path like "specs/search" or "memory://specs/search"
@@ -18,19 +55,47 @@ def normalize_memory_url(url: str) -> str:
18
55
  Returns:
19
56
  Normalized URL starting with memory://
20
57
 
58
+ Raises:
59
+ ValueError: If the URL path is malformed
60
+
21
61
  Examples:
22
62
  >>> normalize_memory_url("specs/search")
23
63
  'memory://specs/search'
24
64
  >>> normalize_memory_url("memory://specs/search")
25
65
  'memory://specs/search'
66
+ >>> normalize_memory_url("memory//test")
67
+ Traceback (most recent call last):
68
+ ...
69
+ ValueError: Invalid memory URL path: 'memory//test' contains double slashes
26
70
  """
71
+ if not url:
72
+ raise ValueError("Memory URL cannot be empty")
73
+
74
+ # Strip whitespace for consistency
75
+ url = url.strip()
76
+
77
+ if not url:
78
+ raise ValueError("Memory URL cannot be empty or whitespace")
79
+
27
80
  clean_path = url.removeprefix("memory://")
81
+
82
+ # Validate the extracted path
83
+ if not validate_memory_url_path(clean_path):
84
+ # Provide specific error messages for common issues
85
+ if "://" in clean_path:
86
+ raise ValueError(f"Invalid memory URL path: '{clean_path}' contains protocol scheme")
87
+ elif "//" in clean_path:
88
+ raise ValueError(f"Invalid memory URL path: '{clean_path}' contains double slashes")
89
+ else:
90
+ raise ValueError(f"Invalid memory URL path: '{clean_path}' contains invalid characters")
91
+
28
92
  return f"memory://{clean_path}"
29
93
 
30
94
 
31
95
  MemoryUrl = Annotated[
32
96
  str,
33
97
  BeforeValidator(str.strip), # Clean whitespace
98
+ BeforeValidator(normalize_memory_url), # Validate and normalize the URL
34
99
  MinLen(1),
35
100
  MaxLen(2028),
36
101
  ]
@@ -58,30 +123,62 @@ def memory_url_path(url: memory_url) -> str: # pyright: ignore
58
123
  class EntitySummary(BaseModel):
59
124
  """Simplified entity representation."""
60
125
 
61
- type: str = "entity"
62
- permalink: str
126
+ type: Literal["entity"] = "entity"
127
+ entity_id: int # Database ID for v2 API consistency
128
+ permalink: Optional[str]
63
129
  title: str
130
+ content: Optional[str] = None
64
131
  file_path: str
65
- created_at: datetime
132
+ created_at: Annotated[
133
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
134
+ ]
135
+
136
+ @field_serializer("created_at")
137
+ def serialize_created_at(self, dt: datetime) -> str:
138
+ return dt.isoformat()
66
139
 
67
140
 
68
141
  class RelationSummary(BaseModel):
69
142
  """Simplified relation representation."""
70
143
 
71
- type: str = "relation"
144
+ type: Literal["relation"] = "relation"
145
+ relation_id: int # Database ID for v2 API consistency
146
+ entity_id: Optional[int] = None # ID of the entity this relation belongs to
147
+ title: str
148
+ file_path: str
72
149
  permalink: str
73
150
  relation_type: str
74
- from_id: str
75
- to_id: Optional[str] = None
151
+ from_entity: Optional[str] = None
152
+ from_entity_id: Optional[int] = None # ID of source entity
153
+ to_entity: Optional[str] = None
154
+ to_entity_id: Optional[int] = None # ID of target entity
155
+ created_at: Annotated[
156
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
157
+ ]
158
+
159
+ @field_serializer("created_at")
160
+ def serialize_created_at(self, dt: datetime) -> str:
161
+ return dt.isoformat()
76
162
 
77
163
 
78
164
  class ObservationSummary(BaseModel):
79
165
  """Simplified observation representation."""
80
166
 
81
- type: str = "observation"
167
+ type: Literal["observation"] = "observation"
168
+ observation_id: int # Database ID for v2 API consistency
169
+ entity_id: Optional[int] = None # ID of the entity this observation belongs to
170
+ title: str
171
+ file_path: str
82
172
  permalink: str
83
173
  category: str
84
174
  content: str
175
+ created_at: Annotated[
176
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
177
+ ]
178
+
179
+ @field_serializer("created_at")
180
+ def serialize_created_at(self, dt: datetime) -> str:
181
+ return dt.isoformat()
85
182
 
86
183
 
87
184
  class MemoryMetadata(BaseModel):
@@ -90,27 +187,99 @@ class MemoryMetadata(BaseModel):
90
187
  uri: Optional[str] = None
91
188
  types: Optional[List[SearchItemType]] = None
92
189
  depth: int
93
- timeframe: str
94
- generated_at: datetime
95
- total_results: int
96
- total_relations: int
190
+ timeframe: Optional[str] = None
191
+ generated_at: Annotated[
192
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
193
+ ]
194
+ primary_count: Optional[int] = None # Changed field name
195
+ related_count: Optional[int] = None # Changed field name
196
+ total_results: Optional[int] = None # For backward compatibility
197
+ total_relations: Optional[int] = None
198
+ total_observations: Optional[int] = None
199
+
200
+ @field_serializer("generated_at")
201
+ def serialize_generated_at(self, dt: datetime) -> str:
202
+ return dt.isoformat()
203
+
204
+
205
+ class ContextResult(BaseModel):
206
+ """Context result containing a primary item with its observations and related items."""
207
+
208
+ primary_result: Annotated[
209
+ Union[EntitySummary, RelationSummary, ObservationSummary],
210
+ Field(discriminator="type", description="Primary item"),
211
+ ]
212
+
213
+ observations: Sequence[ObservationSummary] = Field(
214
+ description="Observations belonging to this entity", default_factory=list
215
+ )
216
+
217
+ related_results: Sequence[
218
+ Annotated[
219
+ Union[EntitySummary, RelationSummary, ObservationSummary], Field(discriminator="type")
220
+ ]
221
+ ] = Field(description="Related items", default_factory=list)
97
222
 
98
223
 
99
224
  class GraphContext(BaseModel):
100
225
  """Complete context response."""
101
226
 
102
- # Direct matches
103
- primary_results: Sequence[EntitySummary | RelationSummary | ObservationSummary] = Field(
104
- description="results directly matching URI"
105
- )
106
-
107
- # Related entities
108
- related_results: Sequence[EntitySummary | RelationSummary | ObservationSummary] = Field(
109
- description="related results"
227
+ # hierarchical results
228
+ results: Sequence[ContextResult] = Field(
229
+ description="Hierarchical results with related items nested", default_factory=list
110
230
  )
111
231
 
112
232
  # Context metadata
113
233
  metadata: MemoryMetadata
114
234
 
115
- page: int = 1
116
- page_size: int = 1
235
+ page: Optional[int] = None
236
+ page_size: Optional[int] = None
237
+
238
+
239
+ class ActivityStats(BaseModel):
240
+ """Statistics about activity across all projects."""
241
+
242
+ total_projects: int
243
+ active_projects: int = Field(description="Projects with activity in timeframe")
244
+ most_active_project: Optional[str] = None
245
+ total_items: int = Field(description="Total items across all projects")
246
+ total_entities: int = 0
247
+ total_relations: int = 0
248
+ total_observations: int = 0
249
+
250
+
251
+ class ProjectActivity(BaseModel):
252
+ """Activity summary for a single project."""
253
+
254
+ project_name: str
255
+ project_path: str
256
+ activity: GraphContext = Field(description="The actual activity data for this project")
257
+ item_count: int = Field(description="Total items in this project's activity")
258
+ last_activity: Optional[
259
+ Annotated[datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})]
260
+ ] = Field(default=None, description="Most recent activity timestamp")
261
+ active_folders: List[str] = Field(default_factory=list, description="Most active folders")
262
+
263
+ @field_serializer("last_activity")
264
+ def serialize_last_activity(self, dt: Optional[datetime]) -> Optional[str]:
265
+ return dt.isoformat() if dt else None # pragma: no cover
266
+
267
+
268
+ class ProjectActivitySummary(BaseModel):
269
+ """Summary of activity across all projects."""
270
+
271
+ projects: Dict[str, ProjectActivity] = Field(
272
+ description="Activity per project, keyed by project name"
273
+ )
274
+ summary: ActivityStats
275
+ timeframe: str = Field(description="The timeframe used for the query")
276
+ generated_at: Annotated[
277
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
278
+ ]
279
+ guidance: Optional[str] = Field(
280
+ default=None, description="Assistant guidance for project selection and session management"
281
+ )
282
+
283
+ @field_serializer("generated_at")
284
+ def serialize_generated_at(self, dt: datetime) -> str:
285
+ return dt.isoformat() # pragma: no cover