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