basic-memory 0.14.4__py3-none-any.whl → 0.15.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 (82) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/knowledge_router.py +25 -8
  5. basic_memory/api/routers/project_router.py +99 -4
  6. basic_memory/cli/app.py +9 -28
  7. basic_memory/cli/auth.py +277 -0
  8. basic_memory/cli/commands/cloud/__init__.py +5 -0
  9. basic_memory/cli/commands/cloud/api_client.py +112 -0
  10. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  11. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  12. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  13. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  14. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  15. basic_memory/cli/commands/command_utils.py +60 -0
  16. basic_memory/cli/commands/import_memory_json.py +0 -4
  17. basic_memory/cli/commands/mcp.py +16 -4
  18. basic_memory/cli/commands/project.py +139 -142
  19. basic_memory/cli/commands/status.py +34 -22
  20. basic_memory/cli/commands/sync.py +45 -228
  21. basic_memory/cli/commands/tool.py +87 -16
  22. basic_memory/cli/main.py +1 -0
  23. basic_memory/config.py +76 -12
  24. basic_memory/db.py +104 -3
  25. basic_memory/deps.py +20 -3
  26. basic_memory/file_utils.py +37 -13
  27. basic_memory/ignore_utils.py +295 -0
  28. basic_memory/markdown/plugins.py +9 -7
  29. basic_memory/mcp/async_client.py +22 -10
  30. basic_memory/mcp/project_context.py +141 -0
  31. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  32. basic_memory/mcp/prompts/continue_conversation.py +1 -1
  33. basic_memory/mcp/prompts/recent_activity.py +116 -32
  34. basic_memory/mcp/prompts/search.py +1 -1
  35. basic_memory/mcp/prompts/utils.py +11 -4
  36. basic_memory/mcp/resources/ai_assistant_guide.md +179 -41
  37. basic_memory/mcp/resources/project_info.py +20 -6
  38. basic_memory/mcp/server.py +0 -37
  39. basic_memory/mcp/tools/__init__.py +5 -6
  40. basic_memory/mcp/tools/build_context.py +29 -19
  41. basic_memory/mcp/tools/canvas.py +19 -8
  42. basic_memory/mcp/tools/chatgpt_tools.py +178 -0
  43. basic_memory/mcp/tools/delete_note.py +67 -34
  44. basic_memory/mcp/tools/edit_note.py +55 -39
  45. basic_memory/mcp/tools/headers.py +44 -0
  46. basic_memory/mcp/tools/list_directory.py +18 -8
  47. basic_memory/mcp/tools/move_note.py +119 -41
  48. basic_memory/mcp/tools/project_management.py +61 -228
  49. basic_memory/mcp/tools/read_content.py +28 -12
  50. basic_memory/mcp/tools/read_note.py +83 -46
  51. basic_memory/mcp/tools/recent_activity.py +441 -42
  52. basic_memory/mcp/tools/search.py +82 -70
  53. basic_memory/mcp/tools/sync_status.py +5 -4
  54. basic_memory/mcp/tools/utils.py +19 -0
  55. basic_memory/mcp/tools/view_note.py +31 -6
  56. basic_memory/mcp/tools/write_note.py +65 -14
  57. basic_memory/models/knowledge.py +12 -6
  58. basic_memory/models/project.py +6 -2
  59. basic_memory/repository/entity_repository.py +29 -82
  60. basic_memory/repository/relation_repository.py +13 -0
  61. basic_memory/repository/repository.py +2 -2
  62. basic_memory/repository/search_repository.py +4 -2
  63. basic_memory/schemas/__init__.py +6 -0
  64. basic_memory/schemas/base.py +39 -11
  65. basic_memory/schemas/cloud.py +46 -0
  66. basic_memory/schemas/memory.py +90 -21
  67. basic_memory/schemas/project_info.py +9 -10
  68. basic_memory/schemas/sync_report.py +48 -0
  69. basic_memory/services/context_service.py +25 -11
  70. basic_memory/services/entity_service.py +75 -45
  71. basic_memory/services/initialization.py +30 -11
  72. basic_memory/services/project_service.py +13 -23
  73. basic_memory/sync/sync_service.py +145 -21
  74. basic_memory/sync/watch_service.py +101 -40
  75. basic_memory/utils.py +14 -4
  76. {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/METADATA +7 -6
  77. basic_memory-0.15.0.dist-info/RECORD +147 -0
  78. basic_memory/mcp/project_session.py +0 -120
  79. basic_memory-0.14.4.dist-info/RECORD +0 -133
  80. {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/WHEEL +0 -0
  81. {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/entry_points.txt +0 -0
  82. {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -74,8 +74,14 @@ class Entity(Base):
74
74
  checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
75
75
 
76
76
  # Metadata and tracking
77
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone())
78
- updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now().astimezone(), onupdate=lambda: datetime.now().astimezone())
77
+ created_at: Mapped[datetime] = mapped_column(
78
+ DateTime(timezone=True), default=lambda: datetime.now().astimezone()
79
+ )
80
+ updated_at: Mapped[datetime] = mapped_column(
81
+ DateTime(timezone=True),
82
+ default=lambda: datetime.now().astimezone(),
83
+ onupdate=lambda: datetime.now().astimezone(),
84
+ )
79
85
 
80
86
  # Relationships
81
87
  project = relationship("Project", back_populates="entities")
@@ -104,15 +110,15 @@ class Entity(Base):
104
110
  def is_markdown(self):
105
111
  """Check if the entity is a markdown file."""
106
112
  return self.content_type == "text/markdown"
107
-
113
+
108
114
  def __getattribute__(self, name):
109
115
  """Override attribute access to ensure datetime fields are timezone-aware."""
110
116
  value = super().__getattribute__(name)
111
-
117
+
112
118
  # Ensure datetime fields are timezone-aware
113
- if name in ('created_at', 'updated_at') and isinstance(value, datetime):
119
+ if name in ("created_at", "updated_at") and isinstance(value, datetime):
114
120
  return ensure_timezone_aware(value)
115
-
121
+
116
122
  return value
117
123
 
118
124
  def __repr__(self) -> str:
@@ -52,9 +52,13 @@ class Project(Base):
52
52
  is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
53
53
 
54
54
  # Timestamps
55
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
55
+ created_at: Mapped[datetime] = mapped_column(
56
+ DateTime(timezone=True), default=lambda: datetime.now(UTC)
57
+ )
56
58
  updated_at: Mapped[datetime] = mapped_column(
57
- DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
59
+ DateTime(timezone=True),
60
+ default=lambda: datetime.now(UTC),
61
+ onupdate=lambda: datetime.now(UTC),
58
62
  )
59
63
 
60
64
  # Define relationships to entities, observations, and relations
@@ -101,11 +101,10 @@ class EntityRepository(Repository[Entity]):
101
101
  return list(result.scalars().all())
102
102
 
103
103
  async def upsert_entity(self, entity: Entity) -> Entity:
104
- """Insert or update entity using a hybrid approach.
104
+ """Insert or update entity using simple try/catch with database-level conflict resolution.
105
105
 
106
- This method provides a cleaner alternative to the try/catch approach
107
- for handling permalink and file_path conflicts. It first tries direct
108
- insertion, then handles conflicts intelligently.
106
+ Handles file_path race conditions by checking for existing entity on IntegrityError.
107
+ For permalink conflicts, generates a unique permalink with numeric suffix.
109
108
 
110
109
  Args:
111
110
  entity: The entity to insert or update
@@ -113,50 +112,12 @@ class EntityRepository(Repository[Entity]):
113
112
  Returns:
114
113
  The inserted or updated entity
115
114
  """
116
-
117
115
  async with db.scoped_session(self.session_maker) as session:
118
116
  # Set project_id if applicable and not already set
119
117
  self._set_project_id_if_needed(entity)
120
118
 
121
- # Check for existing entity with same file_path first
122
- existing_by_path = await session.execute(
123
- select(Entity).where(
124
- Entity.file_path == entity.file_path, Entity.project_id == entity.project_id
125
- )
126
- )
127
- existing_path_entity = existing_by_path.scalar_one_or_none()
128
-
129
- if existing_path_entity:
130
- # Update existing entity with same file path
131
- for key, value in {
132
- "title": entity.title,
133
- "entity_type": entity.entity_type,
134
- "entity_metadata": entity.entity_metadata,
135
- "content_type": entity.content_type,
136
- "permalink": entity.permalink,
137
- "checksum": entity.checksum,
138
- "updated_at": entity.updated_at,
139
- }.items():
140
- setattr(existing_path_entity, key, value)
141
-
142
- await session.flush()
143
- # Return with relationships loaded
144
- query = (
145
- self.select()
146
- .where(Entity.file_path == entity.file_path)
147
- .options(*self.get_load_options())
148
- )
149
- result = await session.execute(query)
150
- found = result.scalar_one_or_none()
151
- if not found: # pragma: no cover
152
- raise RuntimeError(
153
- f"Failed to retrieve entity after update: {entity.file_path}"
154
- )
155
- return found
156
-
157
- # No existing entity with same file_path, try insert
119
+ # Try simple insert first
158
120
  try:
159
- # Simple insert for new entity
160
121
  session.add(entity)
161
122
  await session.flush()
162
123
 
@@ -175,20 +136,20 @@ class EntityRepository(Repository[Entity]):
175
136
  return found
176
137
 
177
138
  except IntegrityError:
178
- # Could be either file_path or permalink conflict
179
139
  await session.rollback()
180
140
 
181
- # Check if it's a file_path conflict (race condition)
182
- existing_by_path_check = await session.execute(
183
- select(Entity).where(
141
+ # Re-query after rollback to get a fresh, attached entity
142
+ existing_result = await session.execute(
143
+ select(Entity)
144
+ .where(
184
145
  Entity.file_path == entity.file_path, Entity.project_id == entity.project_id
185
146
  )
147
+ .options(*self.get_load_options())
186
148
  )
187
- race_condition_entity = existing_by_path_check.scalar_one_or_none()
149
+ existing_entity = existing_result.scalar_one_or_none()
188
150
 
189
- if race_condition_entity:
190
- # Race condition: file_path conflict detected after our initial check
191
- # Update the existing entity instead
151
+ if existing_entity:
152
+ # File path conflict - update the existing entity
192
153
  for key, value in {
193
154
  "title": entity.title,
194
155
  "entity_type": entity.entity_type,
@@ -198,25 +159,22 @@ class EntityRepository(Repository[Entity]):
198
159
  "checksum": entity.checksum,
199
160
  "updated_at": entity.updated_at,
200
161
  }.items():
201
- setattr(race_condition_entity, key, value)
202
-
203
- await session.flush()
204
- # Return the updated entity with relationships loaded
205
- query = (
206
- self.select()
207
- .where(Entity.file_path == entity.file_path)
208
- .options(*self.get_load_options())
209
- )
210
- result = await session.execute(query)
211
- found = result.scalar_one_or_none()
212
- if not found: # pragma: no cover
213
- raise RuntimeError(
214
- f"Failed to retrieve entity after race condition update: {entity.file_path}"
215
- )
216
- return found
162
+ setattr(existing_entity, key, value)
163
+
164
+ # Clear and re-add observations
165
+ existing_entity.observations.clear()
166
+ for obs in entity.observations:
167
+ obs.entity_id = existing_entity.id
168
+ existing_entity.observations.append(obs)
169
+
170
+ await session.commit()
171
+ return existing_entity
172
+
217
173
  else:
218
- # Must be permalink conflict - generate unique permalink
219
- return await self._handle_permalink_conflict(entity, session)
174
+ # No file_path conflict - must be permalink conflict
175
+ # Generate unique permalink and retry
176
+ entity = await self._handle_permalink_conflict(entity, session)
177
+ return entity
220
178
 
221
179
  async def _handle_permalink_conflict(self, entity: Entity, session: AsyncSession) -> Entity:
222
180
  """Handle permalink conflicts by generating a unique permalink."""
@@ -237,18 +195,7 @@ class EntityRepository(Repository[Entity]):
237
195
  break
238
196
  suffix += 1
239
197
 
240
- # Insert with unique permalink (no conflict possible now)
198
+ # Insert with unique permalink
241
199
  session.add(entity)
242
200
  await session.flush()
243
-
244
- # Return the inserted entity with relationships loaded
245
- query = (
246
- self.select()
247
- .where(Entity.file_path == entity.file_path)
248
- .options(*self.get_load_options())
249
- )
250
- result = await session.execute(query)
251
- found = result.scalar_one_or_none()
252
- if not found: # pragma: no cover
253
- raise RuntimeError(f"Failed to retrieve entity after insert: {entity.file_path}")
254
- return found
201
+ return entity
@@ -73,5 +73,18 @@ class RelationRepository(Repository[Relation]):
73
73
  result = await self.execute_query(query)
74
74
  return result.scalars().all()
75
75
 
76
+ async def find_unresolved_relations_for_entity(self, entity_id: int) -> Sequence[Relation]:
77
+ """Find unresolved relations for a specific entity.
78
+
79
+ Args:
80
+ entity_id: The entity whose unresolved outgoing relations to find.
81
+
82
+ Returns:
83
+ List of unresolved relations where this entity is the source.
84
+ """
85
+ query = select(Relation).filter(Relation.from_id == entity_id, Relation.to_id.is_(None))
86
+ result = await self.execute_query(query)
87
+ return result.scalars().all()
88
+
76
89
  def get_load_options(self) -> List[LoaderOption]:
77
90
  return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
@@ -10,13 +10,13 @@ from sqlalchemy import (
10
10
  Executable,
11
11
  inspect,
12
12
  Result,
13
- Column,
14
13
  and_,
15
14
  delete,
16
15
  )
17
16
  from sqlalchemy.exc import NoResultFound
18
17
  from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
19
18
  from sqlalchemy.orm.interfaces import LoaderOption
19
+ from sqlalchemy.sql.elements import ColumnElement
20
20
 
21
21
  from basic_memory import db
22
22
  from basic_memory.models import Base
@@ -38,7 +38,7 @@ class Repository[T: Base]:
38
38
  if Model:
39
39
  self.Model = Model
40
40
  self.mapper = inspect(self.Model).mapper
41
- self.primary_key: Column[Any] = self.mapper.primary_key[0]
41
+ self.primary_key: ColumnElement[Any] = self.mapper.primary_key[0]
42
42
  self.valid_columns = [column.key for column in self.mapper.columns]
43
43
  # Check if this model has a project_id column
44
44
  self.has_project_id = "project_id" in self.valid_columns
@@ -62,7 +62,7 @@ class SearchIndexRow:
62
62
 
63
63
  # Normalize path separators to handle both Windows (\) and Unix (/) paths
64
64
  normalized_path = Path(self.file_path).as_posix()
65
-
65
+
66
66
  # Split the path by slashes
67
67
  parts = normalized_path.split("/")
68
68
 
@@ -527,7 +527,9 @@ class SearchRepository:
527
527
  async with db.scoped_session(self.session_maker) as session:
528
528
  # Delete existing record if any
529
529
  await session.execute(
530
- text("DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"),
530
+ text(
531
+ "DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
532
+ ),
531
533
  {"permalink": search_index_row.permalink, "project_id": self.project_id},
532
534
  )
533
535
 
@@ -48,6 +48,10 @@ from basic_memory.schemas.directory import (
48
48
  DirectoryNode,
49
49
  )
50
50
 
51
+ from basic_memory.schemas.sync_report import (
52
+ SyncReportResponse,
53
+ )
54
+
51
55
  # For convenient imports, export all models
52
56
  __all__ = [
53
57
  # Base
@@ -77,4 +81,6 @@ __all__ = [
77
81
  "ProjectInfoResponse",
78
82
  # Directory
79
83
  "DirectoryNode",
84
+ # Sync
85
+ "SyncReportResponse",
80
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, time
17
+ from datetime import datetime, timedelta
17
18
  from pathlib import Path
18
19
  from typing import List, Optional, Annotated, Dict
19
20
 
@@ -23,7 +24,7 @@ from dateparser import parse
23
24
  from pydantic import BaseModel, BeforeValidator, Field, model_validator
24
25
 
25
26
  from basic_memory.config import ConfigManager
26
- from basic_memory.file_utils import sanitize_for_filename
27
+ from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder
27
28
  from basic_memory.utils import generate_permalink
28
29
 
29
30
 
@@ -51,30 +52,47 @@ def to_snake_case(name: str) -> str:
51
52
  def parse_timeframe(timeframe: str) -> datetime:
52
53
  """Parse timeframe with special handling for 'today' and other natural language expressions.
53
54
 
55
+ Enforces a minimum 1-day lookback to handle timezone differences in distributed deployments.
56
+
54
57
  Args:
55
58
  timeframe: Natural language timeframe like 'today', '1d', '1 week ago', etc.
56
59
 
57
60
  Returns:
58
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.
59
63
 
60
64
  Examples:
61
- parse_timeframe('today') -> 2025-06-05 00:00:00-07:00 (start of today with local timezone)
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)
62
67
  parse_timeframe('1d') -> 2025-06-04 14:50:00-07:00 (24 hours ago with local timezone)
63
68
  parse_timeframe('1 week ago') -> 2025-05-29 14:50:00-07:00 (1 week ago with local timezone)
64
69
  """
65
70
  if timeframe.lower() == "today":
66
- # Return start of today (00:00:00) in local timezone
67
- naive_dt = datetime.combine(datetime.now().date(), time.min)
68
- return naive_dt.astimezone()
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()
69
76
  else:
70
77
  # Use dateparser for other formats
71
78
  parsed = parse(timeframe)
72
79
  if not parsed:
73
80
  raise ValueError(f"Could not parse timeframe: {timeframe}")
74
-
81
+
75
82
  # If the parsed datetime is naive, make it timezone-aware in local system timezone
76
83
  if parsed.tzinfo is None:
77
- return parsed.astimezone()
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
78
96
  else:
79
97
  return parsed
80
98
 
@@ -179,6 +197,7 @@ class Entity(BaseModel):
179
197
  """
180
198
 
181
199
  # private field to override permalink
200
+ # Use empty string "" as sentinel to indicate permalinks are explicitly disabled
182
201
  _permalink: Optional[str] = None
183
202
 
184
203
  title: str
@@ -192,6 +211,10 @@ class Entity(BaseModel):
192
211
  default="text/markdown",
193
212
  )
