basic-memory 0.12.3__py3-none-any.whl → 0.13.0b2__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 (107) hide show
  1. basic_memory/__init__.py +7 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -5
  5. basic_memory/api/app.py +43 -13
  6. basic_memory/api/routers/__init__.py +4 -2
  7. basic_memory/api/routers/directory_router.py +63 -0
  8. basic_memory/api/routers/importer_router.py +152 -0
  9. basic_memory/api/routers/knowledge_router.py +127 -38
  10. basic_memory/api/routers/management_router.py +78 -0
  11. basic_memory/api/routers/memory_router.py +4 -59
  12. basic_memory/api/routers/project_router.py +230 -0
  13. basic_memory/api/routers/prompt_router.py +260 -0
  14. basic_memory/api/routers/search_router.py +3 -21
  15. basic_memory/api/routers/utils.py +130 -0
  16. basic_memory/api/template_loader.py +292 -0
  17. basic_memory/cli/app.py +20 -21
  18. basic_memory/cli/commands/__init__.py +2 -1
  19. basic_memory/cli/commands/auth.py +136 -0
  20. basic_memory/cli/commands/db.py +3 -3
  21. basic_memory/cli/commands/import_chatgpt.py +31 -207
  22. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  23. basic_memory/cli/commands/import_claude_projects.py +33 -143
  24. basic_memory/cli/commands/import_memory_json.py +26 -83
  25. basic_memory/cli/commands/mcp.py +71 -18
  26. basic_memory/cli/commands/project.py +99 -67
  27. basic_memory/cli/commands/status.py +19 -9
  28. basic_memory/cli/commands/sync.py +44 -58
  29. basic_memory/cli/main.py +1 -5
  30. basic_memory/config.py +144 -88
  31. basic_memory/db.py +6 -4
  32. basic_memory/deps.py +227 -30
  33. basic_memory/importers/__init__.py +27 -0
  34. basic_memory/importers/base.py +79 -0
  35. basic_memory/importers/chatgpt_importer.py +222 -0
  36. basic_memory/importers/claude_conversations_importer.py +172 -0
  37. basic_memory/importers/claude_projects_importer.py +148 -0
  38. basic_memory/importers/memory_json_importer.py +93 -0
  39. basic_memory/importers/utils.py +58 -0
  40. basic_memory/markdown/entity_parser.py +5 -2
  41. basic_memory/mcp/auth_provider.py +270 -0
  42. basic_memory/mcp/external_auth_provider.py +321 -0
  43. basic_memory/mcp/project_session.py +103 -0
  44. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  45. basic_memory/mcp/prompts/recent_activity.py +19 -3
  46. basic_memory/mcp/prompts/search.py +14 -140
  47. basic_memory/mcp/prompts/utils.py +3 -3
  48. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  49. basic_memory/mcp/server.py +82 -8
  50. basic_memory/mcp/supabase_auth_provider.py +463 -0
  51. basic_memory/mcp/tools/__init__.py +20 -0
  52. basic_memory/mcp/tools/build_context.py +11 -1
  53. basic_memory/mcp/tools/canvas.py +15 -2
  54. basic_memory/mcp/tools/delete_note.py +12 -4
  55. basic_memory/mcp/tools/edit_note.py +297 -0
  56. basic_memory/mcp/tools/list_directory.py +154 -0
  57. basic_memory/mcp/tools/move_note.py +87 -0
  58. basic_memory/mcp/tools/project_management.py +300 -0
  59. basic_memory/mcp/tools/read_content.py +15 -6
  60. basic_memory/mcp/tools/read_note.py +17 -5
  61. basic_memory/mcp/tools/recent_activity.py +11 -2
  62. basic_memory/mcp/tools/search.py +10 -1
  63. basic_memory/mcp/tools/utils.py +137 -12
  64. basic_memory/mcp/tools/write_note.py +11 -15
  65. basic_memory/models/__init__.py +3 -2
  66. basic_memory/models/knowledge.py +16 -4
  67. basic_memory/models/project.py +80 -0
  68. basic_memory/models/search.py +8 -5
  69. basic_memory/repository/__init__.py +2 -0
  70. basic_memory/repository/entity_repository.py +8 -3
  71. basic_memory/repository/observation_repository.py +35 -3
  72. basic_memory/repository/project_info_repository.py +3 -2
  73. basic_memory/repository/project_repository.py +85 -0
  74. basic_memory/repository/relation_repository.py +8 -2
  75. basic_memory/repository/repository.py +107 -15
  76. basic_memory/repository/search_repository.py +87 -27
  77. basic_memory/schemas/__init__.py +6 -0
  78. basic_memory/schemas/directory.py +30 -0
  79. basic_memory/schemas/importer.py +34 -0
  80. basic_memory/schemas/memory.py +26 -12
  81. basic_memory/schemas/project_info.py +112 -2
  82. basic_memory/schemas/prompt.py +90 -0
  83. basic_memory/schemas/request.py +56 -2
  84. basic_memory/schemas/search.py +1 -1
  85. basic_memory/services/__init__.py +2 -1
  86. basic_memory/services/context_service.py +208 -95
  87. basic_memory/services/directory_service.py +167 -0
  88. basic_memory/services/entity_service.py +385 -5
  89. basic_memory/services/exceptions.py +6 -0
  90. basic_memory/services/file_service.py +14 -15
  91. basic_memory/services/initialization.py +144 -67
  92. basic_memory/services/link_resolver.py +16 -8
  93. basic_memory/services/project_service.py +548 -0
  94. basic_memory/services/search_service.py +77 -2
  95. basic_memory/sync/background_sync.py +25 -0
  96. basic_memory/sync/sync_service.py +10 -9
  97. basic_memory/sync/watch_service.py +63 -39
  98. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  99. basic_memory/templates/prompts/search.hbs +101 -0
  100. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/METADATA +23 -1
  101. basic_memory-0.13.0b2.dist-info/RECORD +132 -0
  102. basic_memory/api/routers/project_info_router.py +0 -274
  103. basic_memory/mcp/main.py +0 -24
  104. basic_memory-0.12.3.dist-info/RECORD +0 -100
  105. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/WHEEL +0 -0
  106. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/entry_points.txt +0 -0
  107. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/licenses/LICENSE +0 -0
