basic-memory 0.17.1__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.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  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 +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  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 +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -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/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -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.mcp.tools.utils import call_put, call_post, resolve_entity_id
11
+ from basic_memory.telemetry import track_mcp_tool
12
+ from basic_memory.schemas import EntityResponse
13
+ from fastmcp import Context
14
+ from basic_memory.schemas.base import Entity
15
+ from basic_memory.utils import parse_tags, validate_project_path
16
+
17
+ # Define TagType as a Union that can accept either a string or a list of strings or None
18
+ TagType = Union[List[str], str, None]
19
+
20
+
21
+ @mcp.tool(
22
+ description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
23
+ )
24
+ async def write_note(
25
+ title: str,
26
+ content: str,
27
+ folder: str,
28
+ project: Optional[str] = None,
29
+ tags: list[str] | str | None = None,
30
+ note_type: str = "note",
31
+ context: Context | None = None,
32
+ ) -> str:
33
+ """Write a markdown note to the knowledge base.
34
+
35
+ Creates or updates a markdown note with semantic observations and relations.
36
+
37
+ Project Resolution:
38
+ Server resolves projects in this order: Single Project Mode → project parameter → default project.
39
+ If project unknown, use list_memory_projects() or recent_activity() first.
40
+
41
+ The content can include semantic observations and relations using markdown syntax:
42
+
43
+ Observations format:
44
+ `- [category] Observation text #tag1 #tag2 (optional context)`
45
+
46
+ Examples:
47
+ `- [design] Files are the source of truth #architecture (All state comes from files)`
48
+ `- [tech] Using SQLite for storage #implementation`
49
+ `- [note] Need to add error handling #todo`
50
+
51
+ Relations format:
52
+ - Explicit: `- relation_type [[Entity]] (optional context)`
53
+ - Inline: Any `[[Entity]]` reference creates a relation
54
+
55
+ Examples:
56
+ `- depends_on [[Content Parser]] (Need for semantic extraction)`
57
+ `- implements [[Search Spec]] (Initial implementation)`
58
+ `- This feature extends [[Base Design]] and uses [[Core Utils]]`
59
+
60
+ Args:
61
+ title: The title of the note
62
+ content: Markdown content for the note, can include observations and relations
63
+ folder: Folder path relative to project root where the file should be saved.
64
+ Use forward slashes (/) as separators. Use "/" or "" to write to project root.
65
+ Examples: "notes", "projects/2025", "research/ml", "/" (root)
66
+ project: Project name to write to. Optional - server will resolve using the
67
+ hierarchy above. If unknown, use list_memory_projects() to discover
68
+ available projects.
69
+ tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
70
+ Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
71
+ note_type: Type of note to create (stored in frontmatter). Defaults to "note".
72
+ Can be "guide", "report", "config", "person", etc.
73
+ context: Optional FastMCP context for performance caching.
74
+
75
+ Returns:
76
+ A markdown formatted summary of the semantic content, including:
77
+ - Creation/update status with project name
78
+ - File path and checksum
79
+ - Observation counts by category
80
+ - Relation counts (resolved/unresolved)
81
+ - Tags if present
82
+ - Session tracking metadata for project awareness
83
+
84
+ Examples:
85
+ # Assistant flow when project is unknown
86
+ # 1. list_memory_projects() -> Ask user which project
87
+ # 2. User: "Use my-research"
88
+ # 3. write_note(...) and remember "my-research" for session
89
+
90
+ # Create a simple note
91
+ write_note(
92
+ project="my-research",
93
+ title="Meeting Notes",
94
+ folder="meetings",
95
+ content="# Weekly Standup\\n\\n- [decision] Use SQLite for storage #tech"
96
+ )
97
+
98
+ # Create a note with tags and note type
99
+ write_note(
100
+ project="work-project",
101
+ title="API Design",
102
+ folder="specs",
103
+ content="# REST API Specification\\n\\n- implements [[Authentication]]",
104
+ tags=["api", "design"],
105
+ note_type="guide"
106
+ )
107
+
108
+ # Update existing note (same title/folder)
109
+ write_note(
110
+ project="my-research",
111
+ title="Meeting Notes",
112
+ folder="meetings",
113
+ content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech"
114
+ )
115
+
116
+ Raises:
117
+ HTTPError: If project doesn't exist or is inaccessible
118
+ SecurityError: If folder path attempts path traversal
119
+ """
120
+ track_mcp_tool("write_note")
121
+ async with get_client() as client:
122
+ logger.info(
123
+ f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
124
+ )
125
+
126
+ # Get and validate the project (supports optional project parameter)
127
+ active_project = await get_active_project(client, project, context)
128
+
129
+ # Normalize "/" to empty string for root folder (must happen before validation)
130
+ if folder == "/":
131
+ folder = ""
132
+
133
+ # Validate folder path to prevent path traversal attacks
134
+ project_path = active_project.home
135
+ if folder and not validate_project_path(folder, project_path):
136
+ logger.warning(
137
+ "Attempted path traversal attack blocked",
138
+ folder=folder,
139
+ project=active_project.name,
140
+ )
141
+ return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
142
+
143
+ # Process tags using the helper function
144
+ tag_list = parse_tags(tags)
145
+ # Create the entity request
146
+ metadata = {"tags": tag_list} if tag_list else None
147
+ entity = Entity(
148
+ title=title,
149
+ folder=folder,
150
+ entity_type=note_type,
151
+ content_type="text/markdown",
152
+ content=content,
153
+ entity_metadata=metadata,
154
+ )
155
+
156
+ # Try to create the entity first (optimistic create)
157
+ logger.debug(f"Attempting to create entity permalink={entity.permalink}")
158
+ action = "Created" # Default to created
159
+ try:
160
+ url = f"/v2/projects/{active_project.id}/knowledge/entities"
161
+ response = await call_post(client, url, json=entity.model_dump())
162
+ result = EntityResponse.model_validate(response.json())
163
+ action = "Created"
164
+ except Exception as e:
165
+ # If creation failed due to conflict (already exists), try to update
166
+ if (
167
+ "409" in str(e)
168
+ or "conflict" in str(e).lower()
169
+ or "already exists" in str(e).lower()
170
+ ):
171
+ logger.debug(f"Entity exists, updating instead permalink={entity.permalink}")
172
+ try:
173
+ if not entity.permalink:
174
+ raise ValueError("Entity permalink is required for updates")
175
+ entity_id = await resolve_entity_id(client, active_project.id, entity.permalink)
176
+ url = f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}"
177
+ response = await call_put(client, url, json=entity.model_dump())
178
+ result = EntityResponse.model_validate(response.json())
179
+ action = "Updated"
180
+ except Exception as update_error:
181
+ # Re-raise the original error if update also fails
182
+ raise e from update_error
183
+ else:
184
+ # Re-raise if it's not a conflict error
185
+ raise
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} status_code={response.status_code}"
228
+ )
229
+ result = "\n".join(summary)
230
+ return add_project_metadata(result, active_project.name)
@@ -0,0 +1,15 @@
1
+ """Models package for basic-memory."""
2
+
3
+ import basic_memory
4
+ from basic_memory.models.base import Base
5
+ from basic_memory.models.knowledge import Entity, Observation, Relation
6
+ from basic_memory.models.project import Project
7
+
8
+ __all__ = [
9
+ "Base",
10
+ "Entity",
11
+ "Observation",
12
+ "Relation",
13
+ "Project",
14
+ "basic_memory",
15
+ ]
@@ -0,0 +1,10 @@
1
+ """Base model class for SQLAlchemy models."""
2
+
3
+ from sqlalchemy.ext.asyncio import AsyncAttrs
4
+ from sqlalchemy.orm import DeclarativeBase
5
+
6
+
7
+ class Base(AsyncAttrs, DeclarativeBase):
8
+ """Base class for all models"""
9
+
10
+ pass
@@ -0,0 +1,226 @@
1
+ """Knowledge graph models."""
2
+
3
+ from datetime import datetime
4
+ from basic_memory.utils import ensure_timezone_aware
5
+ from typing import Optional
6
+
7
+ from sqlalchemy import (
8
+ Integer,
9
+ String,
10
+ Text,
11
+ ForeignKey,
12
+ UniqueConstraint,
13
+ DateTime,
14
+ Index,
15
+ JSON,
16
+ Float,
17
+ text,
18
+ )
19
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
20
+
21
+ from basic_memory.models.base import Base
22
+ from basic_memory.utils import generate_permalink
23
+
24
+
25
+ class Entity(Base):
26
+ """Core entity in the knowledge graph.
27
+
28
+ Entities represent semantic nodes maintained by the AI layer. Each entity:
29
+ - Has a unique numeric ID (database-generated)
30
+ - Maps to a file on disk
31
+ - Maintains a checksum for change detection
32
+ - Tracks both source file and semantic properties
33
+ - Belongs to a specific project
34
+ """
35
+
36
+ __tablename__ = "entity"
37
+ __table_args__ = (
38
+ # Regular indexes
39
+ Index("ix_entity_type", "entity_type"),
40
+ Index("ix_entity_title", "title"),
41
+ Index("ix_entity_created_at", "created_at"), # For timeline queries
42
+ Index("ix_entity_updated_at", "updated_at"), # For timeline queries
43
+ Index("ix_entity_project_id", "project_id"), # For project filtering
44
+ # Project-specific uniqueness constraints
45
+ Index(
46
+ "uix_entity_permalink_project",
47
+ "permalink",
48
+ "project_id",
49
+ unique=True,
50
+ sqlite_where=text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
51
+ ),
52
+ Index(
53
+ "uix_entity_file_path_project",
54
+ "file_path",
55
+ "project_id",
56
+ unique=True,
57
+ ),
58
+ )
59
+
60
+ # Core identity
61
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
62
+ title: Mapped[str] = mapped_column(String)
63
+ entity_type: Mapped[str] = mapped_column(String)
64
+ entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
65
+ content_type: Mapped[str] = mapped_column(String)
66
+
67
+ # Project reference
68
+ project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), nullable=False)
69
+
70
+ # Normalized path for URIs - required for markdown files only
71
+ permalink: Mapped[Optional[str]] = mapped_column(String, nullable=True, index=True)
72
+ # Actual filesystem relative path
73
+ file_path: Mapped[str] = mapped_column(String, index=True)
74
+ # checksum of file
75
+ checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
76
+
77
+ # File metadata for sync
78
+ # mtime: file modification timestamp (Unix epoch float) for change detection
79
+ mtime: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
80
+ # size: file size in bytes for quick change detection
81
+ size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
82
+
83
+ # Metadata and tracking
84
+ created_at: Mapped[datetime] = mapped_column(
85
+ DateTime(timezone=True), default=lambda: datetime.now().astimezone()
86
+ )
87
+ updated_at: Mapped[datetime] = mapped_column(
88
+ DateTime(timezone=True),
89
+ default=lambda: datetime.now().astimezone(),
90
+ onupdate=lambda: datetime.now().astimezone(),
91
+ )
92
+
93
+ # Relationships
94
+ project = relationship("Project", back_populates="entities")
95
+ observations = relationship(
96
+ "Observation", back_populates="entity", cascade="all, delete-orphan"
97
+ )
98
+ outgoing_relations = relationship(
99
+ "Relation",
100
+ back_populates="from_entity",
101
+ foreign_keys="[Relation.from_id]",
102
+ cascade="all, delete-orphan",
103
+ )
104
+ incoming_relations = relationship(
105
+ "Relation",
106
+ back_populates="to_entity",
107
+ foreign_keys="[Relation.to_id]",
108
+ cascade="all, delete-orphan",
109
+ )
110
+
111
+ @property
112
+ def relations(self):
113
+ """Get all relations (incoming and outgoing) for this entity."""
114
+ return self.incoming_relations + self.outgoing_relations
115
+
116
+ @property
117
+ def is_markdown(self):
118
+ """Check if the entity is a markdown file."""
119
+ return self.content_type == "text/markdown"
120
+
121
+ def __getattribute__(self, name):
122
+ """Override attribute access to ensure datetime fields are timezone-aware."""
123
+ value = super().__getattribute__(name)
124
+
125
+ # Ensure datetime fields are timezone-aware
126
+ if name in ("created_at", "updated_at") and isinstance(value, datetime):
127
+ return ensure_timezone_aware(value)
128
+
129
+ return value
130
+
131
+ def __repr__(self) -> str:
132
+ return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}', checksum='{self.checksum}')"
133
+
134
+
135
+ class Observation(Base):
136
+ """An observation about an entity.
137
+
138
+ Observations are atomic facts or notes about an entity.
139
+ """
140
+
141
+ __tablename__ = "observation"
142
+ __table_args__ = (
143
+ Index("ix_observation_entity_id", "entity_id"), # Add FK index
144
+ Index("ix_observation_category", "category"), # Add category index
145
+ )
146
+
147
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
148
+ project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
149
+ entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
150
+ content: Mapped[str] = mapped_column(Text)
151
+ category: Mapped[str] = mapped_column(String, nullable=False, default="note")
152
+ context: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
153
+ tags: Mapped[Optional[list[str]]] = mapped_column(
154
+ JSON, nullable=True, default=list, server_default="[]"
155
+ )
156
+
157
+ # Relationships
158
+ entity = relationship("Entity", back_populates="observations")
159
+
160
+ @property
161
+ def permalink(self) -> str:
162
+ """Create synthetic permalink for the observation.
163
+
164
+ We can construct these because observations are always defined in
165
+ and owned by a single entity.
166
+
167
+ Content is truncated to 200 chars to stay under PostgreSQL's
168
+ btree index limit of 2704 bytes.
169
+ """
170
+ # Truncate content to avoid exceeding PostgreSQL's btree index limit
171
+ content_for_permalink = self.content[:200] if len(self.content) > 200 else self.content
172
+ return generate_permalink(
173
+ f"{self.entity.permalink}/observations/{self.category}/{content_for_permalink}"
174
+ )
175
+
176
+ def __repr__(self) -> str: # pragma: no cover
177
+ return f"Observation(id={self.id}, entity_id={self.entity_id}, content='{self.content}')"
178
+
179
+
180
+ class Relation(Base):
181
+ """A directed relation between two entities."""
182
+
183
+ __tablename__ = "relation"
184
+ __table_args__ = (
185
+ UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation_from_id_to_id"),
186
+ UniqueConstraint(
187
+ "from_id", "to_name", "relation_type", name="uix_relation_from_id_to_name"
188
+ ),
189
+ Index("ix_relation_type", "relation_type"),
190
+ Index("ix_relation_from_id", "from_id"), # Add FK indexes
191
+ Index("ix_relation_to_id", "to_id"),
192
+ )
193
+
194
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
195
+ project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
196
+ from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
197
+ to_id: Mapped[Optional[int]] = mapped_column(
198
+ Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True
199
+ )
200
+ to_name: Mapped[str] = mapped_column(String)
201
+ relation_type: Mapped[str] = mapped_column(String)
202
+ context: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
203
+
204
+ # Relationships
205
+ from_entity = relationship(
206
+ "Entity", foreign_keys=[from_id], back_populates="outgoing_relations"
207
+ )
208
+ to_entity = relationship("Entity", foreign_keys=[to_id], back_populates="incoming_relations")
209
+
210
+ @property
211
+ def permalink(self) -> str:
212
+ """Create relation permalink showing the semantic connection.
213
+
214
+ Format: source/relation_type/target
215
+ Example: "specs/search/implements/features/search-ui"
216
+ """
217
+ # Only create permalinks when both source and target have permalinks
218
+ from_permalink = self.from_entity.permalink or self.from_entity.file_path
219
+
220
+ if self.to_entity:
221
+ to_permalink = self.to_entity.permalink or self.to_entity.file_path
222
+ return generate_permalink(f"{from_permalink}/{self.relation_type}/{to_permalink}")
223
+ return generate_permalink(f"{from_permalink}/{self.relation_type}/{self.to_name}")
224
+
225
+ def __repr__(self) -> str:
226
+ 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,87 @@
1
+ """Project model for Basic Memory."""
2
+
3
+ from datetime import datetime, UTC
4
+ from typing import Optional
5
+
6
+ from sqlalchemy import (
7
+ Integer,
8
+ String,
9
+ Text,
10
+ Boolean,
11
+ DateTime,
12
+ Float,
13
+ Index,
14
+ event,
15
+ )
16
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
17
+
18
+ from basic_memory.models.base import Base
19
+ from basic_memory.utils import generate_permalink
20
+
21
+
22
+ class Project(Base):
23
+ """Project model for Basic Memory.
24
+
25
+ A project represents a collection of knowledge entities that are grouped together.
26
+ Projects are stored in the app-level database and provide context for all knowledge
27
+ operations.
28
+ """
29
+
30
+ __tablename__ = "project"
31
+ __table_args__ = (
32
+ # Regular indexes
33
+ Index("ix_project_name", "name", unique=True),
34
+ Index("ix_project_permalink", "permalink", unique=True),
35
+ Index("ix_project_path", "path"),
36
+ Index("ix_project_created_at", "created_at"),
37
+ Index("ix_project_updated_at", "updated_at"),
38
+ )
39
+
40
+ # Core identity
41
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
42
+ name: Mapped[str] = mapped_column(String, unique=True)
43
+ description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
44
+
45
+ # URL-friendly identifier generated from name
46
+ permalink: Mapped[str] = mapped_column(String, unique=True)
47
+
48
+ # Filesystem path to project directory
49
+ path: Mapped[str] = mapped_column(String)
50
+
51
+ # Status flags
52
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
53
+ is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
54
+
55
+ # Timestamps
56
+ created_at: Mapped[datetime] = mapped_column(
57
+ DateTime(timezone=True), default=lambda: datetime.now(UTC)
58
+ )
59
+ updated_at: Mapped[datetime] = mapped_column(
60
+ DateTime(timezone=True),
61
+ default=lambda: datetime.now(UTC),
62
+ onupdate=lambda: datetime.now(UTC),
63
+ )
64
+
65
+ # Sync optimization - scan watermark tracking
66
+ last_scan_timestamp: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
67
+ last_file_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
68
+
69
+ # Define relationships to entities, observations, and relations
70
+ # These relationships will be established once we add project_id to those models
71
+ entities = relationship("Entity", back_populates="project", cascade="all, delete-orphan")
72
+
73
+ def __repr__(self) -> str: # pragma: no cover
74
+ return f"Project(id={self.id}, name='{self.name}', permalink='{self.permalink}', path='{self.path}')"
75
+
76
+
77
+ @event.listens_for(Project, "before_insert")
78
+ @event.listens_for(Project, "before_update")
79
+ def set_project_permalink(mapper, connection, project):
80
+ """Generate URL-friendly permalink for the project if needed.
81
+
82
+ This event listener ensures the permalink is always derived from the name,
83
+ even if the name changes.
84
+ """
85
+ # If the name changed or permalink is empty, regenerate permalink
86
+ if not project.permalink or project.permalink != generate_permalink(project.name):
87
+ project.permalink = generate_permalink(project.name)
@@ -0,0 +1,85 @@
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
+ """
8
+
9
+ from sqlalchemy import DDL
10
+
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
+ # Define FTS5 virtual table creation for SQLite only
52
+ # This DDL is executed separately for SQLite databases
53
+ CREATE_SEARCH_INDEX = DDL("""
54
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
55
+ -- Core entity fields
56
+ id UNINDEXED, -- Row ID
57
+ title, -- Title for searching
58
+ content_stems, -- Main searchable content split into stems
59
+ content_snippet, -- File content snippet for display
60
+ permalink, -- Stable identifier (now indexed for path search)
61
+ file_path UNINDEXED, -- Physical location
62
+ type UNINDEXED, -- entity/relation/observation
63
+
64
+ -- Project context
65
+ project_id UNINDEXED, -- Project identifier
66
+
67
+ -- Relation fields
68
+ from_id UNINDEXED, -- Source entity
69
+ to_id UNINDEXED, -- Target entity
70
+ relation_type UNINDEXED, -- Type of relation
71
+
72
+ -- Observation fields
73
+ entity_id UNINDEXED, -- Parent entity
74
+ category UNINDEXED, -- Observation category
75
+
76
+ -- Common fields
77
+ metadata UNINDEXED, -- JSON metadata
78
+ created_at UNINDEXED, -- Creation timestamp
79
+ updated_at UNINDEXED, -- Last update
80
+
81
+ -- Configuration
82
+ tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
83
+ prefix='1,2,3,4' -- Support longer prefixes for paths
84
+ );
85
+ """)
@@ -0,0 +1,11 @@
1
+ from .entity_repository import EntityRepository
2
+ from .observation_repository import ObservationRepository
3
+ from .project_repository import ProjectRepository
4
+ from .relation_repository import RelationRepository
5
+
6
+ __all__ = [
7
+ "EntityRepository",
8
+ "ObservationRepository",
9
+ "ProjectRepository",
10
+ "RelationRepository",
11
+ ]