basic-memory 0.2.12__py3-none-any.whl → 0.16.1__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 (149) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +63 -31
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +165 -28
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +28 -67
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +219 -14
  23. basic_memory/api/routers/search_router.py +21 -13
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +52 -1
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +26 -7
  41. basic_memory/cli/commands/import_chatgpt.py +83 -0
  42. basic_memory/cli/commands/import_claude_conversations.py +86 -0
  43. basic_memory/cli/commands/import_claude_projects.py +85 -0
  44. basic_memory/cli/commands/import_memory_json.py +35 -92
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +47 -30
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +13 -6
  50. basic_memory/config.py +481 -22
  51. basic_memory/db.py +192 -32
  52. basic_memory/deps.py +252 -22
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -14
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +437 -59
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +188 -23
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +57 -3
  115. basic_memory/schemas/response.py +9 -1
  116. basic_memory/schemas/search.py +33 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +251 -106
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +595 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +50 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +172 -34
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1176 -96
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +388 -28
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -203
  140. basic_memory/mcp/tools/knowledge.py +0 -56
  141. basic_memory/mcp/tools/memory.py +0 -151
  142. basic_memory/mcp/tools/notes.py +0 -122
  143. basic_memory/schemas/discovery.py +0 -28
  144. basic_memory/sync/file_change_scanner.py +0 -158
  145. basic_memory/sync/utils.py +0 -34
  146. basic_memory-0.2.12.dist-info/METADATA +0 -291
  147. basic_memory-0.2.12.dist-info/RECORD +0 -78
  148. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  149. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -37,11 +37,19 @@ from basic_memory.schemas.response import (
37
37
  DeleteEntitiesResponse,
38
38
  )
39
39
 