@@ -5,6 +5,7 @@ to the Basic Memory API, with improved error handling and logging.
5
5
  """
6
6
 
7
7
  import typing
8
+ from typing import Optional
8
9
 
9
10
  from httpx import Response, URL, AsyncClient, HTTPStatusError
10
11
  from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
@@ -23,7 +24,9 @@ from loguru import logger
23
24
  from mcp.server.fastmcp.exceptions import ToolError
24
25
 
25
26
 
26
- def get_error_message(status_code: int, url: URL | str, method: str) -> str:
27
+ def get_error_message(
28
+ status_code: int, url: URL | str, method: str, msg: Optional[str] = None
29
+ ) -> str:
27
30
  """Get a friendly error message based on the HTTP status code.
28
31
 
29
32
  Args:
@@ -103,6 +106,7 @@ async def call_get(
103
106
  ToolError: If the request fails with an appropriate error message
104
107
  """
105
108
  logger.debug(f"Calling GET '{url}' params: '{params}'")
109
+ error_message = None
106
110
  try:
107
111
  response = await client.get(
108
112
  url,
@@ -120,7 +124,12 @@ async def call_get(
120
124
 
121
125
  # Handle different status codes differently
122
126
  status_code = response.status_code
123
- error_message = get_error_message(status_code, url, "GET")
127
+ # get the message if available
128
+ response_data = response.json()
129
+ if isinstance(response_data, dict) and "detail" in response_data:
130
+ error_message = response_data["detail"]
131
+ else:
132
+ error_message = get_error_message(status_code, url, "PUT")
124
133
 
125
134
  # Log at appropriate level based on status code
126
135
  if 400 <= status_code < 500:
@@ -138,8 +147,6 @@ async def call_get(
138
147
  return response # This line will never execute, but it satisfies the type checker # pragma: no cover
139
148
 
140
149
  except HTTPStatusError as e:
141
- status_code = e.response.status_code
142
- error_message = get_error_message(status_code, url, "GET")
143
150
  raise ToolError(error_message) from e
144
151
 
145
152
 
@@ -183,6 +190,8 @@ async def call_put(
183
190
  ToolError: If the request fails with an appropriate error message
184
191
  """
185
192
  logger.debug(f"Calling PUT '{url}'")
193
+ error_message = None
194
+
186
195
  try:
187
196
  response = await client.put(
188
197
  url,
@@ -204,7 +213,13 @@ async def call_put(
204
213
 
205
214
  # Handle different status codes differently
206
215
  status_code = response.status_code
207
- error_message = get_error_message(status_code, url, "PUT")
216
+
217
+ # get the message if available
218
+ response_data = response.json()
219
+ if isinstance(response_data, dict) and "detail" in response_data:
220
+ error_message = response_data["detail"] # pragma: no cover
221
+ else:
222
+ error_message = get_error_message(status_code, url, "PUT")
208
223
 
209
224
  # Log at appropriate level based on status code
210
225
  if 400 <= status_code < 500:
@@ -221,9 +236,110 @@ async def call_put(
221
236
  response.raise_for_status() # Will always raise since we're in the error case
222
237
  return response # This line will never execute, but it satisfies the type checker # pragma: no cover
223
238
 
239
+ except HTTPStatusError as e:
240
+ raise ToolError(error_message) from e
241
+
242
+
243
+ async def call_patch(
244
+ client: AsyncClient,
245
+ url: URL | str,
246
+ *,
247
+ content: RequestContent | None = None,
248
+ data: RequestData | None = None,
249
+ files: RequestFiles | None = None,
250
+ json: typing.Any | None = None,
251
+ params: QueryParamTypes | None = None,
252
+ headers: HeaderTypes | None = None,
253
+ cookies: CookieTypes | None = None,
254
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
255
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
256
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
257
+ extensions: RequestExtensions | None = None,
258
+ ) -> Response:
259
+ """Make a PATCH request and handle errors appropriately.
260
+
261
+ Args:
262
+ client: The HTTPX AsyncClient to use
263
+ url: The URL to request
264
+ content: Request content
265
+ data: Form data
266
+ files: Files to upload
267
+ json: JSON data
268
+ params: Query parameters
269
+ headers: HTTP headers
270
+ cookies: HTTP cookies
271
+ auth: Authentication
272
+ follow_redirects: Whether to follow redirects
273
+ timeout: Request timeout
274
+ extensions: HTTPX extensions
275
+
276
+ Returns:
277
+ The HTTP response
278
+
279
+ Raises:
280
+ ToolError: If the request fails with an appropriate error message
281
+ """
282
+ logger.debug(f"Calling PATCH '{url}'")
283
+ try:
284
+ response = await client.patch(
285
+ url,
286
+ content=content,
287
+ data=data,
288
+ files=files,
289
+ json=json,
290
+ params=params,
291
+ headers=headers,
292
+ cookies=cookies,
293
+ auth=auth,
294
+ follow_redirects=follow_redirects,
295
+ timeout=timeout,
296
+ extensions=extensions,
297
+ )
298
+
299
+ if response.is_success:
300
+ return response
301
+
302
+ # Handle different status codes differently
303
+ status_code = response.status_code
304
+
305
+ # Try to extract specific error message from response body
306
+ try:
307
+ response_data = response.json()
308
+ if isinstance(response_data, dict) and "detail" in response_data:
309
+ error_message = response_data["detail"]
310
+ else:
311
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
312
+ except Exception: # pragma: no cover
313
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
314
+
315
+ # Log at appropriate level based on status code
316
+ if 400 <= status_code < 500:
317
+ # Client errors: log as info except for 429 (Too Many Requests)
318
+ if status_code == 429: # pragma: no cover
319
+ logger.warning(f"Rate limit exceeded: PATCH {url}: {error_message}")
320
+ else:
321
+ logger.info(f"Client error: PATCH {url}: {error_message}")
322
+ else: # pragma: no cover
323
+ # Server errors: log as error
324
+ logger.error(f"Server error: PATCH {url}: {error_message}") # pragma: no cover
325
+
326
+ # Raise a tool error with the friendly message
327
+ response.raise_for_status() # Will always raise since we're in the error case
328
+ return response # This line will never execute, but it satisfies the type checker # pragma: no cover
329
+
224
330
  except HTTPStatusError as e:
225
331
  status_code = e.response.status_code
226
- error_message = get_error_message(status_code, url, "PUT")
332
+
333
+ # Try to extract specific error message from response body
334
+ try:
335
+ response_data = e.response.json()
336
+ if isinstance(response_data, dict) and "detail" in response_data:
337
+ error_message = response_data["detail"]
338
+ else:
339
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
340
+ except Exception: # pragma: no cover
341
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
342
+
227
343
  raise ToolError(error_message) from e
228
344
 
229
345
 
@@ -267,6 +383,7 @@ async def call_post(
267
383
  ToolError: If the request fails with an appropriate error message
268
384
  """
269
385
  logger.debug(f"Calling POST '{url}'")
386
+ error_message = None
270
387
  try:
271
388
  response = await client.post(
272
389
  url=url,
@@ -282,13 +399,19 @@ async def call_post(
282
399
  timeout=timeout,
283
400
  extensions=extensions,
284
401
  )
402
+ logger.debug(f"response: {response.json()}")
285
403
 
286
404
  if response.is_success:
287
405
  return response
288
406
 
289
407
  # Handle different status codes differently
290
408
  status_code = response.status_code
291
- error_message = get_error_message(status_code, url, "POST")
409
+ # get the message if available
410
+ response_data = response.json()
411
+ if isinstance(response_data, dict) and "detail" in response_data:
412
+ error_message = response_data["detail"]
413
+ else:
414
+ error_message = get_error_message(status_code, url, "POST")
292
415
 
293
416
  # Log at appropriate level based on status code
294
417
  if 400 <= status_code < 500:
@@ -306,8 +429,6 @@ async def call_post(
306
429
  return response # This line will never execute, but it satisfies the type checker # pragma: no cover
307
430
 
308
431
  except HTTPStatusError as e:
309
- status_code = e.response.status_code
310
- error_message = get_error_message(status_code, url, "POST")
311
432
  raise ToolError(error_message) from e
312
433
 
313
434
 
@@ -343,6 +464,7 @@ async def call_delete(
343
464
  ToolError: If the request fails with an appropriate error message
344
465
  """
345
466
  logger.debug(f"Calling DELETE '{url}'")
467
+ error_message = None
346
468
  try:
347
469
  response = await client.delete(
348
470
  url=url,
@@ -360,7 +482,12 @@ async def call_delete(
360
482
 
361
483
  # Handle different status codes differently
362
484
  status_code = response.status_code
363
- error_message = get_error_message(status_code, url, "DELETE")
485
+ # get the message if available
486
+ response_data = response.json()
487
+ if isinstance(response_data, dict) and "detail" in response_data:
488
+ error_message = response_data["detail"] # pragma: no cover
489
+ else:
490
+ error_message = get_error_message(status_code, url, "DELETE")
364
491
 
365
492
  # Log at appropriate level based on status code
366
493
  if 400 <= status_code < 500:
@@ -378,6 +505,4 @@ async def call_delete(
378
505
  return response # This line will never execute, but it satisfies the type checker # pragma: no cover
379
506
 
380
507
  except HTTPStatusError as e:
381
- status_code = e.response.status_code
382
- error_message = get_error_message(status_code, url, "DELETE")
383
508
  raise ToolError(error_message) from e
@@ -1,12 +1,13 @@
1
1
  """Write note tool for Basic Memory MCP server."""
2
2
 
3
- from typing import List, Union
3
+ from typing import List, Union, Optional
4
4
 
5
5
  from loguru import logger
6
6
 
7
7
  from basic_memory.mcp.async_client import client
8
8
  from basic_memory.mcp.server import mcp
9
9
  from basic_memory.mcp.tools.utils import call_put
10
+ from basic_memory.mcp.project_session import get_active_project
10
11
  from basic_memory.schemas import EntityResponse
11
12
  from basic_memory.schemas.base import Entity
12
13
  from basic_memory.utils import parse_tags
@@ -26,6 +27,7 @@ async def write_note(
26
27
  content: str,
27
28
  folder: str,
28
29
  tags=None, # Remove type hint completely to avoid schema issues
30
+ project: Optional[str] = None,
29
31
  ) -> str:
30
32
  """Write a markdown note to the knowledge base.
31
33
 
@@ -55,6 +57,7 @@ async def write_note(
55
57
  folder: the folder where the file should be saved
56
58
  tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
57
59
  Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
60
+ project: Optional project name to write to. If not provided, uses current active project.
58
61
 
59
62
  Returns:
60
63
  A markdown formatted summary of the semantic content, including:
@@ -64,12 +67,12 @@ async def write_note(
64
67
  - Relation counts (resolved/unresolved)
65
68
  - Tags if present
66
69
  """
67
- logger.info("MCP tool call", tool="write_note", folder=folder, title=title, tags=tags)
70
+ logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")
68
71
 
69
72
  # Process tags using the helper function
70
73
  tag_list = parse_tags(tags)
71
74
  # Create the entity request
72
- metadata = {"tags": [f"#{tag}" for tag in tag_list]} if tag_list else None
75
+ metadata = {"tags": tag_list} if tag_list else None
73
76
  entity = Entity(
74
77
  title=title,
75
78
  folder=folder,
@@ -78,10 +81,12 @@ async def write_note(
78
81
  content=content,
79
82
  entity_metadata=metadata,
80
83
  )
84
+ active_project = get_active_project(project)
85
+ project_url = active_project.project_url
81
86
 
82
87
  # Create or update via knowledge API
83
- logger.debug("Creating entity via API", permalink=entity.permalink)
84
- url = f"/knowledge/entities/{entity.permalink}"
88
+ logger.debug(f"Creating entity via API permalink={entity.permalink}")
89
+ url = f"{project_url}/knowledge/entities/{entity.permalink}"
85
90
  response = await call_put(client, url, json=entity.model_dump())
86
91
  result = EntityResponse.model_validate(response.json())
87
92
 
@@ -122,15 +127,6 @@ async def write_note(
122
127
 
123
128
  # Log the response with structured data
124
129
  logger.info(
125
- "MCP tool response",
126
- tool="write_note",
127
- action=action,
128
- permalink=result.permalink,
129
- observations_count=len(result.observations),
130
- relations_count=len(result.relations),
131
- resolved_relations=resolved,
132
- unresolved_relations=unresolved,
133
- status_code=response.status_code,
130
+ f"MCP tool response: tool=write_note 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}"
134
131
  )
135
-
136
132
  return "\n".join(summary)
@@ -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
  ]
@@ -17,7 +17,6 @@ from sqlalchemy import (
17
17
  from sqlalchemy.orm import Mapped, mapped_column, relationship
18
18
 
19
19
  from basic_memory.models.base import Base
20
-
21
20
  from basic_memory.utils import generate_permalink
22
21
 
23
22
 
@@ -29,6 +28,7 @@ class Entity(Base):
29
28
  - Maps to a file on disk
30
29
  - Maintains a checksum for change detection
31
30
  - Tracks both source file and semantic properties
31
+ - Belongs to a specific project
32
32
  """
33
33
 
34
34
  __tablename__ = "entity"
@@ -38,13 +38,21 @@ class Entity(Base):
38
38
  Index("ix_entity_title", "title"),
39
39
  Index("ix_entity_created_at", "created_at"), # For timeline queries
40
40
  Index("ix_entity_updated_at", "updated_at"), # For timeline queries
41
- # Unique index only for markdown files with non-null permalinks
41
+ Index("ix_entity_project_id", "project_id"), # For project filtering
42
+ # Project-specific uniqueness constraints
42
43
  Index(
43
- "uix_entity_permalink",
44
+ "uix_entity_permalink_project",
44
45
  "permalink",
46
+ "project_id",
45
47
  unique=True,
46
48
  sqlite_where=text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
47
49
  ),
50
+ Index(
51
+ "uix_entity_file_path_project",
52
+ "file_path",
53
+ "project_id",
54
+ unique=True,
55
+ ),
48
56
  )
49
57
 
50
58
  # Core identity
@@ -54,10 +62,13 @@ class Entity(Base):
54
62
  entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
55
63
  content_type: Mapped[str] = mapped_column(String)
56
64
 
65
+ # Project reference
66
+ project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), nullable=False)
67
+
57
68
  # Normalized path for URIs - required for markdown files only
58
69
  permalink: Mapped[Optional[str]] = mapped_column(String, nullable=True, index=True)
59
70
  # Actual filesystem relative path
60
- file_path: Mapped[str] = mapped_column(String, unique=True, index=True)
71
+ file_path: Mapped[str] = mapped_column(String, index=True)
61
72
  # checksum of file
62
73
  checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
63
74
 
@@ -66,6 +77,7 @@ class Entity(Base):
66
77
  updated_at: Mapped[datetime] = mapped_column(DateTime)
67
78
 
68
79
  # Relationships
80
+ project = relationship("Project", back_populates="entities")
69
81
  observations = relationship(
70
82
  "Observation", back_populates="entity", cascade="all, delete-orphan"
71
83
  )
@@ -0,0 +1,80 @@
1
+ """Project model for Basic Memory."""
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from sqlalchemy import (
7
+ Integer,
8
+ String,
9
+ Text,
10
+ Boolean,
11
+ DateTime,
12
+ Index,
13
+ event,
14
+ )
15
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
16
+
17
+ from basic_memory.models.base import Base
18
+ from basic_memory.utils import generate_permalink
19
+
20
+
21
+ class Project(Base):
22
+ """Project model for Basic Memory.
23
+
24
+ A project represents a collection of knowledge entities that are grouped together.
25
+ Projects are stored in the app-level database and provide context for all knowledge
26
+ operations.
27
+ """
28
+
29
+ __tablename__ = "project"
30
+ __table_args__ = (
31
+ # Regular indexes
32
+ Index("ix_project_name", "name", unique=True),
33
+ Index("ix_project_permalink", "permalink", unique=True),
34
+ Index("ix_project_path", "path"),
35
+ Index("ix_project_created_at", "created_at"),
36
+ Index("ix_project_updated_at", "updated_at"),
37
+ )
38
+
39
+ # Core identity
40
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
41
+ name: Mapped[str] = mapped_column(String, unique=True)
42
+ description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
43
+
44
+ # URL-friendly identifier generated from name
45
+ permalink: Mapped[str] = mapped_column(String, unique=True)
46
+
47
+ # Filesystem path to project directory
48
+ path: Mapped[str] = mapped_column(String)
49
+
50
+ # Status flags
51
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
52
+ is_default: Mapped[Optional[bool]] = mapped_column(
53
+ Boolean, default=None, unique=True, nullable=True
54
+ )
55
+
56
+ # Timestamps
57
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
58
+ updated_at: Mapped[datetime] = mapped_column(
59
+ DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
60
+ )
61
+
62
+ # Define relationships to entities, observations, and relations
63
+ # These relationships will be established once we add project_id to those models
64
+ entities = relationship("Entity", back_populates="project", cascade="all, delete-orphan")
65
+
66
+ def __repr__(self) -> str: # pragma: no cover
67
+ return f"Project(id={self.id}, name='{self.name}', permalink='{self.permalink}', path='{self.path}')"
68
+
69
+
70
+ @event.listens_for(Project, "before_insert")
71
+ @event.listens_for(Project, "before_update")
72
+ def set_project_permalink(mapper, connection, project):
73
+ """Generate URL-friendly permalink for the project if needed.
74
+
75
+ This event listener ensures the permalink is always derived from the name,
76
+ even if the name changes.
77
+ """
78
+ # If the name changed or permalink is empty, regenerate permalink
79
+ if not project.permalink or project.permalink != generate_permalink(project.name):
80
+ project.permalink = generate_permalink(project.name)
@@ -13,21 +13,24 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
13
13
  permalink, -- Stable identifier (now indexed for path search)
14
14
  file_path UNINDEXED, -- Physical location
15
15
  type UNINDEXED, -- entity/relation/observation
16
-
17
- -- Relation fields
16
+
17
+ -- Project context
18
+ project_id UNINDEXED, -- Project identifier
19
+
20
+ -- Relation fields
18
21
  from_id UNINDEXED, -- Source entity
19
22
  to_id UNINDEXED, -- Target entity
20
23
  relation_type UNINDEXED, -- Type of relation
21
-
24
+
22
25
  -- Observation fields
23
26
  entity_id UNINDEXED, -- Parent entity
24
27
  category UNINDEXED, -- Observation category
25
-
28
+
26
29
  -- Common fields
27
30
  metadata UNINDEXED, -- JSON metadata
28
31
  created_at UNINDEXED, -- Creation timestamp
29
32
  updated_at UNINDEXED, -- Last update
30
-
33
+
31
34
  -- Configuration
32
35
  tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
33
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
  ]
@@ -18,9 +18,14 @@ class EntityRepository(Repository[Entity]):
18
18
  to strings before passing to repository methods.
19
19
  """
20
20
 
21
- def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
22
- """Initialize with session maker."""
23
- super().__init__(session_maker, Entity)
21
+ def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
22
+ """Initialize with session maker and project_id filter.
23
+
24
+ Args:
25
+ session_maker: SQLAlchemy session maker
26
+ project_id: Project ID to filter all operations by
27
+ """
28
+ super().__init__(session_maker, Entity, project_id=project_id)
24
29
 
25
30
  async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
26
31
  """Get entity by permalink.
@@ -1,6 +1,6 @@
1
1
  """Repository for managing Observation objects."""
2
2
 
3
- from typing import Sequence
3
+ from typing import Dict, List, Sequence
4
4
 
5
5
  from sqlalchemy import select
6
6
  from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -12,8 +12,14 @@ from basic_memory.repository.repository import Repository
12
12
  class ObservationRepository(Repository[Observation]):
13
13
  """Repository for Observation model with memory-specific operations."""
14
14
 
15
- def __init__(self, session_maker: async_sessionmaker):
16
- super().__init__(session_maker, Observation)
15
+ def __init__(self, session_maker: async_sessionmaker, project_id: int):
16
+ """Initialize with session maker and project_id filter.
17
+
18
+ Args:
19
+ session_maker: SQLAlchemy session maker
20
+ project_id: Project ID to filter all operations by
21
+ """
22
+ super().__init__(session_maker, Observation, project_id=project_id)
17
23
 
18
24
  async def find_by_entity(self, entity_id: int) -> Sequence[Observation]:
19
25
  """Find all observations for a specific entity."""
@@ -38,3 +44,29 @@ class ObservationRepository(Repository[Observation]):
38
44
  query = select(Observation.category).distinct()
39
45
  result = await self.execute_query(query, use_query_options=False)
40
46
  return result.scalars().all()
47
+
48
+ async def find_by_entities(self, entity_ids: List[int]) -> Dict[int, List[Observation]]:
49
+ """Find all observations for multiple entities in a single query.
50
+
51
+ Args:
52
+ entity_ids: List of entity IDs to fetch observations for
53
+
54
+ Returns:
55
+ Dictionary mapping entity_id to list of observations
56
+ """
57
+ if not entity_ids: # pragma: no cover
58
+ return {}
59
+
60
+ # Query observations for all entities in the list
61
+ query = select(Observation).filter(Observation.entity_id.in_(entity_ids))
62
+ result = await self.execute_query(query)
63
+ observations = result.scalars().all()
64
+
65
+ # Group observations by entity_id
66
+ observations_by_entity = {}
67
+ for obs in observations:
68
+ if obs.entity_id not in observations_by_entity:
69
+ observations_by_entity[obs.entity_id] = []
70
+ observations_by_entity[obs.entity_id].append(obs)
71
+
72
+ return observations_by_entity
@@ -1,9 +1,10 @@
1
1
  from basic_memory.repository.repository import Repository
2
+ from basic_memory.models.project import Project
2
3
 
3
4
 
4
5
  class ProjectInfoRepository(Repository):
5
6
  """Repository for statistics queries."""
6
7
 
7
8
  def __init__(self, session_maker):
8
- # Initialize with a dummy model since we're just using the execute_query method
9
- super().__init__(session_maker, None) # type: ignore
9
+ # Initialize with Project model as a reference
10
+ super().__init__(session_maker, Project)