basic-memory 0.2.12__py3-none-any.whl → 0.16.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.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (149) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +63 -31
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +165 -28
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +28 -67
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +219 -14
  23. basic_memory/api/routers/search_router.py +21 -13
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +52 -1
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +26 -7
  41. basic_memory/cli/commands/import_chatgpt.py +83 -0
  42. basic_memory/cli/commands/import_claude_conversations.py +86 -0
  43. basic_memory/cli/commands/import_claude_projects.py +85 -0
  44. basic_memory/cli/commands/import_memory_json.py +35 -92
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +47 -30
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +13 -6
  50. basic_memory/config.py +481 -22
  51. basic_memory/db.py +192 -32
  52. basic_memory/deps.py +252 -22
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -14
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +437 -59
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +188 -23
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +57 -3
  115. basic_memory/schemas/response.py +9 -1
  116. basic_memory/schemas/search.py +33 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +251 -106
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +595 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +50 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +172 -34
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1176 -96
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +388 -28
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -203
  140. basic_memory/mcp/tools/knowledge.py +0 -56
  141. basic_memory/mcp/tools/memory.py +0 -151
  142. basic_memory/mcp/tools/notes.py +0 -122
  143. basic_memory/schemas/discovery.py +0 -28
  144. basic_memory/sync/file_change_scanner.py +0 -158
  145. basic_memory/sync/utils.py +0 -34
  146. basic_memory-0.2.12.dist-info/METADATA +0 -291
  147. basic_memory-0.2.12.dist-info/RECORD +0 -78
  148. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  149. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,207 @@
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
11
+ from basic_memory.schemas import EntityResponse
12
+ from fastmcp import Context
13
+ from basic_memory.schemas.base import Entity
14
+ from basic_memory.utils import parse_tags, validate_project_path
15
+
16
+ # Define TagType as a Union that can accept either a string or a list of strings or None
17
+ TagType = Union[List[str], str, None]
18
+
19
+
20
+ @mcp.tool(
21
+ description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
22
+ )
23
+ async def write_note(
24
+ title: str,
25
+ content: str,
26
+ folder: str,
27
+ project: Optional[str] = None,
28
+ tags: list[str] | str | None = None,
29
+ note_type: str = "note",
30
+ context: Context | None = None,
31
+ ) -> str:
32
+ """Write a markdown note to the knowledge base.
33
+
34
+ Creates or updates a markdown note with semantic observations and relations.
35
+
36
+ Project Resolution:
37
+ Server resolves projects in this order: Single Project Mode → project parameter → default project.
38
+ If project unknown, use list_memory_projects() or recent_activity() first.
39
+
40
+ The content can include semantic observations and relations using markdown syntax:
41
+
42
+ Observations format:
43
+ `- [category] Observation text #tag1 #tag2 (optional context)`
44
+
45
+ Examples:
46
+ `- [design] Files are the source of truth #architecture (All state comes from files)`
47
+ `- [tech] Using SQLite for storage #implementation`
48
+ `- [note] Need to add error handling #todo`
49
+
50
+ Relations format:
51
+ - Explicit: `- relation_type [[Entity]] (optional context)`
52
+ - Inline: Any `[[Entity]]` reference creates a relation
53
+
54
+ Examples:
55
+ `- depends_on [[Content Parser]] (Need for semantic extraction)`
56
+ `- implements [[Search Spec]] (Initial implementation)`
57
+ `- This feature extends [[Base Design]] and uses [[Core Utils]]`
58
+
59
+ Args:
60
+ title: The title of the note
61
+ content: Markdown content for the note, can include observations and relations
62
+ folder: Folder path relative to project root where the file should be saved.
63
+ Use forward slashes (/) as separators. Use "/" or "" to write to project root.
64
+ Examples: "notes", "projects/2025", "research/ml", "/" (root)
65
+ project: Project name to write to. Optional - server will resolve using the
66
+ hierarchy above. If unknown, use list_memory_projects() to discover
67
+ available projects.
68
+ tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
69
+ Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
70
+ note_type: Type of note to create (stored in frontmatter). Defaults to "note".
71
+ Can be "guide", "report", "config", "person", etc.
72
+ context: Optional FastMCP context for performance caching.
73
+
74
+ Returns:
75
+ A markdown formatted summary of the semantic content, including:
76
+ - Creation/update status with project name
77
+ - File path and checksum
78
+ - Observation counts by category
79
+ - Relation counts (resolved/unresolved)
80
+ - Tags if present
81
+ - Session tracking metadata for project awareness
82
+
83
+ Examples:
84
+ # Assistant flow when project is unknown
85
+ # 1. list_memory_projects() -> Ask user which project
86
+ # 2. User: "Use my-research"
87
+ # 3. write_note(...) and remember "my-research" for session
88
+
89
+ # Create a simple note
90
+ write_note(
91
+ project="my-research",
92
+ title="Meeting Notes",
93
+ folder="meetings",
94
+ content="# Weekly Standup\\n\\n- [decision] Use SQLite for storage #tech"
95
+ )
96
+
97
+ # Create a note with tags and note type
98
+ write_note(
99
+ project="work-project",
100
+ title="API Design",
101
+ folder="specs",
102
+ content="# REST API Specification\\n\\n- implements [[Authentication]]",
103
+ tags=["api", "design"],
104
+ note_type="guide"
105
+ )
106
+
107
+ # Update existing note (same title/folder)
108
+ write_note(
109
+ project="my-research",
110
+ title="Meeting Notes",
111
+ folder="meetings",
112
+ content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech"
113
+ )
114
+
115
+ Raises:
116
+ HTTPError: If project doesn't exist or is inaccessible
117
+ SecurityError: If folder path attempts path traversal
118
+ """
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
+ project_url = active_project.permalink
154
+
155
+ # Create or update via knowledge API
156
+ logger.debug(f"Creating entity via API permalink={entity.permalink}")
157
+ url = f"{project_url}/knowledge/entities/{entity.permalink}"
158
+ response = await call_put(client, url, json=entity.model_dump())
159
+ result = EntityResponse.model_validate(response.json())
160
+
161
+ # Format semantic summary based on status code
162
+ action = "Created" if response.status_code == 201 else "Updated"
163
+ summary = [
164
+ f"# {action} note",
165
+ f"project: {active_project.name}",
166
+ f"file_path: {result.file_path}",
167
+ f"permalink: {result.permalink}",
168
+ f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
169
+ ]
170
+
171
+ # Count observations by category
172
+ categories = {}
173
+ if result.observations:
174
+ for obs in result.observations:
175
+ categories[obs.category] = categories.get(obs.category, 0) + 1
176
+
177
+ summary.append("\n## Observations")
178
+ for category, count in sorted(categories.items()):
179
+ summary.append(f"- {category}: {count}")
180
+
181
+ # Count resolved/unresolved relations
182
+ unresolved = 0
183
+ resolved = 0
184
+ if result.relations:
185
+ unresolved = sum(1 for r in result.relations if not r.to_id)
186
+ resolved = len(result.relations) - unresolved
187
+
188
+ summary.append("\n## Relations")
189
+ summary.append(f"- Resolved: {resolved}")
190
+ if unresolved:
191
+ summary.append(f"- Unresolved: {unresolved}")
192
+ summary.append(
193
+ "\nNote: Unresolved relations point to entities that don't exist yet."
194
+ )
195
+ summary.append(
196
+ "They will be automatically resolved when target entities are created or during sync operations."
197
+ )
198
+
199
+ if tag_list:
200
+ summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
201
+
202
+ # Log the response with structured data
203
+ logger.info(
204
+ 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}"
205
+ )
206
+ result = "\n".join(summary)
207
+ return add_project_metadata(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,7 @@
1
1
  """Knowledge graph models."""
2
2
 
3
3
  from datetime import datetime
4
+ from basic_memory.utils import ensure_timezone_aware
4
5
  from typing import Optional
5
6
 
6
7
  from sqlalchemy import (
@@ -12,11 +13,12 @@ from sqlalchemy import (
12
13
  DateTime,
13
14
  Index,
14
15
  JSON,
16
+ Float,
17
+ text,
15
18
  )
16
19
  from sqlalchemy.orm import Mapped, mapped_column, relationship
17
20
 
18
21
  from basic_memory.models.base import Base
19
-
20
22
  from basic_memory.utils import generate_permalink
21
23
 
22
24
 
@@ -28,15 +30,31 @@ class Entity(Base):
28
30
  - Maps to a file on disk
29
31
  - Maintains a checksum for change detection
30
32
  - Tracks both source file and semantic properties
33
+ - Belongs to a specific project
31
34
  """
32
35
 
33
36
  __tablename__ = "entity"
34
37
  __table_args__ = (
35
- UniqueConstraint("permalink", name="uix_entity_permalink"), # Make permalink unique
38
+ # Regular indexes
36
39
  Index("ix_entity_type", "entity_type"),
37
40
  Index("ix_entity_title", "title"),
38
41
  Index("ix_entity_created_at", "created_at"), # For timeline queries
39
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
+ ),
40
58
  )
41
59
 
42
60
  # Core identity
@@ -46,18 +64,34 @@ class Entity(Base):
46
64
  entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
47
65
  content_type: Mapped[str] = mapped_column(String)
48
66
 
49
- # Normalized path for URIs
50
- permalink: Mapped[str] = mapped_column(String, unique=True, index=True)
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)
51
72
  # Actual filesystem relative path
52
- file_path: Mapped[str] = mapped_column(String, unique=True, index=True)
73
+ file_path: Mapped[str] = mapped_column(String, index=True)
53
74
  # checksum of file
54
75
  checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
55
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
+
56
83
  # Metadata and tracking
57
- created_at: Mapped[datetime] = mapped_column(DateTime)
58
- updated_at: Mapped[datetime] = mapped_column(DateTime)
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
+ )
59
92
 
60
93
  # Relationships
94
+ project = relationship("Project", back_populates="entities")
61
95
  observations = relationship(
62
96
  "Observation", back_populates="entity", cascade="all, delete-orphan"
63
97
  )
@@ -79,6 +113,21 @@ class Entity(Base):
79
113
  """Get all relations (incoming and outgoing) for this entity."""
80
114
  return self.incoming_relations + self.outgoing_relations
81
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
+
82
131
  def __repr__(self) -> str:
83
132
  return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
84
133
 
@@ -127,7 +176,10 @@ class Relation(Base):
127
176
 
128
177
  __tablename__ = "relation"
129
178
  __table_args__ = (
130
- UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation"),
179
+ UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation_from_id_to_id"),
180
+ UniqueConstraint(
181
+ "from_id", "to_name", "relation_type", name="uix_relation_from_id_to_name"
182
+ ),
131
183
  Index("ix_relation_type", "relation_type"),
132
184
  Index("ix_relation_from_id", "from_id"), # Add FK indexes
133
185
  Index("ix_relation_to_id", "to_id"),
@@ -155,13 +207,13 @@ class Relation(Base):
155
207
  Format: source/relation_type/target
156
208
  Example: "specs/search/implements/features/search-ui"
157
209
  """
210
+ # Only create permalinks when both source and target have permalinks
211
+ from_permalink = self.from_entity.permalink or self.from_entity.file_path
212
+
158
213
  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
- )
214
+ to_permalink = self.to_entity.permalink or self.to_entity.file_path
215
+ return generate_permalink(f"{from_permalink}/{self.relation_type}/{to_permalink}")
216
+ return generate_permalink(f"{from_permalink}/{self.relation_type}/{self.to_name}")
165
217
 
166
218
  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}')"