40
- # Discovery and analytics models
41
- from basic_memory.schemas.discovery import (
42
- EntityTypeList,
43
- ObservationCategoryList,
44
- TypedEntityList,
40
+ from basic_memory.schemas.project_info import (
41
+ ProjectStatistics,
42
+ ActivityMetrics,
43
+ SystemStatus,
44
+ ProjectInfoResponse,
45
+ )
46
+
47
+ from basic_memory.schemas.directory import (
48
+ DirectoryNode,
49
+ )
50
+
51
+ from basic_memory.schemas.sync_report import (
52
+ SyncReportResponse,
45
53
  )
46
54
 
47
55
  # For convenient imports, export all models
@@ -66,8 +74,13 @@ __all__ = [
66
74
  "DeleteEntitiesResponse",
67
75
  # Delete Operations
68
76
  "DeleteEntitiesRequest",
69
- # Discovery and Analytics
70
- "EntityTypeList",
71
- "ObservationCategoryList",
72
- "TypedEntityList",
77
+ # Project Info
78
+ "ProjectStatistics",
79
+ "ActivityMetrics",
80
+ "SystemStatus",
81
+ "ProjectInfoResponse",
82
+ # Directory
83
+ "DirectoryNode",
84
+ # Sync
85
+ "SyncReportResponse",
73
86
  ]
@@ -11,9 +11,10 @@ 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
 
@@ -22,6 +23,8 @@ from dateparser import parse
22
23
 
23
24
  from pydantic import BaseModel, BeforeValidator, Field, model_validator
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
 
@@ -46,18 +49,69 @@ def to_snake_case(name: str) -> str:
46
49
  return s2.lower()
47
50
 
48
51
 
52
+ def parse_timeframe(timeframe: str) -> datetime:
53
+ """Parse timeframe with special handling for 'today' and other natural language expressions.
54
+
55
+ Enforces a minimum 1-day lookback to handle timezone differences in distributed deployments.
56
+
57
+ Args:
58
+ timeframe: Natural language timeframe like 'today', '1d', '1 week ago', etc.
59
+
60
+ Returns:
61
+ datetime: The parsed datetime for the start of the timeframe, timezone-aware in local system timezone
62
+ Always returns at least 1 day ago to handle timezone differences.
63
+
64
+ Examples:
65
+ parse_timeframe('today') -> 2025-06-04 14:50:00-07:00 (1 day ago, not start of today)
66
+ parse_timeframe('1h') -> 2025-06-04 14:50:00-07:00 (1 day ago, not 1 hour ago)
67
+ parse_timeframe('1d') -> 2025-06-04 14:50:00-07:00 (24 hours ago with local timezone)
68
+ parse_timeframe('1 week ago') -> 2025-05-29 14:50:00-07:00 (1 week ago with local timezone)
69
+ """
70
+ if timeframe.lower() == "today":
71
+ # For "today", return 1 day ago to ensure we capture recent activity across timezones
72
+ # This handles the case where client and server are in different timezones
73
+ now = datetime.now()
74
+ one_day_ago = now - timedelta(days=1)
75
+ return one_day_ago.astimezone()
76
+ else:
77
+ # Use dateparser for other formats
78
+ parsed = parse(timeframe)
79
+ if not parsed:
80
+ raise ValueError(f"Could not parse timeframe: {timeframe}")
81
+
82
+ # If the parsed datetime is naive, make it timezone-aware in local system timezone
83
+ if parsed.tzinfo is None:
84
+ parsed = parsed.astimezone()
85
+ else:
86
+ parsed = parsed
87
+
88
+ # Enforce minimum 1-day lookback to handle timezone differences
89
+ # This ensures we don't miss recent activity due to client/server timezone mismatches
90
+ now = datetime.now().astimezone()
91
+ one_day_ago = now - timedelta(days=1)
92
+
93
+ # If the parsed time is more recent than 1 day ago, use 1 day ago instead
94
+ if parsed > one_day_ago:
95
+ return one_day_ago
96
+ else:
97
+ return parsed
98
+
99
+
49
100
  def validate_timeframe(timeframe: str) -> str:
50
101
  """Convert human readable timeframes to a duration relative to the current time."""
51
102
  if not isinstance(timeframe, str):
52
103
  raise ValueError("Timeframe must be a string")
53
104
 
54
- # Parse relative time expression
55
- parsed = parse(timeframe)
56
- if not parsed:
57
- raise ValueError(f"Could not parse timeframe: {timeframe}")
105
+ # Preserve special timeframe strings that need custom handling
106
+ special_timeframes = ["today"]
107
+ if timeframe.lower() in special_timeframes:
108
+ return timeframe.lower()
109
+
110
+ # Parse relative time expression using our enhanced parser
111
+ parsed = parse_timeframe(timeframe)
58
112
 
59
113
  # Convert to duration
60
- now = datetime.now()
114
+ now = datetime.now().astimezone()
61
115
  if parsed > now:
62
116
  raise ValueError("Timeframe cannot be in the future")
63
117
 
@@ -143,6 +197,7 @@ class Entity(BaseModel):
143
197
  """
144
198
 
145
199
  # private field to override permalink
200
+ # Use empty string "" as sentinel to indicate permalinks are explicitly disabled
146
201
  _permalink: Optional[str] = None
147
202
 
148
203
  title: str
@@ -156,14 +211,48 @@ class Entity(BaseModel):
156
211
  default="text/markdown",
157
212
  )
158
213
 
214
+ def __init__(self, **data):
215
+ data["folder"] = sanitize_for_folder(data.get("folder", ""))
216
+ super().__init__(**data)
217
+
218
+ @property
219
+ def safe_title(self) -> str:
220
+ """
221
+ A sanitized version of the title, which is safe for use on the filesystem. For example,
222
+ a title of "Coupon Enable/Disable Feature" should create a the file as "Coupon Enable-Disable Feature.md"
223
+ instead of creating a file named "Disable Feature.md" beneath the "Coupon Enable" directory.
224
+
225
+ Replaces POSIX and/or Windows style slashes as well as a few other characters that are not safe for filenames.
226
+ If kebab_filenames is True, then behavior is consistent with transformation used when generating permalink
227
+ strings (e.g. "Coupon Enable/Disable Feature" -> "coupon-enable-disable-feature").
228
+ """
229
+ fixed_title = sanitize_for_filename(self.title)
230
+
231
+ app_config = ConfigManager().config
232
+ use_kebab_case = app_config.kebab_filenames
233
+
234
+ if use_kebab_case:
235
+ fixed_title = generate_permalink(file_path=fixed_title, split_extension=False)
236
+
237
+ return fixed_title
238
+
159
239
  @property
160
240
  def file_path(self):
161
241
  """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"
242
+ safe_title = self.safe_title
243
+ if self.content_type == "text/markdown":
244
+ return (
245
+ os.path.join(self.folder, f"{safe_title}.md") if self.folder else f"{safe_title}.md"
246
+ )
247
+ else:
248
+ return os.path.join(self.folder, safe_title) if self.folder else safe_title
163
249
 
164
250
  @property
165
- def permalink(self) -> Permalink:
251
+ def permalink(self) -> Optional[Permalink]:
166
252
  """Get a url friendly path}."""
253
+ # Empty string is a sentinel value indicating permalinks are disabled
254
+ if self._permalink == "":
255
+ return None
167
256
  return self._permalink or generate_permalink(self.file_path)
168
257
 
169
258
  @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,30 @@
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
+ entity_id: Optional[int] = None
20
+ entity_type: Optional[str] = None
21
+ content_type: Optional[str] = None
22
+ updated_at: Optional[datetime] = None
23
+
24
+ @property
25
+ def has_children(self) -> bool:
26
+ return bool(self.children)
27
+
28
+
29
+ # Support for recursive model
30
+ 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,55 @@ 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
+ permalink: Optional[str]
63
128
  title: str
129
+ content: Optional[str] = None
64
130
  file_path: str
65
- created_at: datetime
131
+ created_at: Annotated[
132
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
133
+ ]
134
+
135
+ @field_serializer("created_at")
136
+ def serialize_created_at(self, dt: datetime) -> str:
137
+ return dt.isoformat()
66
138
 
67
139
 
68
140
  class RelationSummary(BaseModel):
69
141
  """Simplified relation representation."""
70
142
 
71
- type: str = "relation"
143
+ type: Literal["relation"] = "relation"
144
+ title: str
145
+ file_path: str
72
146
  permalink: str
73
147
  relation_type: str
74
- from_id: str
75
- to_id: Optional[str] = None
148
+ from_entity: Optional[str] = None
149
+ to_entity: Optional[str] = None
150
+ created_at: Annotated[
151
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
152
+ ]
153
+
154
+ @field_serializer("created_at")
155
+ def serialize_created_at(self, dt: datetime) -> str:
156
+ return dt.isoformat()
76
157
 
77
158
 
78
159
  class ObservationSummary(BaseModel):
79
160
  """Simplified observation representation."""
80
161
 
81
- type: str = "observation"
162
+ type: Literal["observation"] = "observation"
163
+ title: str
164
+ file_path: str
82
165
  permalink: str
83
166
  category: str
84
167
  content: str
168
+ created_at: Annotated[
169
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
170
+ ]
171
+
172
+ @field_serializer("created_at")
173
+ def serialize_created_at(self, dt: datetime) -> str:
174
+ return dt.isoformat()
85
175
 
86
176
 
87
177
  class MemoryMetadata(BaseModel):
@@ -90,24 +180,99 @@ class MemoryMetadata(BaseModel):
90
180
  uri: Optional[str] = None
91
181
  types: Optional[List[SearchItemType]] = None
92
182
  depth: int
93
- timeframe: str
94
- generated_at: datetime
95
- total_results: int
96
- total_relations: int
183
+ timeframe: Optional[str] = None
184
+ generated_at: Annotated[
185
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
186
+ ]
187
+ primary_count: Optional[int] = None # Changed field name
188
+ related_count: Optional[int] = None # Changed field name
189
+ total_results: Optional[int] = None # For backward compatibility
190
+ total_relations: Optional[int] = None
191
+ total_observations: Optional[int] = None
192
+
193
+ @field_serializer("generated_at")
194
+ def serialize_generated_at(self, dt: datetime) -> str:
195
+ return dt.isoformat()
196
+
197
+
198
+ class ContextResult(BaseModel):
199
+ """Context result containing a primary item with its observations and related items."""
200
+
201
+ primary_result: Annotated[
202
+ Union[EntitySummary, RelationSummary, ObservationSummary],
203
+ Field(discriminator="type", description="Primary item"),
204
+ ]
205
+
206
+ observations: Sequence[ObservationSummary] = Field(
207
+ description="Observations belonging to this entity", default_factory=list
208
+ )
209
+
210
+ related_results: Sequence[
211
+ Annotated[
212
+ Union[EntitySummary, RelationSummary, ObservationSummary], Field(discriminator="type")
213
+ ]
214
+ ] = Field(description="Related items", default_factory=list)
97
215
 
98
216
 
99
217
  class GraphContext(BaseModel):
100
218
  """Complete context response."""
101
219
 
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"
220
+ # hierarchical results
221
+ results: Sequence[ContextResult] = Field(
222
+ description="Hierarchical results with related items nested", default_factory=list
110
223
  )
111
224
 
112
225
  # Context metadata
113
226
  metadata: MemoryMetadata
227
+
228
+ page: Optional[int] = None
229
+ page_size: Optional[int] = None
230
+
231
+
232
+ class ActivityStats(BaseModel):
233
+ """Statistics about activity across all projects."""
234
+
235
+ total_projects: int
236
+ active_projects: int = Field(description="Projects with activity in timeframe")
237
+ most_active_project: Optional[str] = None
238
+ total_items: int = Field(description="Total items across all projects")
239
+ total_entities: int = 0
240
+ total_relations: int = 0
241
+ total_observations: int = 0
242
+
243
+
244
+ class ProjectActivity(BaseModel):
245
+ """Activity summary for a single project."""
246
+
247
+ project_name: str
248
+ project_path: str
249
+ activity: GraphContext = Field(description="The actual activity data for this project")
250
+ item_count: int = Field(description="Total items in this project's activity")
251
+ last_activity: Optional[
252
+ Annotated[datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})]
253
+ ] = Field(default=None, description="Most recent activity timestamp")
254
+ active_folders: List[str] = Field(default_factory=list, description="Most active folders")
255
+
256
+ @field_serializer("last_activity")
257
+ def serialize_last_activity(self, dt: Optional[datetime]) -> Optional[str]:
258
+ return dt.isoformat() if dt else None
259
+
260
+
261
+ class ProjectActivitySummary(BaseModel):
262
+ """Summary of activity across all projects."""
263
+
264
+ projects: Dict[str, ProjectActivity] = Field(
265
+ description="Activity per project, keyed by project name"
266
+ )
267
+ summary: ActivityStats
268
+ timeframe: str = Field(description="The timeframe used for the query")
269
+ generated_at: Annotated[
270
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
271
+ ]
272
+ guidance: Optional[str] = Field(
273
+ default=None, description="Assistant guidance for project selection and session management"
274
+ )
275
+
276
+ @field_serializer("generated_at")
277
+ def serialize_generated_at(self, dt: datetime) -> str:
278
+ return dt.isoformat()