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.

Files changed (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,230 @@
1
+ """Write note tool for Basic Memory MCP server."""
2
+
3
+ from typing import List, Union, Optional
4
+
5
+ from loguru import logger
6
+
7
+ from basic_memory.mcp.async_client import get_client
8
+ from basic_memory.mcp.project_context import get_active_project, add_project_metadata
9
+ from basic_memory.mcp.server import mcp
10
+ from basic_memory.telemetry import track_mcp_tool
11
+ from fastmcp import Context
12
+ from basic_memory.schemas.base import Entity
13
+ from basic_memory.utils import parse_tags, validate_project_path
14
+
15
+ # Define TagType as a Union that can accept either a string or a list of strings or None
16
+ TagType = Union[List[str], str, None]
17
+
18
+
19
+ @mcp.tool(
20
+ description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
21
+ )
22
+ async def write_note(
23
+ title: str,
24
+ content: str,
25
+ folder: str,
26
+ project: Optional[str] = None,
27
+ tags: list[str] | str | None = None,
28
+ note_type: str = "note",
29
+ context: Context | None = None,
30
+ ) -> str:
31
+ """Write a markdown note to the knowledge base.
32
+
33
+ Creates or updates a markdown note with semantic observations and relations.
34
+
35
+ Project Resolution:
36
+ Server resolves projects in this order: Single Project Mode → project parameter → default project.
37
+ If project unknown, use list_memory_projects() or recent_activity() first.
38
+
39
+ The content can include semantic observations and relations using markdown syntax:
40
+
41
+ Observations format:
42
+ `- [category] Observation text #tag1 #tag2 (optional context)`
43
+
44
+ Examples:
45
+ `- [design] Files are the source of truth #architecture (All state comes from files)`
46
+ `- [tech] Using SQLite for storage #implementation`
47
+ `- [note] Need to add error handling #todo`
48
+
49
+ Relations format:
50
+ - Explicit: `- relation_type [[Entity]] (optional context)`
51
+ - Inline: Any `[[Entity]]` reference creates a relation
52
+
53
+ Examples:
54
+ `- depends_on [[Content Parser]] (Need for semantic extraction)`
55
+ `- implements [[Search Spec]] (Initial implementation)`
56
+ `- This feature extends [[Base Design]] and uses [[Core Utils]]`
57
+
58
+ Args:
59
+ title: The title of the note
60
+ content: Markdown content for the note, can include observations and relations
61
+ folder: Folder path relative to project root where the file should be saved.
62
+ Use forward slashes (/) as separators. Use "/" or "" to write to project root.
63
+ Examples: "notes", "projects/2025", "research/ml", "/" (root)
64
+ project: Project name to write to. Optional - server will resolve using the
65
+ hierarchy above. If unknown, use list_memory_projects() to discover
66
+ available projects.
67
+ tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
68
+ Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
69
+ note_type: Type of note to create (stored in frontmatter). Defaults to "note".
70
+ Can be "guide", "report", "config", "person", etc.
71
+ context: Optional FastMCP context for performance caching.
72
+
73
+ Returns:
74
+ A markdown formatted summary of the semantic content, including:
75
+ - Creation/update status with project name
76
+ - File path and checksum
77
+ - Observation counts by category
78
+ - Relation counts (resolved/unresolved)
79
+ - Tags if present
80
+ - Session tracking metadata for project awareness
81
+
82
+ Examples:
83
+ # Assistant flow when project is unknown
84
+ # 1. list_memory_projects() -> Ask user which project
85
+ # 2. User: "Use my-research"
86
+ # 3. write_note(...) and remember "my-research" for session
87
+
88
+ # Create a simple note
89
+ write_note(
90
+ project="my-research",
91
+ title="Meeting Notes",
92
+ folder="meetings",
93
+ content="# Weekly Standup\\n\\n- [decision] Use SQLite for storage #tech"
94
+ )
95
+
96
+ # Create a note with tags and note type
97
+ write_note(
98
+ project="work-project",
99
+ title="API Design",
100
+ folder="specs",
101
+ content="# REST API Specification\\n\\n- implements [[Authentication]]",
102
+ tags=["api", "design"],
103
+ note_type="guide"
104
+ )
105
+
106
+ # Update existing note (same title/folder)
107
+ write_note(
108
+ project="my-research",
109
+ title="Meeting Notes",
110
+ folder="meetings",
111
+ content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech"
112
+ )
113
+
114
+ Raises:
115
+ HTTPError: If project doesn't exist or is inaccessible
116
+ SecurityError: If folder path attempts path traversal
117
+ """
118
+ track_mcp_tool("write_note")
119
+ async with get_client() as client:
120
+ logger.info(
121
+ f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
122
+ )
123
+
124
+ # Get and validate the project (supports optional project parameter)
125
+ active_project = await get_active_project(client, project, context)
126
+
127
+ # Normalize "/" to empty string for root folder (must happen before validation)
128
+ if folder == "/":
129
+ folder = ""
130
+
131
+ # Validate folder path to prevent path traversal attacks
132
+ project_path = active_project.home
133
+ if folder and not validate_project_path(folder, project_path):
134
+ logger.warning(
135
+ "Attempted path traversal attack blocked",
136
+ folder=folder,
137
+ project=active_project.name,
138
+ )
139
+ return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
140
+
141
+ # Process tags using the helper function
142
+ tag_list = parse_tags(tags)
143
+ # Create the entity request
144
+ metadata = {"tags": tag_list} if tag_list else None
145
+ entity = Entity(
146
+ title=title,
147
+ folder=folder,
148
+ entity_type=note_type,
149
+ content_type="text/markdown",
150
+ content=content,
151
+ entity_metadata=metadata,
152
+ )
153
+
154
+ # Import here to avoid circular import
155
+ from basic_memory.mcp.clients import KnowledgeClient
156
+
157
+ # Use typed KnowledgeClient for API calls
158
+ knowledge_client = KnowledgeClient(client, active_project.external_id)
159
+
160
+ # Try to create the entity first (optimistic create)
161
+ logger.debug(f"Attempting to create entity permalink={entity.permalink}")
162
+ action = "Created" # Default to created
163
+ try:
164
+ result = await knowledge_client.create_entity(entity.model_dump())
165
+ action = "Created"
166
+ except Exception as e:
167
+ # If creation failed due to conflict (already exists), try to update
168
+ if (
169
+ "409" in str(e)
170
+ or "conflict" in str(e).lower()
171
+ or "already exists" in str(e).lower()
172
+ ):
173
+ logger.debug(f"Entity exists, updating instead permalink={entity.permalink}")
174
+ try:
175
+ if not entity.permalink:
176
+ raise ValueError("Entity permalink is required for updates") # pragma: no cover
177
+ entity_id = await knowledge_client.resolve_entity(entity.permalink)
178
+ result = await knowledge_client.update_entity(entity_id, entity.model_dump())
179
+ action = "Updated"
180
+ except Exception as update_error: # pragma: no cover
181
+ # Re-raise the original error if update also fails
182
+ raise e from update_error # pragma: no cover
183
+ else:
184
+ # Re-raise if it's not a conflict error
185
+ raise # pragma: no cover
186
+ summary = [
187
+ f"# {action} note",
188
+ f"project: {active_project.name}",
189
+ f"file_path: {result.file_path}",
190
+ f"permalink: {result.permalink}",
191
+ f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
192
+ ]
193
+
194
+ # Count observations by category
195
+ categories = {}
196
+ if result.observations:
197
+ for obs in result.observations:
198
+ categories[obs.category] = categories.get(obs.category, 0) + 1
199
+
200
+ summary.append("\n## Observations")
201
+ for category, count in sorted(categories.items()):
202
+ summary.append(f"- {category}: {count}")
203
+
204
+ # Count resolved/unresolved relations
205
+ unresolved = 0
206
+ resolved = 0
207
+ if result.relations:
208
+ unresolved = sum(1 for r in result.relations if not r.to_id)
209
+ resolved = len(result.relations) - unresolved
210
+
211
+ summary.append("\n## Relations")
212
+ summary.append(f"- Resolved: {resolved}")
213
+ if unresolved:
214
+ summary.append(f"- Unresolved: {unresolved}")
215
+ summary.append(
216
+ "\nNote: Unresolved relations point to entities that don't exist yet."
217
+ )
218
+ summary.append(
219
+ "They will be automatically resolved when target entities are created or during sync operations."
220
+ )
221
+
222
+ if tag_list:
223
+ summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
224
+
225
+ # Log the response with structured data
226
+ logger.info(
227
+ f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}"
228
+ )
229
+ summary_result = "\n".join(summary)
230
+ return add_project_metadata(summary_result, active_project.name)
@@ -3,12 +3,13 @@
3
3
  import basic_memory
