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.
- basic_memory/__init__.py +7 -1
- basic_memory/alembic/env.py +1 -1
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -5
- basic_memory/api/app.py +43 -13
- basic_memory/api/routers/__init__.py +4 -2
- basic_memory/api/routers/directory_router.py +63 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +127 -38
- basic_memory/api/routers/management_router.py +78 -0
- basic_memory/api/routers/memory_router.py +4 -59
- basic_memory/api/routers/project_router.py +230 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/search_router.py +3 -21
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +20 -21
- basic_memory/cli/commands/__init__.py +2 -1
- basic_memory/cli/commands/auth.py +136 -0
- basic_memory/cli/commands/db.py +3 -3
- basic_memory/cli/commands/import_chatgpt.py +31 -207
- basic_memory/cli/commands/import_claude_conversations.py +16 -142
- basic_memory/cli/commands/import_claude_projects.py +33 -143
- basic_memory/cli/commands/import_memory_json.py +26 -83
- basic_memory/cli/commands/mcp.py +71 -18
- basic_memory/cli/commands/project.py +99 -67
- basic_memory/cli/commands/status.py +19 -9
- basic_memory/cli/commands/sync.py +44 -58
- basic_memory/cli/main.py +1 -5
- basic_memory/config.py +144 -88
- basic_memory/db.py +6 -4
- basic_memory/deps.py +227 -30
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +222 -0
- basic_memory/importers/claude_conversations_importer.py +172 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +93 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +5 -2
- basic_memory/mcp/auth_provider.py +270 -0
- basic_memory/mcp/external_auth_provider.py +321 -0
- basic_memory/mcp/project_session.py +103 -0
- basic_memory/mcp/prompts/continue_conversation.py +18 -68
- basic_memory/mcp/prompts/recent_activity.py +19 -3
- basic_memory/mcp/prompts/search.py +14 -140
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/{tools → resources}/project_info.py +6 -2
- basic_memory/mcp/server.py +82 -8
- basic_memory/mcp/supabase_auth_provider.py +463 -0
- basic_memory/mcp/tools/__init__.py +20 -0
- basic_memory/mcp/tools/build_context.py +11 -1
- basic_memory/mcp/tools/canvas.py +15 -2
- basic_memory/mcp/tools/delete_note.py +12 -4
- basic_memory/mcp/tools/edit_note.py +297 -0
- basic_memory/mcp/tools/list_directory.py +154 -0
- basic_memory/mcp/tools/move_note.py +87 -0
- basic_memory/mcp/tools/project_management.py +300 -0
- basic_memory/mcp/tools/read_content.py +15 -6
- basic_memory/mcp/tools/read_note.py +17 -5
- basic_memory/mcp/tools/recent_activity.py +11 -2
- basic_memory/mcp/tools/search.py +10 -1
- basic_memory/mcp/tools/utils.py +137 -12
- basic_memory/mcp/tools/write_note.py +11 -15
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +16 -4
- basic_memory/models/project.py +80 -0
- basic_memory/models/search.py +8 -5
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +8 -3
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +3 -2
- basic_memory/repository/project_repository.py +85 -0
- basic_memory/repository/relation_repository.py +8 -2
- basic_memory/repository/repository.py +107 -15
- basic_memory/repository/search_repository.py +87 -27
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +34 -0
- basic_memory/schemas/memory.py +26 -12
- basic_memory/schemas/project_info.py +112 -2
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/search.py +1 -1
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +208 -95
- basic_memory/services/directory_service.py +167 -0
- basic_memory/services/entity_service.py +385 -5
- basic_memory/services/exceptions.py +6 -0
- basic_memory/services/file_service.py +14 -15
- basic_memory/services/initialization.py +144 -67
- basic_memory/services/link_resolver.py +16 -8
- basic_memory/services/project_service.py +548 -0
- basic_memory/services/search_service.py +77 -2
- basic_memory/sync/background_sync.py +25 -0
- basic_memory/sync/sync_service.py +10 -9
- basic_memory/sync/watch_service.py +63 -39
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/METADATA +23 -1
- basic_memory-0.13.0b2.dist-info/RECORD +132 -0
- basic_memory/api/routers/project_info_router.py +0 -274
- basic_memory/mcp/main.py +0 -24
- basic_memory-0.12.3.dist-info/RECORD +0 -100
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/WHEEL +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/licenses/LICENSE +0 -0
basic_memory/mcp/tools/utils.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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":
|
|
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
|
|
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)
|
basic_memory/models/__init__.py
CHANGED
|
@@ -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
|
]
|
basic_memory/models/knowledge.py
CHANGED
|
@@ -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
|
-
#
|
|
41
|
+
Index("ix_entity_project_id", "project_id"), # For project filtering
|
|
42
|
+
# Project-specific uniqueness constraints
|
|
42
43
|
Index(
|
|
43
|
-
"
|
|
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,
|
|
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)
|
basic_memory/models/search.py
CHANGED
|
@@ -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
|
-
--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
super().__init__(session_maker,
|
|
9
|
+
# Initialize with Project model as a reference
|
|
10
|
+
super().__init__(session_maker, Project)
|