194
213
 
214
+ def __init__(self, **data):
215
+ data["folder"] = sanitize_for_folder(data.get("folder", ""))
216
+ super().__init__(**data)
217
+
195
218
  @property
196
219
  def safe_title(self) -> str:
197
220
  """
@@ -218,13 +241,18 @@ class Entity(BaseModel):
218
241
  """Get the file path for this entity based on its permalink."""
219
242
  safe_title = self.safe_title
220
243
  if self.content_type == "text/markdown":
221
- return f"{self.folder}/{safe_title}.md" if self.folder else f"{safe_title}.md"
244
+ return (
245
+ os.path.join(self.folder, f"{safe_title}.md") if self.folder else f"{safe_title}.md"
246
+ )
222
247
  else:
223
- return f"{self.folder}/{safe_title}" if self.folder else safe_title
248
+ return os.path.join(self.folder, safe_title) if self.folder else safe_title
224
249
 
225
250
  @property
226
- def permalink(self) -> Permalink:
251
+ def permalink(self) -> Optional[Permalink]:
227
252
  """Get a url friendly path}."""
253
+ # Empty string is a sentinel value indicating permalinks are disabled
254
+ if self._permalink == "":
255
+ return None
228
256
  return self._permalink or generate_permalink(self.file_path)
229
257
 
230
258
  @model_validator(mode="after")
@@ -0,0 +1,46 @@
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
+ name: str = Field(..., description="Created project name")
45
+ path: str = Field(..., description="Created project path")
46
+ message: str = Field(default="", description="Success message")
@@ -1,10 +1,10 @@
1
1
  """Schemas for memory context."""
2
2
 
3
3
  from datetime import datetime
4
- from typing import List, Optional, Annotated, Sequence, Literal, Union
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, ConfigDict
7
+ from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter, field_serializer
8
8
 
9
9
  from basic_memory.schemas.search import SearchItemType
10
10
 
@@ -26,6 +26,7 @@ def validate_memory_url_path(path: str) -> bool:
26
26
  >>> validate_memory_url_path("invalid://test") # Contains protocol
27
27
  False
28
28
  """
