basic-memory 0.7.0__py3-none-any.whl → 0.17.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +130 -20
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +87 -20
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/__init__.py +4 -1
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +180 -23
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +9 -64
- basic_memory/api/routers/project_router.py +460 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +136 -11
- basic_memory/api/routers/search_router.py +5 -5
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +80 -10
- basic_memory/cli/auth.py +300 -0
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +127 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +240 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +99 -0
- basic_memory/cli/commands/db.py +87 -12
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +47 -223
- basic_memory/cli/commands/import_claude_conversations.py +48 -171
- basic_memory/cli/commands/import_claude_projects.py +53 -160
- basic_memory/cli/commands/import_memory_json.py +55 -111
- basic_memory/cli/commands/mcp.py +67 -11
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +52 -34
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +14 -6
- basic_memory/config.py +580 -26
- basic_memory/db.py +285 -28
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +16 -185
- basic_memory/file_utils.py +318 -54
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +100 -0
- basic_memory/importers/chatgpt_importer.py +245 -0
- basic_memory/importers/claude_conversations_importer.py +192 -0
- basic_memory/importers/claude_projects_importer.py +184 -0
- basic_memory/importers/memory_json_importer.py +128 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/entity_parser.py +182 -23
- basic_memory/markdown/markdown_processor.py +70 -7
- basic_memory/markdown/plugins.py +43 -23
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +38 -14
- basic_memory/mcp/async_client.py +135 -4
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +155 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +61 -9
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +249 -0
- basic_memory/mcp/tools/edit_note.py +325 -0
- basic_memory/mcp/tools/list_directory.py +157 -0
- basic_memory/mcp/tools/move_note.py +549 -0
- basic_memory/mcp/tools/project_management.py +204 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +265 -0
- basic_memory/mcp/tools/recent_activity.py +528 -0
- basic_memory/mcp/tools/search.py +377 -24
- basic_memory/mcp/tools/utils.py +402 -16
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +82 -17
- basic_memory/models/project.py +93 -0
- basic_memory/models/search.py +68 -8
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +437 -8
- basic_memory/repository/observation_repository.py +36 -3
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +140 -0
- basic_memory/repository/relation_repository.py +79 -4
- basic_memory/repository/repository.py +148 -29
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +79 -268
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +131 -12
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +31 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +194 -25
- basic_memory/schemas/project_info.py +213 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/response.py +85 -28
- basic_memory/schemas/search.py +36 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +451 -138
- basic_memory/services/directory_service.py +310 -0
- basic_memory/services/entity_service.py +636 -71
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +402 -33
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +888 -0
- basic_memory/services/search_service.py +232 -37
- basic_memory/sync/__init__.py +4 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +1200 -109
- basic_memory/sync/watch_service.py +432 -135
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +407 -54
- basic_memory-0.17.4.dist-info/METADATA +617 -0
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -206
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Repository for managing Relation objects."""
|
|
2
2
|
|
|
3
|
-
from sqlalchemy import and_, delete
|
|
4
3
|
from typing import Sequence, List, Optional
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import and_, delete, select
|
|
7
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
8
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
7
9
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
8
10
|
from sqlalchemy.orm import selectinload, aliased
|
|
9
11
|
from sqlalchemy.orm.interfaces import LoaderOption
|
|
@@ -16,8 +18,14 @@ from basic_memory.repository.repository import Repository
|
|
|
16
18
|
class RelationRepository(Repository[Relation]):
|
|
17
19
|
"""Repository for Relation model with memory-specific operations."""
|
|
18
20
|
|
|
19
|
-
def __init__(self, session_maker: async_sessionmaker):
|
|
20
|
-
|
|
21
|
+
def __init__(self, session_maker: async_sessionmaker, 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, Relation, project_id=project_id)
|
|
21
29
|
|
|
22
30
|
async def find_relation(
|
|
23
31
|
self, from_permalink: str, to_permalink: str, relation_type: str
|
|
@@ -67,5 +75,72 @@ class RelationRepository(Repository[Relation]):
|
|
|
67
75
|
result = await self.execute_query(query)
|
|
68
76
|
return result.scalars().all()
|
|
69
77
|
|
|
78
|
+
async def find_unresolved_relations_for_entity(self, entity_id: int) -> Sequence[Relation]:
|
|
79
|
+
"""Find unresolved relations for a specific entity.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
entity_id: The entity whose unresolved outgoing relations to find.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of unresolved relations where this entity is the source.
|
|
86
|
+
"""
|
|
87
|
+
query = select(Relation).filter(Relation.from_id == entity_id, Relation.to_id.is_(None))
|
|
88
|
+
result = await self.execute_query(query)
|
|
89
|
+
return result.scalars().all()
|
|
90
|
+
|
|
91
|
+
async def add_all_ignore_duplicates(self, relations: List[Relation]) -> int:
|
|
92
|
+
"""Bulk insert relations, ignoring duplicates.
|
|
93
|
+
|
|
94
|
+
Uses ON CONFLICT DO NOTHING to skip relations that would violate the
|
|
95
|
+
unique constraint on (from_id, to_name, relation_type). This is useful
|
|
96
|
+
for bulk operations where the same link may appear multiple times in
|
|
97
|
+
a document.
|
|
98
|
+
|
|
99
|
+
Works with both SQLite and PostgreSQL dialects.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
relations: List of Relation objects to insert
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Number of relations actually inserted (excludes duplicates)
|
|
106
|
+
"""
|
|
107
|
+
if not relations:
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
# Convert Relation objects to dicts for insert
|
|
111
|
+
values = [
|
|
112
|
+
{
|
|
113
|
+
"project_id": r.project_id if r.project_id else self.project_id,
|
|
114
|
+
"from_id": r.from_id,
|
|
115
|
+
"to_id": r.to_id,
|
|
116
|
+
"to_name": r.to_name,
|
|
117
|
+
"relation_type": r.relation_type,
|
|
118
|
+
"context": r.context,
|
|
119
|
+
}
|
|
120
|
+
for r in relations
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
async with db.scoped_session(self.session_maker) as session:
|
|
124
|
+
# Check dialect to use appropriate insert
|
|
125
|
+
dialect_name = session.bind.dialect.name if session.bind else "sqlite"
|
|
126
|
+
|
|
127
|
+
if dialect_name == "postgresql": # pragma: no cover
|
|
128
|
+
# PostgreSQL: use RETURNING to count inserted rows
|
|
129
|
+
# (rowcount is 0 for ON CONFLICT DO NOTHING)
|
|
130
|
+
stmt = ( # pragma: no cover
|
|
131
|
+
pg_insert(Relation)
|
|
132
|
+
.values(values)
|
|
133
|
+
.on_conflict_do_nothing()
|
|
134
|
+
.returning(Relation.id)
|
|
135
|
+
)
|
|
136
|
+
result = await session.execute(stmt) # pragma: no cover
|
|
137
|
+
return len(result.fetchall()) # pragma: no cover
|
|
138
|
+
else:
|
|
139
|
+
# SQLite: rowcount works correctly
|
|
140
|
+
stmt = sqlite_insert(Relation).values(values)
|
|
141
|
+
stmt = stmt.on_conflict_do_nothing()
|
|
142
|
+
result = await session.execute(stmt)
|
|
143
|
+
return result.rowcount if result.rowcount > 0 else 0
|
|
144
|
+
|
|
70
145
|
def get_load_options(self) -> List[LoaderOption]:
|
|
71
146
|
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Base repository implementation."""
|
|
2
2
|
|
|
3
|
-
from typing import Type, Optional, Any, Sequence, TypeVar, List
|
|
3
|
+
from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict
|
|
4
|
+
|
|
4
5
|
|
|
5
6
|
from loguru import logger
|
|
6
7
|
from sqlalchemy import (
|
|
@@ -10,13 +11,13 @@ from sqlalchemy import (
|
|
|
10
11
|
Executable,
|
|
11
12
|
inspect,
|
|
12
13
|
Result,
|
|
13
|
-
Column,
|
|
14
14
|
and_,
|
|
15
15
|
delete,
|
|
16
16
|
)
|
|
17
17
|
from sqlalchemy.exc import NoResultFound
|
|
18
18
|
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
|
19
19
|
from sqlalchemy.orm.interfaces import LoaderOption
|
|
20
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
20
21
|
|
|
21
22
|
from basic_memory import db
|
|
22
23
|
from basic_memory.models import Base
|
|
@@ -27,12 +28,30 @@ T = TypeVar("T", bound=Base)
|
|
|
27
28
|
class Repository[T: Base]:
|
|
28
29
|
"""Base repository implementation with generic CRUD operations."""
|
|
29
30
|
|
|
30
|
-
def __init__(
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
session_maker: async_sessionmaker[AsyncSession],
|
|
34
|
+
Model: Type[T],
|
|
35
|
+
project_id: Optional[int] = None,
|
|
36
|
+
):
|
|
31
37
|
self.session_maker = session_maker
|
|
32
|
-
self.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
self.project_id = project_id
|
|
39
|
+
if Model:
|
|
40
|
+
self.Model = Model
|
|
41
|
+
self.mapper = inspect(self.Model).mapper
|
|
42
|
+
self.primary_key: ColumnElement[Any] = self.mapper.primary_key[0]
|
|
43
|
+
self.valid_columns = [column.key for column in self.mapper.columns]
|
|
44
|
+
# Check if this model has a project_id column
|
|
45
|
+
self.has_project_id = "project_id" in self.valid_columns
|
|
46
|
+
|
|
47
|
+
def _set_project_id_if_needed(self, model: T) -> None:
|
|
48
|
+
"""Set project_id on model if needed and available."""
|
|
49
|
+
if (
|
|
50
|
+
self.has_project_id
|
|
51
|
+
and self.project_id is not None
|
|
52
|
+
and getattr(model, "project_id", None) is None
|
|
53
|
+
):
|
|
54
|
+
setattr(model, "project_id", self.project_id)
|
|
36
55
|
|
|
37
56
|
def get_model_data(self, entity_data):
|
|
38
57
|
model_data = {
|
|
@@ -40,6 +59,19 @@ class Repository[T: Base]:
|
|
|
40
59
|
}
|
|
41
60
|
return model_data
|
|
42
61
|
|
|
62
|
+
def _add_project_filter(self, query: Select) -> Select:
|
|
63
|
+
"""Add project_id filter to query if applicable.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
query: The SQLAlchemy query to modify
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Updated query with project filter if applicable
|
|
70
|
+
"""
|
|
71
|
+
if self.has_project_id and self.project_id is not None:
|
|
72
|
+
query = query.filter(getattr(self.Model, "project_id") == self.project_id)
|
|
73
|
+
return query
|
|
74
|
+
|
|
43
75
|
async def select_by_id(self, session: AsyncSession, entity_id: int) -> Optional[T]:
|
|
44
76
|
"""Select an entity by ID using an existing session."""
|
|
45
77
|
query = (
|
|
@@ -47,6 +79,9 @@ class Repository[T: Base]:
|
|
|
47
79
|
.filter(self.primary_key == entity_id)
|
|
48
80
|
.options(*self.get_load_options())
|
|
49
81
|
)
|
|
82
|
+
# Add project filter if applicable
|
|
83
|
+
query = self._add_project_filter(query)
|
|
84
|
+
|
|
50
85
|
result = await session.execute(query)
|
|
51
86
|
return result.scalars().one_or_none()
|
|
52
87
|
|
|
@@ -55,6 +90,9 @@ class Repository[T: Base]:
|
|
|
55
90
|
query = (
|
|
56
91
|
select(self.Model).where(self.primary_key.in_(ids)).options(*self.get_load_options())
|
|
57
92
|
)
|
|
93
|
+
# Add project filter if applicable
|
|
94
|
+
query = self._add_project_filter(query)
|
|
95
|
+
|
|
58
96
|
result = await session.execute(query)
|
|
59
97
|
return result.scalars().all()
|
|
60
98
|
|
|
@@ -65,12 +103,23 @@ class Repository[T: Base]:
|
|
|
65
103
|
:return: the added model instance
|
|
66
104
|
"""
|
|
67
105
|
async with db.scoped_session(self.session_maker) as session:
|
|
106
|
+
# Set project_id if applicable and not already set
|
|
107
|
+
self._set_project_id_if_needed(model)
|
|
108
|
+
|
|
68
109
|
session.add(model)
|
|
69
110
|
await session.flush()
|
|
70
111
|
|
|
71
112
|
# Query within same session
|
|
72
113
|
found = await self.select_by_id(session, model.id) # pyright: ignore [reportAttributeAccessIssue]
|
|
73
|
-
|
|
114
|
+
if found is None: # pragma: no cover
|
|
115
|
+
logger.error(
|
|
116
|
+
"Failed to retrieve model after add",
|
|
117
|
+
model_type=self.Model.__name__,
|
|
118
|
+
model_id=model.id, # pyright: ignore
|
|
119
|
+
)
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"Can't find {self.Model.__name__} with ID {model.id} after session.add" # pyright: ignore
|
|
122
|
+
)
|
|
74
123
|
return found
|
|
75
124
|
|
|
76
125
|
async def add_all(self, models: List[T]) -> Sequence[T]:
|
|
@@ -80,6 +129,10 @@ class Repository[T: Base]:
|
|
|
80
129
|
:return: the added models instances
|
|
81
130
|
"""
|
|
82
131
|
async with db.scoped_session(self.session_maker) as session:
|
|
132
|
+
# set the project id if not present in models
|
|
133
|
+
for model in models:
|
|
134
|
+
self._set_project_id_if_needed(model)
|
|
135
|
+
|
|
83
136
|
session.add_all(models)
|
|
84
137
|
await session.flush()
|
|
85
138
|
|
|
@@ -95,14 +148,33 @@ class Repository[T: Base]:
|
|
|
95
148
|
"""
|
|
96
149
|
if not entities:
|
|
97
150
|
entities = (self.Model,)
|
|
98
|
-
|
|
151
|
+
query = select(*entities)
|
|
152
|
+
|
|
153
|
+
# Add project filter if applicable
|
|
154
|
+
return self._add_project_filter(query)
|
|
155
|
+
|
|
156
|
+
async def find_all(
|
|
157
|
+
self, skip: int = 0, limit: Optional[int] = None, use_load_options: bool = True
|
|
158
|
+
) -> Sequence[T]:
|
|
159
|
+
"""Fetch records from the database with pagination.
|
|
99
160
|
|
|
100
|
-
|
|
101
|
-
|
|
161
|
+
Args:
|
|
162
|
+
skip: Number of records to skip
|
|
163
|
+
limit: Maximum number of records to return
|
|
164
|
+
use_load_options: Whether to apply eager loading options (default: True)
|
|
165
|
+
"""
|
|
102
166
|
logger.debug(f"Finding all {self.Model.__name__} (skip={skip}, limit={limit})")
|
|
103
167
|
|
|
104
168
|
async with db.scoped_session(self.session_maker) as session:
|
|
105
|
-
query = select(self.Model).offset(skip)
|
|
169
|
+
query = select(self.Model).offset(skip)
|
|
170
|
+
|
|
171
|
+
# Only apply load options if requested
|
|
172
|
+
if use_load_options:
|
|
173
|
+
query = query.options(*self.get_load_options())
|
|
174
|
+
|
|
175
|
+
# Add project filter if applicable
|
|
176
|
+
query = self._add_project_filter(query)
|
|
177
|
+
|
|
106
178
|
if limit:
|
|
107
179
|
query = query.limit(limit)
|
|
108
180
|
|
|
@@ -128,17 +200,15 @@ class Repository[T: Base]:
|
|
|
128
200
|
|
|
129
201
|
async def find_one(self, query: Select[tuple[T]]) -> Optional[T]:
|
|
130
202
|
"""Execute a query and retrieve a single record."""
|
|
131
|
-
logger.debug(f"Finding one {self.Model.__name__} with query: {query}")
|
|
132
|
-
|
|
133
203
|
# add in load options
|
|
134
204
|
query = query.options(*self.get_load_options())
|
|
135
205
|
result = await self.execute_query(query)
|
|
136
206
|
entity = result.scalars().one_or_none()
|
|
137
207
|
|
|
138
208
|
if entity:
|
|
139
|
-
logger.
|
|
209
|
+
logger.trace(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
|
|
140
210
|
else:
|
|
141
|
-
logger.
|
|
211
|
+
logger.trace(f"No {self.Model.__name__} found")
|
|
142
212
|
return entity
|
|
143
213
|
|
|
144
214
|
async def create(self, data: dict) -> T:
|
|
@@ -147,12 +217,29 @@ class Repository[T: Base]:
|
|
|
147
217
|
async with db.scoped_session(self.session_maker) as session:
|
|
148
218
|
# Only include valid columns that are provided in entity_data
|
|
149
219
|
model_data = self.get_model_data(data)
|
|
220
|
+
|
|
221
|
+
# Add project_id if applicable and not already provided
|
|
222
|
+
if (
|
|
223
|
+
self.has_project_id
|
|
224
|
+
and self.project_id is not None
|
|
225
|
+
and "project_id" not in model_data
|
|
226
|
+
):
|
|
227
|
+
model_data["project_id"] = self.project_id
|
|
228
|
+
|
|
150
229
|
model = self.Model(**model_data)
|
|
151
230
|
session.add(model)
|
|
152
231
|
await session.flush()
|
|
153
232
|
|
|
154
233
|
return_instance = await self.select_by_id(session, model.id) # pyright: ignore [reportAttributeAccessIssue]
|
|
155
|
-
|
|
234
|
+
if return_instance is None: # pragma: no cover
|
|
235
|
+
logger.error(
|
|
236
|
+
"Failed to retrieve model after create",
|
|
237
|
+
model_type=self.Model.__name__,
|
|
238
|
+
model_id=model.id, # pyright: ignore
|
|
239
|
+
)
|
|
240
|
+
raise ValueError(
|
|
241
|
+
f"Can't find {self.Model.__name__} with ID {model.id} after session.add" # pyright: ignore
|
|
242
|
+
)
|
|
156
243
|
return return_instance
|
|
157
244
|
|
|
158
245
|
async def create_all(self, data_list: List[dict]) -> Sequence[T]:
|
|
@@ -161,12 +248,20 @@ class Repository[T: Base]:
|
|
|
161
248
|
|
|
162
249
|
async with db.scoped_session(self.session_maker) as session:
|
|
163
250
|
# Only include valid columns that are provided in entity_data
|
|
164
|
-
model_list = [
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
251
|
+
model_list = []
|
|
252
|
+
for d in data_list:
|
|
253
|
+
model_data = self.get_model_data(d)
|
|
254
|
+
|
|
255
|
+
# Add project_id if applicable and not already provided
|
|
256
|
+
if (
|
|
257
|
+
self.has_project_id
|
|
258
|
+
and self.project_id is not None
|
|
259
|
+
and "project_id" not in model_data
|
|
260
|
+
):
|
|
261
|
+
model_data["project_id"] = self.project_id # pragma: no cover
|
|
262
|
+
|
|
263
|
+
model_list.append(self.Model(**model_data))
|
|
264
|
+
|
|
170
265
|
session.add_all(model_list)
|
|
171
266
|
await session.flush()
|
|
172
267
|
|
|
@@ -222,7 +317,13 @@ class Repository[T: Base]:
|
|
|
222
317
|
"""Delete records matching given IDs."""
|
|
223
318
|
logger.debug(f"Deleting {self.Model.__name__} by ids: {ids}")
|
|
224
319
|
async with db.scoped_session(self.session_maker) as session:
|
|
225
|
-
|
|
320
|
+
conditions = [self.primary_key.in_(ids)]
|
|
321
|
+
|
|
322
|
+
# Add project_id filter if applicable
|
|
323
|
+
if self.has_project_id and self.project_id is not None: # pragma: no cover
|
|
324
|
+
conditions.append(getattr(self.Model, "project_id") == self.project_id)
|
|
325
|
+
|
|
326
|
+
query = delete(self.Model).where(and_(*conditions))
|
|
226
327
|
result = await session.execute(query)
|
|
227
328
|
logger.debug(f"Deleted {result.rowcount} records")
|
|
228
329
|
return result.rowcount
|
|
@@ -232,6 +333,11 @@ class Repository[T: Base]:
|
|
|
232
333
|
logger.debug(f"Deleting {self.Model.__name__} by fields: {filters}")
|
|
233
334
|
async with db.scoped_session(self.session_maker) as session:
|
|
234
335
|
conditions = [getattr(self.Model, field) == value for field, value in filters.items()]
|
|
336
|
+
|
|
337
|
+
# Add project_id filter if applicable
|
|
338
|
+
if self.has_project_id and self.project_id is not None:
|
|
339
|
+
conditions.append(getattr(self.Model, "project_id") == self.project_id)
|
|
340
|
+
|
|
235
341
|
query = delete(self.Model).where(and_(*conditions))
|
|
236
342
|
result = await session.execute(query)
|
|
237
343
|
deleted = result.rowcount > 0
|
|
@@ -243,21 +349,34 @@ class Repository[T: Base]:
|
|
|
243
349
|
async with db.scoped_session(self.session_maker) as session:
|
|
244
350
|
if query is None:
|
|
245
351
|
query = select(func.count()).select_from(self.Model)
|
|
352
|
+
# Add project filter if applicable
|
|
353
|
+
if (
|
|
354
|
+
isinstance(query, Select)
|
|
355
|
+
and self.has_project_id
|
|
356
|
+
and self.project_id is not None
|
|
357
|
+
):
|
|
358
|
+
query = query.where(
|
|
359
|
+
getattr(self.Model, "project_id") == self.project_id
|
|
360
|
+
) # pragma: no cover
|
|
361
|
+
|
|
246
362
|
result = await session.execute(query)
|
|
247
363
|
scalar = result.scalar()
|
|
248
364
|
count = scalar if scalar is not None else 0
|
|
249
365
|
logger.debug(f"Counted {count} {self.Model.__name__} records")
|
|
250
366
|
return count
|
|
251
367
|
|
|
252
|
-
async def execute_query(
|
|
368
|
+
async def execute_query(
|
|
369
|
+
self,
|
|
370
|
+
query: Executable,
|
|
371
|
+
params: Optional[Dict[str, Any]] = None,
|
|
372
|
+
use_query_options: bool = True,
|
|
373
|
+
) -> Result[Any]:
|
|
253
374
|
"""Execute a query asynchronously."""
|
|
254
375
|
|
|
255
376
|
query = query.options(*self.get_load_options()) if use_query_options else query
|
|
256
|
-
|
|
257
|
-
logger.debug(f"Executing query: {query}")
|
|
377
|
+
logger.trace(f"Executing query: {query}, params: {params}")
|
|
258
378
|
async with db.scoped_session(self.session_maker) as session:
|
|
259
|
-
result = await session.execute(query)
|
|
260
|
-
logger.debug("Query executed successfully")
|
|
379
|
+
result = await session.execute(query, params)
|
|
261
380
|
return result
|
|
262
381
|
|
|
263
382
|
def get_load_options(self) -> List[LoaderOption]:
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Search index data structures."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from basic_memory.schemas.search import SearchItemType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SearchIndexRow:
|
|
14
|
+
"""Search result with score and metadata."""
|
|
15
|
+
|
|
16
|
+
project_id: int
|
|
17
|
+
id: int
|
|
18
|
+
type: str
|
|
19
|
+
file_path: str
|
|
20
|
+
|
|
21
|
+
# date values
|
|
22
|
+
created_at: datetime
|
|
23
|
+
updated_at: datetime
|
|
24
|
+
|
|
25
|
+
permalink: Optional[str] = None
|
|
26
|
+
metadata: Optional[dict] = None
|
|
27
|
+
|
|
28
|
+
# assigned in result
|
|
29
|
+
score: Optional[float] = None
|
|
30
|
+
|
|
31
|
+
# Type-specific fields
|
|
32
|
+
title: Optional[str] = None # entity
|
|
33
|
+
content_stems: Optional[str] = None # entity, observation
|
|
34
|
+
content_snippet: Optional[str] = None # entity, observation
|
|
35
|
+
entity_id: Optional[int] = None # observations
|
|
36
|
+
category: Optional[str] = None # observations
|
|
37
|
+
from_id: Optional[int] = None # relations
|
|
38
|
+
to_id: Optional[int] = None # relations
|
|
39
|
+
relation_type: Optional[str] = None # relations
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def content(self):
|
|
43
|
+
return self.content_snippet
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def directory(self) -> str:
|
|
47
|
+
"""Extract directory part from file_path.
|
|
48
|
+
|
|
49
|
+
For a file at "projects/notes/ideas.md", returns "/projects/notes"
|
|
50
|
+
For a file at root level "README.md", returns "/"
|
|
51
|
+
"""
|
|
52
|
+
if not self.type == SearchItemType.ENTITY.value and not self.file_path:
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
# Normalize path separators to handle both Windows (\) and Unix (/) paths
|
|
56
|
+
normalized_path = Path(self.file_path).as_posix()
|
|
57
|
+
|
|
58
|
+
# Split the path by slashes
|
|
59
|
+
parts = normalized_path.split("/")
|
|
60
|
+
|
|
61
|
+
# If there's only one part (e.g., "README.md"), it's at the root
|
|
62
|
+
if len(parts) <= 1:
|
|
63
|
+
return "/"
|
|
64
|
+
|
|
65
|
+
# Join all parts except the last one (filename)
|
|
66
|
+
directory_path = "/".join(parts[:-1])
|
|
67
|
+
return f"/{directory_path}"
|
|
68
|
+
|
|
69
|
+
def to_insert(self, serialize_json: bool = True):
|
|
70
|
+
"""Convert to dict for database insertion.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
serialize_json: If True, converts metadata dict to JSON string (for SQLite).
|
|
74
|
+
If False, keeps metadata as dict (for Postgres JSONB).
|
|
75
|
+
"""
|
|
76
|
+
return {
|
|
77
|
+
"id": self.id,
|
|
78
|
+
"title": self.title,
|
|
79
|
+
"content_stems": self.content_stems,
|
|
80
|
+
"content_snippet": self.content_snippet,
|
|
81
|
+
"permalink": self.permalink,
|
|
82
|
+
"file_path": self.file_path,
|
|
83
|
+
"type": self.type,
|
|
84
|
+
"metadata": json.dumps(self.metadata)
|
|
85
|
+
if serialize_json and self.metadata
|
|
86
|
+
else self.metadata,
|
|
87
|
+
"from_id": self.from_id,
|
|
88
|
+
"to_id": self.to_id,
|
|
89
|
+
"relation_type": self.relation_type,
|
|
90
|
+
"entity_id": self.entity_id,
|
|
91
|
+
"category": self.category,
|
|
92
|
+
"created_at": self.created_at if self.created_at else None,
|
|
93
|
+
"updated_at": self.updated_at if self.updated_at else None,
|
|
94
|
+
"project_id": self.project_id,
|
|
95
|
+
}
|