basic-memory 0.16.1__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 +1 -1
- basic_memory/alembic/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -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 +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- 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 +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- 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 +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- 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 +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- 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/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- 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/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/runtime.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Runtime mode resolution for Basic Memory.
|
|
2
|
+
|
|
3
|
+
This module centralizes runtime mode detection, ensuring cloud/local/test
|
|
4
|
+
determination happens in one place rather than scattered across modules.
|
|
5
|
+
|
|
6
|
+
Composition roots (containers) read ConfigManager and use this module
|
|
7
|
+
to resolve the runtime mode, then pass the result downstream.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from enum import Enum, auto
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RuntimeMode(Enum):
|
|
14
|
+
"""Runtime modes for Basic Memory."""
|
|
15
|
+
|
|
16
|
+
LOCAL = auto() # Local standalone mode (default)
|
|
17
|
+
CLOUD = auto() # Cloud mode with remote sync
|
|
18
|
+
TEST = auto() # Test environment
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_cloud(self) -> bool:
|
|
22
|
+
return self == RuntimeMode.CLOUD
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_local(self) -> bool:
|
|
26
|
+
return self == RuntimeMode.LOCAL
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_test(self) -> bool:
|
|
30
|
+
return self == RuntimeMode.TEST
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_runtime_mode(
|
|
34
|
+
cloud_mode_enabled: bool,
|
|
35
|
+
is_test_env: bool,
|
|
36
|
+
) -> RuntimeMode:
|
|
37
|
+
"""Resolve the runtime mode from configuration flags.
|
|
38
|
+
|
|
39
|
+
This is the single source of truth for mode resolution.
|
|
40
|
+
Composition roots call this with config values they've read.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
cloud_mode_enabled: Whether cloud mode is enabled in config
|
|
44
|
+
is_test_env: Whether running in test environment
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The resolved RuntimeMode
|
|
48
|
+
"""
|
|
49
|
+
# Trigger: test environment is detected
|
|
50
|
+
# Why: tests need special handling (no file sync, isolated DB)
|
|
51
|
+
# Outcome: returns TEST mode, skipping cloud mode check
|
|
52
|
+
if is_test_env:
|
|
53
|
+
return RuntimeMode.TEST
|
|
54
|
+
|
|
55
|
+
# Trigger: cloud mode is enabled in config
|
|
56
|
+
# Why: cloud mode changes auth, sync, and API behavior
|
|
57
|
+
# Outcome: returns CLOUD mode for remote-first behavior
|
|
58
|
+
if cloud_mode_enabled:
|
|
59
|
+
return RuntimeMode.CLOUD
|
|
60
|
+
|
|
61
|
+
return RuntimeMode.LOCAL
|
basic_memory/schemas/base.py
CHANGED
|
@@ -21,13 +21,38 @@ from typing import List, Optional, Annotated, Dict
|
|
|
21
21
|
from annotated_types import MinLen, MaxLen
|
|
22
22
|
from dateparser import parse
|
|
23
23
|
|
|
24
|
-
from pydantic import BaseModel, BeforeValidator, Field, model_validator
|
|
24
|
+
from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field
|
|
25
25
|
|
|
26
26
|
from basic_memory.config import ConfigManager
|
|
27
27
|
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder
|
|
28
28
|
from basic_memory.utils import generate_permalink
|
|
29
29
|
|
|
30
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
|
+
|
|
31
56
|
def to_snake_case(name: str) -> str:
|
|
32
57
|
"""Convert a string to snake_case.
|
|
33
58
|
|
|
@@ -83,7 +108,7 @@ def parse_timeframe(timeframe: str) -> datetime:
|
|
|
83
108
|
if parsed.tzinfo is None:
|
|
84
109
|
parsed = parsed.astimezone()
|
|
85
110
|
else:
|
|
86
|
-
parsed = parsed
|
|
111
|
+
parsed = parsed # pragma: no cover
|
|
87
112
|
|
|
88
113
|
# Enforce minimum 1-day lookback to handle timezone differences
|
|
89
114
|
# This ensures we don't miss recent activity due to client/server timezone mismatches
|
|
@@ -113,7 +138,7 @@ def validate_timeframe(timeframe: str) -> str:
|
|
|
113
138
|
# Convert to duration
|
|
114
139
|
now = datetime.now().astimezone()
|
|
115
140
|
if parsed > now:
|
|
116
|
-
raise ValueError("Timeframe cannot be in the future")
|
|
141
|
+
raise ValueError("Timeframe cannot be in the future") # pragma: no cover
|
|
117
142
|
|
|
118
143
|
# Could format the duration back to our standard format
|
|
119
144
|
days = (now - parsed).days
|
|
@@ -158,7 +183,7 @@ ObservationStr = Annotated[
|
|
|
158
183
|
str,
|
|
159
184
|
BeforeValidator(str.strip), # Clean whitespace
|
|
160
185
|
MinLen(1), # Ensure non-empty after stripping
|
|
161
|
-
|
|
186
|
+
# No MaxLen - matches DB Text column which has no length restriction
|
|
162
187
|
]
|
|
163
188
|
|
|
164
189
|
|
|
@@ -232,12 +257,17 @@ class Entity(BaseModel):
|
|
|
232
257
|
use_kebab_case = app_config.kebab_filenames
|
|
233
258
|
|
|
234
259
|
if use_kebab_case:
|
|
235
|
-
|
|
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)
|
|
236
265
|
|
|
237
266
|
return fixed_title
|
|
238
267
|
|
|
268
|
+
@computed_field
|
|
239
269
|
@property
|
|
240
|
-
def file_path(self):
|
|
270
|
+
def file_path(self) -> str:
|
|
241
271
|
"""Get the file path for this entity based on its permalink."""
|
|
242
272
|
safe_title = self.safe_title
|
|
243
273
|
if self.content_type == "text/markdown":
|
|
@@ -16,7 +16,8 @@ class DirectoryNode(BaseModel):
|
|
|
16
16
|
children: List["DirectoryNode"] = [] # Default to empty list
|
|
17
17
|
title: Optional[str] = None
|
|
18
18
|
permalink: Optional[str] = None
|
|
19
|
-
|
|
19
|
+
external_id: Optional[str] = None # UUID (primary API identifier for v2)
|
|
20
|
+
entity_id: Optional[int] = None # Internal numeric ID
|
|
20
21
|
entity_type: Optional[str] = None
|
|
21
22
|
content_type: Optional[str] = None
|
|
22
23
|
updated_at: Optional[datetime] = None
|
basic_memory/schemas/memory.py
CHANGED
|
@@ -124,6 +124,7 @@ class EntitySummary(BaseModel):
|
|
|
124
124
|
"""Simplified entity representation."""
|
|
125
125
|
|
|
126
126
|
type: Literal["entity"] = "entity"
|
|
127
|
+
entity_id: int # Database ID for v2 API consistency
|
|
127
128
|
permalink: Optional[str]
|
|
128
129
|
title: str
|
|
129
130
|
content: Optional[str] = None
|
|
@@ -141,12 +142,16 @@ class RelationSummary(BaseModel):
|
|
|
141
142
|
"""Simplified relation representation."""
|
|
142
143
|
|
|
143
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
|
|
144
147
|
title: str
|
|
145
148
|
file_path: str
|
|
146
149
|
permalink: str
|
|
147
150
|
relation_type: str
|
|
148
151
|
from_entity: Optional[str] = None
|
|
152
|
+
from_entity_id: Optional[int] = None # ID of source entity
|
|
149
153
|
to_entity: Optional[str] = None
|
|
154
|
+
to_entity_id: Optional[int] = None # ID of target entity
|
|
150
155
|
created_at: Annotated[
|
|
151
156
|
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
|
|
152
157
|
]
|
|
@@ -160,6 +165,8 @@ class ObservationSummary(BaseModel):
|
|
|
160
165
|
"""Simplified observation representation."""
|
|
161
166
|
|
|
162
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
|
|
163
170
|
title: str
|
|
164
171
|
file_path: str
|
|
165
172
|
permalink: str
|
|
@@ -255,7 +262,7 @@ class ProjectActivity(BaseModel):
|
|
|
255
262
|
|
|
256
263
|
@field_serializer("last_activity")
|
|
257
264
|
def serialize_last_activity(self, dt: Optional[datetime]) -> Optional[str]:
|
|
258
|
-
return dt.isoformat() if dt else None
|
|
265
|
+
return dt.isoformat() if dt else None # pragma: no cover
|
|
259
266
|
|
|
260
267
|
|
|
261
268
|
class ProjectActivitySummary(BaseModel):
|
|
@@ -275,4 +282,4 @@ class ProjectActivitySummary(BaseModel):
|
|
|
275
282
|
|
|
276
283
|
@field_serializer("generated_at")
|
|
277
284
|
def serialize_generated_at(self, dt: datetime) -> str:
|
|
278
|
-
return dt.isoformat()
|
|
285
|
+
return dt.isoformat() # pragma: no cover
|
|
@@ -173,6 +173,8 @@ class ProjectWatchStatus(BaseModel):
|
|
|
173
173
|
class ProjectItem(BaseModel):
|
|
174
174
|
"""Simple representation of a project."""
|
|
175
175
|
|
|
176
|
+
id: int
|
|
177
|
+
external_id: str # UUID string for API references (required after migration)
|
|
176
178
|
name: str
|
|
177
179
|
path: str
|
|
178
180
|
is_default: bool = False
|
basic_memory/schemas/response.py
CHANGED
|
@@ -14,7 +14,7 @@ Key Features:
|
|
|
14
14
|
from datetime import datetime
|
|
15
15
|
from typing import List, Optional, Dict
|
|
16
16
|
|
|
17
|
-
from pydantic import BaseModel, ConfigDict, Field,
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
18
18
|
|
|
19
19
|
from basic_memory.schemas.base import Relation, Permalink, EntityType, ContentType, Observation
|
|
20
20
|
|
|
@@ -64,32 +64,89 @@ class RelationResponse(Relation, SQLAlchemyModel):
|
|
|
64
64
|
|
|
65
65
|
permalink: Permalink
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
67
|
+
# Override base Relation fields to allow Optional values
|
|
68
|
+
from_id: Optional[Permalink] = Field(default=None) # pyright: ignore[reportIncompatibleVariableOverride]
|
|
69
|
+
to_id: Optional[Permalink] = Field(default=None) # pyright: ignore[reportIncompatibleVariableOverride]
|
|
70
|
+
to_name: Optional[str] = Field(default=None)
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def resolve_entity_references(cls, data):
|
|
75
|
+
"""Resolve from_id and to_id from joined entities, falling back to file_path.
|
|
76
|
+
|
|
77
|
+
When loading from SQLAlchemy models, the from_entity and to_entity relationships
|
|
78
|
+
are joined. We extract the permalink from these entities, falling back to
|
|
79
|
+
file_path when permalink is None.
|
|
80
|
+
|
|
81
|
+
We use file_path directly (not converted to permalink format) because if the
|
|
82
|
+
entity doesn't have a permalink, the system won't be able to find it by a
|
|
83
|
+
generated one anyway. Using the actual file_path preserves the real identifier.
|
|
84
|
+
"""
|
|
85
|
+
# Handle dict input (e.g., from API or tests)
|
|
86
|
+
if isinstance(data, dict):
|
|
87
|
+
from_entity = data.get("from_entity")
|
|
88
|
+
to_entity = data.get("to_entity")
|
|
89
|
+
|
|
90
|
+
# Resolve from_id: prefer permalink, fall back to file_path
|
|
91
|
+
if from_entity and isinstance(from_entity, dict):
|
|
92
|
+
permalink = from_entity.get("permalink")
|
|
93
|
+
if permalink:
|
|
94
|
+
data["from_id"] = permalink
|
|
95
|
+
elif from_entity.get("file_path"):
|
|
96
|
+
data["from_id"] = from_entity["file_path"]
|
|
97
|
+
|
|
98
|
+
# Resolve to_id: prefer permalink, fall back to file_path
|
|
99
|
+
if to_entity and isinstance(to_entity, dict):
|
|
100
|
+
permalink = to_entity.get("permalink")
|
|
101
|
+
if permalink:
|
|
102
|
+
data["to_id"] = permalink
|
|
103
|
+
elif to_entity.get("file_path"):
|
|
104
|
+
data["to_id"] = to_entity["file_path"]
|
|
105
|
+
|
|
106
|
+
# Also resolve to_name from entity title
|
|
107
|
+
if to_entity.get("title") and not data.get("to_name"):
|
|
108
|
+
data["to_name"] = to_entity["title"]
|
|
109
|
+
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
# Handle SQLAlchemy model input (from_attributes=True)
|
|
113
|
+
# Access attributes directly from the ORM model
|
|
114
|
+
from_entity = getattr(data, "from_entity", None)
|
|
115
|
+
to_entity = getattr(data, "to_entity", None)
|
|
116
|
+
|
|
117
|
+
# Build a dict from the model's attributes
|
|
118
|
+
result = {}
|
|
119
|
+
|
|
120
|
+
# Copy base fields
|
|
121
|
+
for field in ["permalink", "relation_type", "context", "to_name"]:
|
|
122
|
+
if hasattr(data, field):
|
|
123
|
+
result[field] = getattr(data, field)
|
|
124
|
+
|
|
125
|
+
# Resolve from_id: prefer permalink, fall back to file_path
|
|
126
|
+
if from_entity:
|
|
127
|
+
permalink = getattr(from_entity, "permalink", None)
|
|
128
|
+
file_path = getattr(from_entity, "file_path", None)
|
|
129
|
+
if permalink:
|
|
130
|
+
result["from_id"] = permalink
|
|
131
|
+
elif file_path:
|
|
132
|
+
result["from_id"] = file_path
|
|
133
|
+
|
|
134
|
+
# Resolve to_id: prefer permalink, fall back to file_path
|
|
135
|
+
if to_entity:
|
|
136
|
+
permalink = getattr(to_entity, "permalink", None)
|
|
137
|
+
file_path = getattr(to_entity, "file_path", None)
|
|
138
|
+
if permalink:
|
|
139
|
+
result["to_id"] = permalink
|
|
140
|
+
elif file_path:
|
|
141
|
+
result["to_id"] = file_path
|
|
142
|
+
|
|
143
|
+
# Also resolve to_name from entity title if not set
|
|
144
|
+
if not result.get("to_name"):
|
|
145
|
+
title = getattr(to_entity, "title", None)
|
|
146
|
+
if title:
|
|
147
|
+
result["to_name"] = title
|
|
148
|
+
|
|
149
|
+
return result
|
|
93
150
|
|
|
94
151
|
|
|
95
152
|
class EntityResponse(SQLAlchemyModel):
|
basic_memory/schemas/search.py
CHANGED
|
@@ -97,6 +97,11 @@ class SearchResult(BaseModel):
|
|
|
97
97
|
|
|
98
98
|
metadata: Optional[dict] = None
|
|
99
99
|
|
|
100
|
+
# IDs for v2 API consistency
|
|
101
|
+
entity_id: Optional[int] = None # Entity ID (always present for entities)
|
|
102
|
+
observation_id: Optional[int] = None # Observation ID (for observation results)
|
|
103
|
+
relation_id: Optional[int] = None # Relation ID (for relation results)
|
|
104
|
+
|
|
100
105
|
# Type-specific fields
|
|
101
106
|
category: Optional[str] = None # For observations
|
|
102
107
|
from_entity: Optional[Permalink] = None # For relations
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""V2 API schemas - ID-based entity and project references."""
|
|
2
|
+
|
|
3
|
+
from basic_memory.schemas.v2.entity import (
|
|
4
|
+
EntityResolveRequest,
|
|
5
|
+
EntityResolveResponse,
|
|
6
|
+
EntityResponseV2,
|
|
7
|
+
MoveEntityRequestV2,
|
|
8
|
+
ProjectResolveRequest,
|
|
9
|
+
ProjectResolveResponse,
|
|
10
|
+
)
|
|
11
|
+
from basic_memory.schemas.v2.resource import (
|
|
12
|
+
CreateResourceRequest,
|
|
13
|
+
UpdateResourceRequest,
|
|
14
|
+
ResourceResponse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"EntityResolveRequest",
|
|
19
|
+
"EntityResolveResponse",
|
|
20
|
+
"EntityResponseV2",
|
|
21
|
+
"MoveEntityRequestV2",
|
|
22
|
+
"ProjectResolveRequest",
|
|
23
|
+
"ProjectResolveResponse",
|
|
24
|
+
"CreateResourceRequest",
|
|
25
|
+
"UpdateResourceRequest",
|
|
26
|
+
"ResourceResponse",
|
|
27
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""V2 entity and project schemas with ID-first design."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Dict, List, Literal, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
7
|
+
|
|
8
|
+
from basic_memory.schemas.response import ObservationResponse, RelationResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EntityResolveRequest(BaseModel):
|
|
12
|
+
"""Request to resolve a string identifier to an entity ID.
|
|
13
|
+
|
|
14
|
+
Supports resolution of:
|
|
15
|
+
- Permalinks (e.g., "specs/search")
|
|
16
|
+
- Titles (e.g., "Search Specification")
|
|
17
|
+
- File paths (e.g., "specs/search.md")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
identifier: str = Field(
|
|
21
|
+
...,
|
|
22
|
+
description="Entity identifier to resolve (permalink, title, or file path)",
|
|
23
|
+
min_length=1,
|
|
24
|
+
max_length=500,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EntityResolveResponse(BaseModel):
|
|
29
|
+
"""Response from identifier resolution.
|
|
30
|
+
|
|
31
|
+
Returns the entity ID and associated metadata for the resolved entity.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
external_id: str = Field(..., description="External UUID (primary API identifier)")
|
|
35
|
+
entity_id: int = Field(..., description="Numeric entity ID (internal identifier)")
|
|
36
|
+
permalink: Optional[str] = Field(None, description="Entity permalink")
|
|
37
|
+
file_path: str = Field(..., description="Relative file path")
|
|
38
|
+
title: str = Field(..., description="Entity title")
|
|
39
|
+
resolution_method: Literal["external_id", "permalink", "title", "path", "search"] = Field(
|
|
40
|
+
..., description="How the identifier was resolved"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MoveEntityRequestV2(BaseModel):
|
|
45
|
+
"""V2 request schema for moving an entity to a new file location.
|
|
46
|
+
|
|
47
|
+
In V2 API, the entity ID is provided in the URL path, so this request
|
|
48
|
+
only needs the destination path.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
destination_path: str = Field(
|
|
52
|
+
...,
|
|
53
|
+
description="New file path for the entity (relative to project root)",
|
|
54
|
+
min_length=1,
|
|
55
|
+
max_length=500,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EntityResponseV2(BaseModel):
|
|
60
|
+
"""V2 entity response with external_id as the primary API identifier.
|
|
61
|
+
|
|
62
|
+
This response format emphasizes the external_id (UUID) as the primary API identifier,
|
|
63
|
+
with the numeric id maintained for internal reference.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
# External UUID first - this is the primary API identifier in v2
|
|
67
|
+
external_id: str = Field(..., description="External UUID (primary API identifier)")
|
|
68
|
+
# Internal numeric ID
|
|
69
|
+
id: int = Field(..., description="Numeric entity ID (internal identifier)")
|
|
70
|
+
|
|
71
|
+
# Core entity fields
|
|
72
|
+
title: str = Field(..., description="Entity title")
|
|
73
|
+
entity_type: str = Field(..., description="Entity type")
|
|
74
|
+
content_type: str = Field(default="text/markdown", description="Content MIME type")
|
|
75
|
+
|
|
76
|
+
# Secondary identifiers (for compatibility and convenience)
|
|
77
|
+
permalink: Optional[str] = Field(None, description="Entity permalink (may change)")
|
|
78
|
+
file_path: str = Field(..., description="Relative file path (may change)")
|
|
79
|
+
|
|
80
|
+
# Content and metadata
|
|
81
|
+
content: Optional[str] = Field(None, description="Entity content")
|
|
82
|
+
entity_metadata: Optional[Dict] = Field(None, description="Entity metadata")
|
|
83
|
+
|
|
84
|
+
# Relationships
|
|
85
|
+
observations: List[ObservationResponse] = Field(
|
|
86
|
+
default_factory=list, description="Entity observations"
|
|
87
|
+
)
|
|
88
|
+
relations: List[RelationResponse] = Field(default_factory=list, description="Entity relations")
|
|
89
|
+
|
|
90
|
+
# Timestamps
|
|
91
|
+
created_at: datetime = Field(..., description="Creation timestamp")
|
|
92
|
+
updated_at: datetime = Field(..., description="Last update timestamp")
|
|
93
|
+
|
|
94
|
+
# V2-specific metadata
|
|
95
|
+
api_version: Literal["v2"] = Field(
|
|
96
|
+
default="v2", description="API version (always 'v2' for this response)"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
model_config = ConfigDict(from_attributes=True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ProjectResolveRequest(BaseModel):
|
|
103
|
+
"""Request to resolve a project identifier to a project ID.
|
|
104
|
+
|
|
105
|
+
Supports resolution of:
|
|
106
|
+
- Project names (e.g., "my-project")
|
|
107
|
+
- Permalinks (e.g., "my-project")
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
identifier: str = Field(
|
|
111
|
+
...,
|
|
112
|
+
description="Project identifier to resolve (name or permalink)",
|
|
113
|
+
min_length=1,
|
|
114
|
+
max_length=255,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ProjectResolveResponse(BaseModel):
|
|
119
|
+
"""Response from project identifier resolution.
|
|
120
|
+
|
|
121
|
+
Returns the project ID and associated metadata for the resolved project.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
external_id: str = Field(..., description="External UUID (primary API identifier)")
|
|
125
|
+
project_id: int = Field(..., description="Numeric project ID (internal identifier)")
|
|
126
|
+
name: str = Field(..., description="Project name")
|
|
127
|
+
permalink: str = Field(..., description="Project permalink")
|
|
128
|
+
path: str = Field(..., description="Project file path")
|
|
129
|
+
is_active: bool = Field(..., description="Whether the project is active")
|
|
130
|
+
is_default: bool = Field(..., description="Whether the project is the default")
|
|
131
|
+
resolution_method: Literal["external_id", "name", "permalink"] = Field(
|
|
132
|
+
..., description="How the identifier was resolved"
|
|
133
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""V2 resource schemas for file content operations."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CreateResourceRequest(BaseModel):
|
|
7
|
+
"""Request to create a new resource file.
|
|
8
|
+
|
|
9
|
+
File path is required for new resources since we need to know where
|
|
10
|
+
to create the file.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
file_path: str = Field(
|
|
14
|
+
...,
|
|
15
|
+
description="Path to create the file, relative to project root",
|
|
16
|
+
min_length=1,
|
|
17
|
+
max_length=500,
|
|
18
|
+
)
|
|
19
|
+
content: str = Field(..., description="File content to write")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UpdateResourceRequest(BaseModel):
|
|
23
|
+
"""Request to update an existing resource by entity ID.
|
|
24
|
+
|
|
25
|
+
Only content is required - the file path is already known from the entity.
|
|
26
|
+
Optionally can update the file_path to move the file.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
content: str = Field(..., description="File content to write")
|
|
30
|
+
file_path: str | None = Field(
|
|
31
|
+
None,
|
|
32
|
+
description="Optional new file path to move the resource",
|
|
33
|
+
min_length=1,
|
|
34
|
+
max_length=500,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ResourceResponse(BaseModel):
|
|
39
|
+
"""Response from resource operations."""
|
|
40
|
+
|
|
41
|
+
entity_id: int = Field(..., description="Internal entity ID of the resource")
|
|
42
|
+
external_id: str = Field(..., description="External UUID of the resource for API references")
|
|
43
|
+
file_path: str = Field(..., description="File path of the resource")
|
|
44
|
+
checksum: str = Field(..., description="File content checksum")
|
|
45
|
+
size: int = Field(..., description="File size in bytes")
|
|
46
|
+
created_at: float = Field(..., description="Creation timestamp")
|
|
47
|
+
modified_at: float = Field(..., description="Modification timestamp")
|