29
+ # Empty paths are not valid
29
30
  if not path or not path.strip():
30
31
  return False
31
32
 
@@ -68,7 +69,13 @@ def normalize_memory_url(url: str | None) -> str:
68
69
  ValueError: Invalid memory URL path: 'memory//test' contains double slashes
69
70
  """
70
71
  if not url:
71
- return ""
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")
72
79
 
73
80
  clean_path = url.removeprefix("memory://")
74
81
 
@@ -79,8 +86,6 @@ def normalize_memory_url(url: str | None) -> str:
79
86
  raise ValueError(f"Invalid memory URL path: '{clean_path}' contains protocol scheme")
80
87
  elif "//" in clean_path:
81
88
  raise ValueError(f"Invalid memory URL path: '{clean_path}' contains double slashes")
82
- elif not clean_path.strip():
83
- raise ValueError("Memory URL path cannot be empty or whitespace")
84
89
  else:
85
90
  raise ValueError(f"Invalid memory URL path: '{clean_path}' contains invalid characters")
86
91
 
@@ -117,21 +122,23 @@ def memory_url_path(url: memory_url) -> str: # pyright: ignore
117
122
 
118
123
  class EntitySummary(BaseModel):
119
124
  """Simplified entity representation."""
120
-
121
- model_config = ConfigDict(json_encoders={datetime: lambda dt: dt.isoformat()})
122
125
 
123
126
  type: Literal["entity"] = "entity"
124
127
  permalink: Optional[str]
125
128
  title: str
126
129
  content: Optional[str] = None
127
130
  file_path: str
128
- 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()
129
138
 
130
139
 
131
140
  class RelationSummary(BaseModel):
132
141
  """Simplified relation representation."""
133
-
134
- model_config = ConfigDict(json_encoders={datetime: lambda dt: dt.isoformat()})
135
142
 
136
143
  type: Literal["relation"] = "relation"
137
144
  title: str
@@ -140,13 +147,17 @@ class RelationSummary(BaseModel):
140
147
  relation_type: str
141
148
  from_entity: Optional[str] = None
142
149
  to_entity: Optional[str] = None
143
- created_at: datetime
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()
144
157
 
145
158
 
146
159
  class ObservationSummary(BaseModel):
147
160
  """Simplified observation representation."""
148
-
149
- model_config = ConfigDict(json_encoders={datetime: lambda dt: dt.isoformat()})
150
161
 
151
162
  type: Literal["observation"] = "observation"
152
163
  title: str
@@ -154,32 +165,42 @@ class ObservationSummary(BaseModel):
154
165
  permalink: str
155
166
  category: str
156
167
  content: str
157
- created_at: datetime
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()
158
175
 
159
176
 
160
177
  class MemoryMetadata(BaseModel):
161
178
  """Simplified response metadata."""
162
-
163
- model_config = ConfigDict(json_encoders={datetime: lambda dt: dt.isoformat()})
164
179
 
165
180
  uri: Optional[str] = None
166
181
  types: Optional[List[SearchItemType]] = None
167
182
  depth: int
168
183
  timeframe: Optional[str] = None
169
- generated_at: datetime
184
+ generated_at: Annotated[
185
+ datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
186
+ ]
170
187
  primary_count: Optional[int] = None # Changed field name
171
188
  related_count: Optional[int] = None # Changed field name
172
189
  total_results: Optional[int] = None # For backward compatibility
173
190
  total_relations: Optional[int] = None
174
191
  total_observations: Optional[int] = None
175
192
 
193
+ @field_serializer("generated_at")
194
+ def serialize_generated_at(self, dt: datetime) -> str:
195
+ return dt.isoformat()
196
+
176
197
 
177
198
  class ContextResult(BaseModel):
178
199
  """Context result containing a primary item with its observations and related items."""
179
200
 
180
201
  primary_result: Annotated[
181
- Union[EntitySummary, RelationSummary, ObservationSummary],
182
- Field(discriminator="type", description="Primary item")
202
+ Union[EntitySummary, RelationSummary, ObservationSummary],
203
+ Field(discriminator="type", description="Primary item"),
183
204
  ]
184
205
 
185
206
  observations: Sequence[ObservationSummary] = Field(
@@ -188,8 +209,7 @@ class ContextResult(BaseModel):
188
209
 
189
210
  related_results: Sequence[
190
211
  Annotated[
191
- Union[EntitySummary, RelationSummary, ObservationSummary],
192
- Field(discriminator="type")
212
+ Union[EntitySummary, RelationSummary, ObservationSummary], Field(discriminator="type")
193
213
  ]
194
214
  ] = Field(description="Related items", default_factory=list)
195
215
 
@@ -207,3 +227,52 @@ class GraphContext(BaseModel):
207
227
 
208
228
  page: Optional[int] = None
209
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()