4
4
  from basic_memory.models.base import Base
5
5
  from basic_memory.models.knowledge import Entity, Observation, Relation
6
-
7
- SCHEMA_VERSION = basic_memory.__version__ + "-" + "003"
6
+ from basic_memory.models.project import Project
8
7
 
9
8
  __all__ = [
10
9
  "Base",
11
10
  "Entity",
12
11
  "Observation",
13
12
  "Relation",
13
+ "Project",
14
+ "basic_memory",
14
15
  ]
@@ -1,6 +1,8 @@
1
1
  """Knowledge graph models."""
2
2
 
3
+ import uuid
3
4
  from datetime import datetime
5
+ from basic_memory.utils import ensure_timezone_aware
4
6
  from typing import Optional
5
7
 
6
8
  from sqlalchemy import (
@@ -12,11 +14,12 @@ from sqlalchemy import (
12
14
  DateTime,
13
15
  Index,
14
16
  JSON,
17
+ Float,
18
+ text,
15
19
  )
16
20
  from sqlalchemy.orm import Mapped, mapped_column, relationship
17
21
 
18
22
  from basic_memory.models.base import Base
19
-
20
23
  from basic_memory.utils import generate_permalink
21
24
 
22
25
 
@@ -28,36 +31,73 @@ class Entity(Base):
28
31
  - Maps to a file on disk
29
32
  - Maintains a checksum for change detection
30
33
  - Tracks both source file and semantic properties
34
+ - Belongs to a specific project
31
35
  """
32
36
 
33
37
  __tablename__ = "entity"
34
38
  __table_args__ = (
35
- UniqueConstraint("permalink", name="uix_entity_permalink"), # Make permalink unique
39
+ # Regular indexes
36
40
  Index("ix_entity_type", "entity_type"),
37
41
  Index("ix_entity_title", "title"),
42
+ Index("ix_entity_external_id", "external_id", unique=True),
38
43
  Index("ix_entity_created_at", "created_at"), # For timeline queries
39
44
  Index("ix_entity_updated_at", "updated_at"), # For timeline queries
45
+ Index("ix_entity_project_id", "project_id"), # For project filtering
46
+ # Project-specific uniqueness constraints
47
+ Index(
48
+ "uix_entity_permalink_project",
49
+ "permalink",
50
+ "project_id",
51
+ unique=True,
52
+ sqlite_where=text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
53
+ ),
54
+ Index(
55
+ "uix_entity_file_path_project",
56
+ "file_path",
57
+ "project_id",
58
+ unique=True,
59
+ ),
40
60
  )
41
61
 
42
62
  # Core identity
43
63
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
64
+ # External UUID for API references - stable identifier that won't change
65
+ external_id: Mapped[str] = mapped_column(
66
+ String, unique=True, default=lambda: str(uuid.uuid4())
67
+ )
44
68
  title: Mapped[str] = mapped_column(String)
45
69
  entity_type: Mapped[str] = mapped_column(String)
46
70
  entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
47
71
  content_type: Mapped[str] = mapped_column(String)
48
72
 
49
- # Normalized path for URIs
50
- permalink: Mapped[str] = mapped_column(String, unique=True, index=True)
73
+ # Project reference
74
+ project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), nullable=False)
75
+
76
+ # Normalized path for URIs - required for markdown files only
77
+ permalink: Mapped[Optional[str]] = mapped_column(String, nullable=True, index=True)
51
78
  # Actual filesystem relative path
52
- file_path: Mapped[str] = mapped_column(String, unique=True, index=True)
79
+ file_path: Mapped[str] = mapped_column(String, index=True)
53
80
  # checksum of file
54
81
  checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
55
82
 
83
+ # File metadata for sync
84
+ # mtime: file modification timestamp (Unix epoch float) for change detection
85
+ mtime: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
86
+ # size: file size in bytes for quick change detection
87
+ size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
88
+
56
89
  # Metadata and tracking
57
- created_at: Mapped[datetime] = mapped_column(DateTime)
58
- updated_at: Mapped[datetime] = mapped_column(DateTime)
90
+ created_at: Mapped[datetime] = mapped_column(
91
+ DateTime(timezone=True), default=lambda: datetime.now().astimezone()
92
+ )
93
+ updated_at: Mapped[datetime] = mapped_column(
94
+ DateTime(timezone=True),
95
+ default=lambda: datetime.now().astimezone(),
96
+ onupdate=lambda: datetime.now().astimezone(),
97
+ )
59
98
 
60
99
  # Relationships
100
+ project = relationship("Project", back_populates="entities")
61
101
  observations = relationship(
62
102
  "Observation", back_populates="entity", cascade="all, delete-orphan"
63
103
  )
@@ -79,8 +119,23 @@ class Entity(Base):
79
119
  """Get all relations (incoming and outgoing) for this entity."""
80
120
  return self.incoming_relations + self.outgoing_relations
81
121
 
122
+ @property
123
+ def is_markdown(self):
124
+ """Check if the entity is a markdown file."""
125
+ return self.content_type == "text/markdown"
126
+
127
+ def __getattribute__(self, name):
128
+ """Override attribute access to ensure datetime fields are timezone-aware."""
129
+ value = super().__getattribute__(name)
130
+
131
+ # Ensure datetime fields are timezone-aware
132
+ if name in ("created_at", "updated_at") and isinstance(value, datetime):
133
+ return ensure_timezone_aware(value)
134
+
135
+ return value
136
+
82
137
  def __repr__(self) -> str:
83
- return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
138
+ return f"Entity(id={self.id}, external_id='{self.external_id}', name='{self.title}', type='{self.entity_type}', checksum='{self.checksum}')"
84
139
 
85
140
 
86
141
  class Observation(Base):
@@ -96,6 +151,7 @@ class Observation(Base):
96
151
  )
97
152
 
98
153
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
154
+ project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
99
155
  entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
100
156
  content: Mapped[str] = mapped_column(Text)
101
157
  category: Mapped[str] = mapped_column(String, nullable=False, default="note")
@@ -113,9 +169,14 @@ class Observation(Base):
113
169
 
114
170
  We can construct these because observations are always defined in
115
171
  and owned by a single entity.
172
+
173
+ Content is truncated to 200 chars to stay under PostgreSQL's
174
+ btree index limit of 2704 bytes.
116
175
  """
176
+ # Truncate content to avoid exceeding PostgreSQL's btree index limit
177
+ content_for_permalink = self.content[:200] if len(self.content) > 200 else self.content
117
178
  return generate_permalink(
118
- f"{self.entity.permalink}/observations/{self.category}/{self.content}"
179
+ f"{self.entity.permalink}/observations/{self.category}/{content_for_permalink}"
119
180
  )
120
181
 
121
182
  def __repr__(self) -> str: # pragma: no cover
@@ -127,13 +188,17 @@ class Relation(Base):
127
188
 
128
189
  __tablename__ = "relation"
129
190
  __table_args__ = (
130
- UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation"),
191
+ UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation_from_id_to_id"),
192
+ UniqueConstraint(
193
+ "from_id", "to_name", "relation_type", name="uix_relation_from_id_to_name"
194
+ ),
131
195
  Index("ix_relation_type", "relation_type"),
132
196
  Index("ix_relation_from_id", "from_id"), # Add FK indexes
133
197
  Index("ix_relation_to_id", "to_id"),
134
198
  )
135
199
 
136
200
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
201
+ project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
137
202
  from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
138
203
  to_id: Mapped[Optional[int]] = mapped_column(
139
204
  Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True
@@ -155,13 +220,13 @@ class Relation(Base):
155
220
  Format: source/relation_type/target
156
221
  Example: "specs/search/implements/features/search-ui"
157
222
  """
223
+ # Only create permalinks when both source and target have permalinks
224
+ from_permalink = self.from_entity.permalink or self.from_entity.file_path
225
+
158
226
  if self.to_entity:
159
- return generate_permalink(
160
- f"{self.from_entity.permalink}/{self.relation_type}/{self.to_entity.permalink}"
161
- )
162
- return generate_permalink(
163
- f"{self.from_entity.permalink}/{self.relation_type}/{self.to_name}"
164
- )
227
+ to_permalink = self.to_entity.permalink or self.to_entity.file_path
228
+ return generate_permalink(f"{from_permalink}/{self.relation_type}/{to_permalink}")
229
+ return generate_permalink(f"{from_permalink}/{self.relation_type}/{self.to_name}")
165
230
 
166
231
  def __repr__(self) -> str:
167
- return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')"
232
+ return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')" # pragma: no cover
@@ -0,0 +1,93 @@
1
+ """Project model for Basic Memory."""
2
+
3
+ import uuid
4
+ from datetime import datetime, UTC
5
+ from typing import Optional
6
+
7
+ from sqlalchemy import (
8
+ Integer,
9
+ String,
10
+ Text,
11
+ Boolean,
12
+ DateTime,
13
+ Float,
14
+ Index,
15
+ event,
16
+ )
17
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
18
+
19
+ from basic_memory.models.base import Base
20
+ from basic_memory.utils import generate_permalink
21
+
22
+
23
+ class Project(Base):
24
+ """Project model for Basic Memory.
25
+
26
+ A project represents a collection of knowledge entities that are grouped together.
27
+ Projects are stored in the app-level database and provide context for all knowledge
28
+ operations.
29
+ """
30
+
31
+ __tablename__ = "project"
32
+ __table_args__ = (
33
+ # Regular indexes
34
+ Index("ix_project_name", "name", unique=True),
35
+ Index("ix_project_permalink", "permalink", unique=True),
36
+ Index("ix_project_external_id", "external_id", unique=True),
37
+ Index("ix_project_path", "path"),
38
+ Index("ix_project_created_at", "created_at"),
39
+ Index("ix_project_updated_at", "updated_at"),
40
+ )
41
+
42
+ # Core identity
43
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
44
+ # External UUID for API references - stable identifier that won't change
45
+ external_id: Mapped[str] = mapped_column(
46
+ String, unique=True, default=lambda: str(uuid.uuid4())
47
+ )
48
+ name: Mapped[str] = mapped_column(String, unique=True)
49
+ description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
50
+
51
+ # URL-friendly identifier generated from name
52
+ permalink: Mapped[str] = mapped_column(String, unique=True)
53
+
54
+ # Filesystem path to project directory
55
+ path: Mapped[str] = mapped_column(String)
56
+
57
+ # Status flags
58
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
59
+ is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
60
+
61
+ # Timestamps
62
+ created_at: Mapped[datetime] = mapped_column(
63
+ DateTime(timezone=True), default=lambda: datetime.now(UTC)
64
+ )
65
+ updated_at: Mapped[datetime] = mapped_column(
66
+ DateTime(timezone=True),
67
+ default=lambda: datetime.now(UTC),
68
+ onupdate=lambda: datetime.now(UTC),
69
+ )
70
+
71
+ # Sync optimization - scan watermark tracking
72
+ last_scan_timestamp: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
73
+ last_file_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
74
+
75
+ # Define relationships to entities, observations, and relations
76
+ # These relationships will be established once we add project_id to those models
77
+ entities = relationship("Entity", back_populates="project", cascade="all, delete-orphan")
78
+
79
+ def __repr__(self) -> str: # pragma: no cover
80
+ return f"Project(id={self.id}, external_id='{self.external_id}', name='{self.name}', permalink='{self.permalink}', path='{self.path}')"
81
+
82
+
83
+ @event.listens_for(Project, "before_insert")
84
+ @event.listens_for(Project, "before_update")
85
+ def set_project_permalink(mapper, connection, project):
86
+ """Generate URL-friendly permalink for the project if needed.
87
+
88
+ This event listener ensures the permalink is always derived from the name,
89
+ even if the name changes.
90
+ """
91
+ # If the name changed or permalink is empty, regenerate permalink
92
+ if not project.permalink or project.permalink != generate_permalink(project.name):
93
+ project.permalink = generate_permalink(project.name)
@@ -1,32 +1,92 @@
1
- """Search models and tables."""
1
+ """Search DDL statements for SQLite and Postgres.
2
+
3
+ The search_index table is created via raw DDL, not ORM models, because:
4
+ - SQLite uses FTS5 virtual tables (cannot be represented as ORM)
5
+ - Postgres uses composite primary keys and generated tsvector columns
6
+ - Both backends use raw SQL for all search operations via SearchIndexRow dataclass
7
+ """
2
8
 
3
9
  from sqlalchemy import DDL
4
10
 
5
- # Define FTS5 virtual table creation
11
+
12
+ # Define Postgres search_index table with composite primary key and tsvector
13
+ # This DDL matches the Alembic migration schema (314f1ea54dc4)
14
+ # Used by tests to create the table without running full migrations
15
+ # NOTE: Split into separate DDL statements because asyncpg doesn't support
16
+ # multiple statements in a single execute call.
17
+ CREATE_POSTGRES_SEARCH_INDEX_TABLE = DDL("""
18
+ CREATE TABLE IF NOT EXISTS search_index (
19
+ id INTEGER NOT NULL,
20
+ project_id INTEGER NOT NULL,
21
+ title TEXT,
22
+ content_stems TEXT,
23
+ content_snippet TEXT,
24
+ permalink VARCHAR,
25
+ file_path VARCHAR,
26
+ type VARCHAR,
27
+ from_id INTEGER,
28
+ to_id INTEGER,
29
+ relation_type VARCHAR,
30
+ entity_id INTEGER,
31
+ category VARCHAR,
32
+ metadata JSONB,
33
+ created_at TIMESTAMP WITH TIME ZONE,
34
+ updated_at TIMESTAMP WITH TIME ZONE,
35
+ textsearchable_index_col tsvector GENERATED ALWAYS AS (
36
+ to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content_stems, ''))
37
+ ) STORED,
38
+ PRIMARY KEY (id, type, project_id),
39
+ FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
40
+ )
41
+ """)
42
+
43
+ CREATE_POSTGRES_SEARCH_INDEX_FTS = DDL("""
44
+ CREATE INDEX IF NOT EXISTS idx_search_index_fts ON search_index USING gin(textsearchable_index_col)
45
+ """)
46
+
47
+ CREATE_POSTGRES_SEARCH_INDEX_METADATA = DDL("""
48
+ CREATE INDEX IF NOT EXISTS idx_search_index_metadata_gin ON search_index USING gin(metadata jsonb_path_ops)
49
+ """)
50
+
51
+ # Partial unique index on (permalink, project_id) for non-null permalinks
52
+ # This prevents duplicate permalinks per project and is used by upsert operations
53
+ # in PostgresSearchRepository to handle race conditions during parallel indexing
54
+ CREATE_POSTGRES_SEARCH_INDEX_PERMALINK = DDL("""
55
+ CREATE UNIQUE INDEX IF NOT EXISTS uix_search_index_permalink_project
56
+ ON search_index (permalink, project_id)
57
+ WHERE permalink IS NOT NULL
58
+ """)
59
+
60
+ # Define FTS5 virtual table creation for SQLite only
61
+ # This DDL is executed separately for SQLite databases
6
62
  CREATE_SEARCH_INDEX = DDL("""
