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.

Files changed (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -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
@@ -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
- MaxLen(1000), # Keep reasonable length
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
- fixed_title = generate_permalink(file_path=fixed_title, split_extension=False)
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
- entity_id: Optional[int] = None
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
@@ -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
@@ -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, AliasPath, AliasChoices
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
- from_id: Permalink = Field(
68
- # use the permalink from the associated Entity
69
- # or the from_id value
70
- validation_alias=AliasChoices(
71
- AliasPath("from_entity", "permalink"),
72
- "from_id",
73
- )
74
- )
75
- to_id: Optional[Permalink] = Field( # pyright: ignore
76
- # use the permalink from the associated Entity
77
- # or the to_id value
78
- validation_alias=AliasChoices(
79
- AliasPath("to_entity", "permalink"),
80
- "to_id",
81
- ),
82
- default=None,
83
- )
84
- to_name: Optional[Permalink] = Field(
85
- # use the permalink from the associated Entity
86
- # or the to_id value
87
- validation_alias=AliasChoices(
88
- AliasPath("to_entity", "title"),
89
- "to_name",
90
- ),
91
- default=None,
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):
@@ -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
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Dict, List, Set
6
6
  from pydantic import BaseModel, Field
7
7
 
8
8
  # avoid cirular imports
9
- if TYPE_CHECKING:
9
+ if TYPE_CHECKING: # pragma: no cover
10
10
  from basic_memory.sync.sync_service import SyncReport
11
11
 
12
12
 
@@ -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")