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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
- basic_memory/api/app.py +10 -4
- basic_memory/api/routers/knowledge_router.py +25 -8
- basic_memory/api/routers/project_router.py +99 -4
- basic_memory/cli/app.py +9 -28
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/cloud/__init__.py +5 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
- basic_memory/cli/commands/cloud/core_commands.py +288 -0
- basic_memory/cli/commands/cloud/mount_commands.py +295 -0
- basic_memory/cli/commands/cloud/rclone_config.py +288 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
- basic_memory/cli/commands/command_utils.py +60 -0
- basic_memory/cli/commands/import_memory_json.py +0 -4
- basic_memory/cli/commands/mcp.py +16 -4
- basic_memory/cli/commands/project.py +139 -142
- basic_memory/cli/commands/status.py +34 -22
- basic_memory/cli/commands/sync.py +45 -228
- basic_memory/cli/commands/tool.py +87 -16
- basic_memory/cli/main.py +1 -0
- basic_memory/config.py +76 -12
- basic_memory/db.py +104 -3
- basic_memory/deps.py +20 -3
- basic_memory/file_utils.py +37 -13
- basic_memory/ignore_utils.py +295 -0
- basic_memory/markdown/plugins.py +9 -7
- basic_memory/mcp/async_client.py +22 -10
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
- basic_memory/mcp/prompts/continue_conversation.py +1 -1
- basic_memory/mcp/prompts/recent_activity.py +116 -32
- basic_memory/mcp/prompts/search.py +1 -1
- basic_memory/mcp/prompts/utils.py +11 -4
- basic_memory/mcp/resources/ai_assistant_guide.md +179 -41
- basic_memory/mcp/resources/project_info.py +20 -6
- basic_memory/mcp/server.py +0 -37
- basic_memory/mcp/tools/__init__.py +5 -6
- basic_memory/mcp/tools/build_context.py +29 -19
- basic_memory/mcp/tools/canvas.py +19 -8
- basic_memory/mcp/tools/chatgpt_tools.py +178 -0
- basic_memory/mcp/tools/delete_note.py +67 -34
- basic_memory/mcp/tools/edit_note.py +55 -39
- basic_memory/mcp/tools/headers.py +44 -0
- basic_memory/mcp/tools/list_directory.py +18 -8
- basic_memory/mcp/tools/move_note.py +119 -41
- basic_memory/mcp/tools/project_management.py +61 -228
- basic_memory/mcp/tools/read_content.py +28 -12
- basic_memory/mcp/tools/read_note.py +83 -46
- basic_memory/mcp/tools/recent_activity.py +441 -42
- basic_memory/mcp/tools/search.py +82 -70
- basic_memory/mcp/tools/sync_status.py +5 -4
- basic_memory/mcp/tools/utils.py +19 -0
- basic_memory/mcp/tools/view_note.py +31 -6
- basic_memory/mcp/tools/write_note.py +65 -14
- basic_memory/models/knowledge.py +12 -6
- basic_memory/models/project.py +6 -2
- basic_memory/repository/entity_repository.py +29 -82
- basic_memory/repository/relation_repository.py +13 -0
- basic_memory/repository/repository.py +2 -2
- basic_memory/repository/search_repository.py +4 -2
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +39 -11
- basic_memory/schemas/cloud.py +46 -0
- basic_memory/schemas/memory.py +90 -21
- basic_memory/schemas/project_info.py +9 -10
- basic_memory/schemas/sync_report.py +48 -0
- basic_memory/services/context_service.py +25 -11
- basic_memory/services/entity_service.py +75 -45
- basic_memory/services/initialization.py +30 -11
- basic_memory/services/project_service.py +13 -23
- basic_memory/sync/sync_service.py +145 -21
- basic_memory/sync/watch_service.py +101 -40
- basic_memory/utils.py +14 -4
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/METADATA +7 -6
- basic_memory-0.15.0.dist-info/RECORD +147 -0
- basic_memory/mcp/project_session.py +0 -120
- basic_memory-0.14.4.dist-info/RECORD +0 -133
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/licenses/LICENSE +0 -0
basic_memory/models/knowledge.py
CHANGED
|
@@ -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(
|
|
78
|
-
|
|
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 (
|
|
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:
|
basic_memory/models/project.py
CHANGED
|
@@ -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(
|
|
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),
|
|
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
|
|
104
|
+
"""Insert or update entity using simple try/catch with database-level conflict resolution.
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
182
|
-
|
|
183
|
-
select(Entity)
|
|
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
|
-
|
|
149
|
+
existing_entity = existing_result.scalar_one_or_none()
|
|
188
150
|
|
|
189
|
-
if
|
|
190
|
-
#
|
|
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(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
.
|
|
208
|
-
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
#
|
|
219
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
basic_memory/schemas/__init__.py
CHANGED
|
@@ -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
|
]
|
basic_memory/schemas/base.py
CHANGED
|
@@ -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
|
|
|
@@ -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-
|
|
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
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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")
|
basic_memory/schemas/memory.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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()
|