7
63
  CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
8
64
  -- Core entity fields
9
65
  id UNINDEXED, -- Row ID
10
66
  title, -- Title for searching
11
- content, -- Main searchable content
67
+ content_stems, -- Main searchable content split into stems
68
+ content_snippet, -- File content snippet for display
12
69
  permalink, -- Stable identifier (now indexed for path search)
13
70
  file_path UNINDEXED, -- Physical location
14
71
  type UNINDEXED, -- entity/relation/observation
15
-
16
- -- Relation fields
72
+
73
+ -- Project context
74
+ project_id UNINDEXED, -- Project identifier
75
+
76
+ -- Relation fields
17
77
  from_id UNINDEXED, -- Source entity
18
78
  to_id UNINDEXED, -- Target entity
19
79
  relation_type UNINDEXED, -- Type of relation
20
-
80
+
21
81
  -- Observation fields
22
82
  entity_id UNINDEXED, -- Parent entity
23
83
  category UNINDEXED, -- Observation category
24
-
84
+
25
85
  -- Common fields
26
86
  metadata UNINDEXED, -- JSON metadata
27
87
  created_at UNINDEXED, -- Creation timestamp
28
88
  updated_at UNINDEXED, -- Last update
29
-
89
+
30
90
  -- Configuration
31
91
  tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
32
92
  prefix='1,2,3,4' -- Support longer prefixes for paths