219
+ 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)
@@ -8,25 +8,29 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
8
8
  -- Core entity fields
9
9
  id UNINDEXED, -- Row ID
10
10
  title, -- Title for searching
11
- content, -- Main searchable content
11
+ content_stems, -- Main searchable content split into stems
12
+ content_snippet, -- File content snippet for display
12
13
  permalink, -- Stable identifier (now indexed for path search)
13
14
  file_path UNINDEXED, -- Physical location
14
15
  type UNINDEXED, -- entity/relation/observation
15
-
16
- -- Relation fields
16
+
17
+ -- Project context
18
+ project_id UNINDEXED, -- Project identifier
19
+
20
+ -- Relation fields
17
21
  from_id UNINDEXED, -- Source entity
18
22
  to_id UNINDEXED, -- Target entity
19
23
  relation_type UNINDEXED, -- Type of relation
20
-
24
+
21
25
  -- Observation fields
22
26
  entity_id UNINDEXED, -- Parent entity
23
27
  category UNINDEXED, -- Observation category
24
-
28
+
25
29
  -- Common fields
26
30
  metadata UNINDEXED, -- JSON metadata
27
31
  created_at UNINDEXED, -- Creation timestamp
28
32
  updated_at UNINDEXED, -- Last update
29
-
33
+
30
34
  -- Configuration
31
35
  tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
32
36
  prefix='1,2,3,4' -- Support longer prefixes for paths
@@ -1,9 +1,11 @@
1
1
  from .entity_repository import EntityRepository
2
2
  from .observation_repository import ObservationRepository
3
+ from .project_repository import ProjectRepository
3
4
  from .relation_repository import RelationRepository
4
5
 
5
6
  __all__ = [
6
7
  "EntityRepository",
7
8
  "ObservationRepository",
9
+ "ProjectRepository",
8
10
  "RelationRepository",
9
11
  ]