basic-memory 0.16.1__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 +1 -1
- basic_memory/alembic/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -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 +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- 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 +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- 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 +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- 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 +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- 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/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- 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/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Add project_id to relation/observation and pg_trgm for fuzzy link resolution
|
|
2
|
+
|
|
3
|
+
Revision ID: f8a9b2c3d4e5
|
|
4
|
+
Revises: 314f1ea54dc4
|
|
5
|
+
Create Date: 2025-12-01 12:00:00.000000
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Sequence, Union
|
|
10
|
+
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
from alembic import op
|
|
13
|
+
from sqlalchemy import text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def column_exists(connection, table: str, column: str) -> bool:
|
|
17
|
+
"""Check if a column exists in a table (idempotent migration support)."""
|
|
18
|
+
if connection.dialect.name == "postgresql":
|
|
19
|
+
result = connection.execute(
|
|
20
|
+
text(
|
|
21
|
+
"SELECT 1 FROM information_schema.columns "
|
|
22
|
+
"WHERE table_name = :table AND column_name = :column"
|
|
23
|
+
),
|
|
24
|
+
{"table": table, "column": column},
|
|
25
|
+
)
|
|
26
|
+
return result.fetchone() is not None
|
|
27
|
+
else:
|
|
28
|
+
# SQLite
|
|
29
|
+
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
|
30
|
+
columns = [row[1] for row in result]
|
|
31
|
+
return column in columns
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def index_exists(connection, index_name: str) -> bool:
|
|
35
|
+
"""Check if an index exists (idempotent migration support)."""
|
|
36
|
+
if connection.dialect.name == "postgresql":
|
|
37
|
+
result = connection.execute(
|
|
38
|
+
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
|
39
|
+
{"index_name": index_name},
|
|
40
|
+
)
|
|
41
|
+
return result.fetchone() is not None
|
|
42
|
+
else:
|
|
43
|
+
# SQLite
|
|
44
|
+
result = connection.execute(
|
|
45
|
+
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
|
|
46
|
+
{"index_name": index_name},
|
|
47
|
+
)
|
|
48
|
+
return result.fetchone() is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# revision identifiers, used by Alembic.
|
|
52
|
+
revision: str = "f8a9b2c3d4e5"
|
|
53
|
+
down_revision: Union[str, None] = "314f1ea54dc4"
|
|
54
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
55
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def upgrade() -> None:
|
|
59
|
+
"""Add project_id to relation and observation tables, plus pg_trgm indexes.
|
|
60
|
+
|
|
61
|
+
This migration:
|
|
62
|
+
1. Adds project_id column to relation and observation tables (denormalization)
|
|
63
|
+
2. Backfills project_id from the associated entity
|
|
64
|
+
3. Enables pg_trgm extension for trigram-based fuzzy matching (Postgres only)
|
|
65
|
+
4. Creates GIN indexes on entity title and permalink for fast similarity searches
|
|
66
|
+
5. Creates partial index on unresolved relations for efficient bulk resolution
|
|
67
|
+
"""
|
|
68
|
+
connection = op.get_bind()
|
|
69
|
+
dialect = connection.dialect.name
|
|
70
|
+
|
|
71
|
+
# -------------------------------------------------------------------------
|
|
72
|
+
# Add project_id to relation table
|
|
73
|
+
# -------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
# Step 1: Add project_id column as nullable first (idempotent)
|
|
76
|
+
if not column_exists(connection, "relation", "project_id"):
|
|
77
|
+
op.add_column("relation", sa.Column("project_id", sa.Integer(), nullable=True))
|
|
78
|
+
|
|
79
|
+
# Step 2: Backfill project_id from entity.project_id via from_id
|
|
80
|
+
if dialect == "postgresql":
|
|
81
|
+
op.execute("""
|
|
82
|
+
UPDATE relation
|
|
83
|
+
SET project_id = entity.project_id
|
|
84
|
+
FROM entity
|
|
85
|
+
WHERE relation.from_id = entity.id
|
|
86
|
+
""")
|
|
87
|
+
else:
|
|
88
|
+
# SQLite syntax
|
|
89
|
+
op.execute("""
|
|
90
|
+
UPDATE relation
|
|
91
|
+
SET project_id = (
|
|
92
|
+
SELECT entity.project_id
|
|
93
|
+
FROM entity
|
|
94
|
+
WHERE entity.id = relation.from_id
|
|
95
|
+
)
|
|
96
|
+
""")
|
|
97
|
+
|
|
98
|
+
# Step 3: Make project_id NOT NULL and add foreign key
|
|
99
|
+
if dialect == "postgresql":
|
|
100
|
+
op.alter_column("relation", "project_id", nullable=False)
|
|
101
|
+
op.create_foreign_key(
|
|
102
|
+
"fk_relation_project_id",
|
|
103
|
+
"relation",
|
|
104
|
+
"project",
|
|
105
|
+
["project_id"],
|
|
106
|
+
["id"],
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
# SQLite requires batch operations for ALTER COLUMN
|
|
110
|
+
with op.batch_alter_table("relation") as batch_op:
|
|
111
|
+
batch_op.alter_column("project_id", nullable=False)
|
|
112
|
+
batch_op.create_foreign_key(
|
|
113
|
+
"fk_relation_project_id",
|
|
114
|
+
"project",
|
|
115
|
+
["project_id"],
|
|
116
|
+
["id"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Step 4: Create index on relation.project_id (idempotent)
|
|
120
|
+
if not index_exists(connection, "ix_relation_project_id"):
|
|
121
|
+
op.create_index("ix_relation_project_id", "relation", ["project_id"])
|
|
122
|
+
|
|
123
|
+
# -------------------------------------------------------------------------
|
|
124
|
+
# Add project_id to observation table
|
|
125
|
+
# -------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
# Step 1: Add project_id column as nullable first (idempotent)
|
|
128
|
+
if not column_exists(connection, "observation", "project_id"):
|
|
129
|
+
op.add_column("observation", sa.Column("project_id", sa.Integer(), nullable=True))
|
|
130
|
+
|
|
131
|
+
# Step 2: Backfill project_id from entity.project_id via entity_id
|
|
132
|
+
if dialect == "postgresql":
|
|
133
|
+
op.execute("""
|
|
134
|
+
UPDATE observation
|
|
135
|
+
SET project_id = entity.project_id
|
|
136
|
+
FROM entity
|
|
137
|
+
WHERE observation.entity_id = entity.id
|
|
138
|
+
""")
|
|
139
|
+
else:
|
|
140
|
+
# SQLite syntax
|
|
141
|
+
op.execute("""
|
|
142
|
+
UPDATE observation
|
|
143
|
+
SET project_id = (
|
|
144
|
+
SELECT entity.project_id
|
|
145
|
+
FROM entity
|
|
146
|
+
WHERE entity.id = observation.entity_id
|
|
147
|
+
)
|
|
148
|
+
""")
|
|
149
|
+
|
|
150
|
+
# Step 3: Make project_id NOT NULL and add foreign key
|
|
151
|
+
if dialect == "postgresql":
|
|
152
|
+
op.alter_column("observation", "project_id", nullable=False)
|
|
153
|
+
op.create_foreign_key(
|
|
154
|
+
"fk_observation_project_id",
|
|
155
|
+
"observation",
|
|
156
|
+
"project",
|
|
157
|
+
["project_id"],
|
|
158
|
+
["id"],
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
# SQLite requires batch operations for ALTER COLUMN
|
|
162
|
+
with op.batch_alter_table("observation") as batch_op:
|
|
163
|
+
batch_op.alter_column("project_id", nullable=False)
|
|
164
|
+
batch_op.create_foreign_key(
|
|
165
|
+
"fk_observation_project_id",
|
|
166
|
+
"project",
|
|
167
|
+
["project_id"],
|
|
168
|
+
["id"],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Step 4: Create index on observation.project_id (idempotent)
|
|
172
|
+
if not index_exists(connection, "ix_observation_project_id"):
|
|
173
|
+
op.create_index("ix_observation_project_id", "observation", ["project_id"])
|
|
174
|
+
|
|
175
|
+
# Postgres-specific: pg_trgm and GIN indexes
|
|
176
|
+
if dialect == "postgresql":
|
|
177
|
+
# Enable pg_trgm extension for fuzzy string matching
|
|
178
|
+
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
|
179
|
+
|
|
180
|
+
# Create trigram indexes on entity table for fuzzy matching
|
|
181
|
+
# GIN indexes with gin_trgm_ops support similarity searches
|
|
182
|
+
op.execute("""
|
|
183
|
+
CREATE INDEX IF NOT EXISTS idx_entity_title_trgm
|
|
184
|
+
ON entity USING gin (title gin_trgm_ops)
|
|
185
|
+
""")
|
|
186
|
+
|
|
187
|
+
op.execute("""
|
|
188
|
+
CREATE INDEX IF NOT EXISTS idx_entity_permalink_trgm
|
|
189
|
+
ON entity USING gin (permalink gin_trgm_ops)
|
|
190
|
+
""")
|
|
191
|
+
|
|
192
|
+
# Create partial index on unresolved relations for efficient bulk resolution
|
|
193
|
+
# This makes "WHERE to_id IS NULL AND project_id = X" queries very fast
|
|
194
|
+
op.execute("""
|
|
195
|
+
CREATE INDEX IF NOT EXISTS idx_relation_unresolved
|
|
196
|
+
ON relation (project_id, to_name)
|
|
197
|
+
WHERE to_id IS NULL
|
|
198
|
+
""")
|
|
199
|
+
|
|
200
|
+
# Create index on relation.to_name for join performance in bulk resolution
|
|
201
|
+
op.execute("""
|
|
202
|
+
CREATE INDEX IF NOT EXISTS idx_relation_to_name
|
|
203
|
+
ON relation (to_name)
|
|
204
|
+
""")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def downgrade() -> None:
|
|
208
|
+
"""Remove project_id from relation/observation and pg_trgm indexes."""
|
|
209
|
+
connection = op.get_bind()
|
|
210
|
+
dialect = connection.dialect.name
|
|
211
|
+
|
|
212
|
+
if dialect == "postgresql":
|
|
213
|
+
# Drop Postgres-specific indexes
|
|
214
|
+
op.execute("DROP INDEX IF EXISTS idx_relation_to_name")
|
|
215
|
+
op.execute("DROP INDEX IF EXISTS idx_relation_unresolved")
|
|
216
|
+
op.execute("DROP INDEX IF EXISTS idx_entity_permalink_trgm")
|
|
217
|
+
op.execute("DROP INDEX IF EXISTS idx_entity_title_trgm")
|
|
218
|
+
# Note: We don't drop the pg_trgm extension as other code may depend on it
|
|
219
|
+
|
|
220
|
+
# Drop project_id from observation
|
|
221
|
+
op.drop_index("ix_observation_project_id", table_name="observation")
|
|
222
|
+
op.drop_constraint("fk_observation_project_id", "observation", type_="foreignkey")
|
|
223
|
+
op.drop_column("observation", "project_id")
|
|
224
|
+
|
|
225
|
+
# Drop project_id from relation
|
|
226
|
+
op.drop_index("ix_relation_project_id", table_name="relation")
|
|
227
|
+
op.drop_constraint("fk_relation_project_id", "relation", type_="foreignkey")
|
|
228
|
+
op.drop_column("relation", "project_id")
|
|
229
|
+
else:
|
|
230
|
+
# SQLite requires batch operations
|
|
231
|
+
op.drop_index("ix_observation_project_id", table_name="observation")
|
|
232
|
+
with op.batch_alter_table("observation") as batch_op:
|
|
233
|
+
batch_op.drop_constraint("fk_observation_project_id", type_="foreignkey")
|
|
234
|
+
batch_op.drop_column("project_id")
|
|
235
|
+
|
|
236
|
+
op.drop_index("ix_relation_project_id", table_name="relation")
|
|
237
|
+
with op.batch_alter_table("relation") as batch_op:
|
|
238
|
+
batch_op.drop_constraint("fk_relation_project_id", type_="foreignkey")
|
|
239
|
+
batch_op.drop_column("project_id")
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Add external_id UUID column to project and entity tables
|
|
2
|
+
|
|
3
|
+
Revision ID: g9a0b3c4d5e6
|
|
4
|
+
Revises: f8a9b2c3d4e5
|
|
5
|
+
Create Date: 2025-12-29 10:00:00.000000
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Sequence, Union
|
|
11
|
+
|
|
12
|
+
import sqlalchemy as sa
|
|
13
|
+
from alembic import op
|
|
14
|
+
from sqlalchemy import text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def column_exists(connection, table: str, column: str) -> bool:
|
|
18
|
+
"""Check if a column exists in a table (idempotent migration support)."""
|
|
19
|
+
if connection.dialect.name == "postgresql":
|
|
20
|
+
result = connection.execute(
|
|
21
|
+
text(
|
|
22
|
+
"SELECT 1 FROM information_schema.columns "
|
|
23
|
+
"WHERE table_name = :table AND column_name = :column"
|
|
24
|
+
),
|
|
25
|
+
{"table": table, "column": column},
|
|
26
|
+
)
|
|
27
|
+
return result.fetchone() is not None
|
|
28
|
+
else:
|
|
29
|
+
# SQLite
|
|
30
|
+
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
|
31
|
+
columns = [row[1] for row in result]
|
|
32
|
+
return column in columns
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def index_exists(connection, index_name: str) -> bool:
|
|
36
|
+
"""Check if an index exists (idempotent migration support)."""
|
|
37
|
+
if connection.dialect.name == "postgresql":
|
|
38
|
+
result = connection.execute(
|
|
39
|
+
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
|
40
|
+
{"index_name": index_name},
|
|
41
|
+
)
|
|
42
|
+
return result.fetchone() is not None
|
|
43
|
+
else:
|
|
44
|
+
# SQLite
|
|
45
|
+
result = connection.execute(
|
|
46
|
+
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
|
|
47
|
+
{"index_name": index_name},
|
|
48
|
+
)
|
|
49
|
+
return result.fetchone() is not None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# revision identifiers, used by Alembic.
|
|
53
|
+
revision: str = "g9a0b3c4d5e6"
|
|
54
|
+
down_revision: Union[str, None] = "f8a9b2c3d4e5"
|
|
55
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
56
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def upgrade() -> None:
|
|
60
|
+
"""Add external_id UUID column to project and entity tables.
|
|
61
|
+
|
|
62
|
+
This migration:
|
|
63
|
+
1. Adds external_id column to project table
|
|
64
|
+
2. Adds external_id column to entity table
|
|
65
|
+
3. Generates UUIDs for existing rows
|
|
66
|
+
4. Creates unique indexes on both columns
|
|
67
|
+
"""
|
|
68
|
+
connection = op.get_bind()
|
|
69
|
+
dialect = connection.dialect.name
|
|
70
|
+
|
|
71
|
+
# -------------------------------------------------------------------------
|
|
72
|
+
# Add external_id to project table
|
|
73
|
+
# -------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
if not column_exists(connection, "project", "external_id"):
|
|
76
|
+
# Step 1: Add external_id column as nullable first
|
|
77
|
+
op.add_column("project", sa.Column("external_id", sa.String(), nullable=True))
|
|
78
|
+
|
|
79
|
+
# Step 2: Generate UUIDs for existing rows
|
|
80
|
+
if dialect == "postgresql":
|
|
81
|
+
# Postgres has gen_random_uuid() function
|
|
82
|
+
op.execute("""
|
|
83
|
+
UPDATE project
|
|
84
|
+
SET external_id = gen_random_uuid()::text
|
|
85
|
+
WHERE external_id IS NULL
|
|
86
|
+
""")
|
|
87
|
+
else:
|
|
88
|
+
# SQLite: need to generate UUIDs in Python
|
|
89
|
+
result = connection.execute(text("SELECT id FROM project WHERE external_id IS NULL"))
|
|
90
|
+
for row in result:
|
|
91
|
+
new_uuid = str(uuid.uuid4())
|
|
92
|
+
connection.execute(
|
|
93
|
+
text("UPDATE project SET external_id = :uuid WHERE id = :id"),
|
|
94
|
+
{"uuid": new_uuid, "id": row[0]},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Step 3: Make external_id NOT NULL
|
|
98
|
+
if dialect == "postgresql":
|
|
99
|
+
op.alter_column("project", "external_id", nullable=False)
|
|
100
|
+
else:
|
|
101
|
+
# SQLite requires batch operations for ALTER COLUMN
|
|
102
|
+
with op.batch_alter_table("project") as batch_op:
|
|
103
|
+
batch_op.alter_column("external_id", nullable=False)
|
|
104
|
+
|
|
105
|
+
# Step 4: Create unique index on project.external_id (idempotent)
|
|
106
|
+
if not index_exists(connection, "ix_project_external_id"):
|
|
107
|
+
op.create_index("ix_project_external_id", "project", ["external_id"], unique=True)
|
|
108
|
+
|
|
109
|
+
# -------------------------------------------------------------------------
|
|
110
|
+
# Add external_id to entity table
|
|
111
|
+
# -------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
if not column_exists(connection, "entity", "external_id"):
|
|
114
|
+
# Step 1: Add external_id column as nullable first
|
|
115
|
+
op.add_column("entity", sa.Column("external_id", sa.String(), nullable=True))
|
|
116
|
+
|
|
117
|
+
# Step 2: Generate UUIDs for existing rows
|
|
118
|
+
if dialect == "postgresql":
|
|
119
|
+
# Postgres has gen_random_uuid() function
|
|
120
|
+
op.execute("""
|
|
121
|
+
UPDATE entity
|
|
122
|
+
SET external_id = gen_random_uuid()::text
|
|
123
|
+
WHERE external_id IS NULL
|
|
124
|
+
""")
|
|
125
|
+
else:
|
|
126
|
+
# SQLite: need to generate UUIDs in Python
|
|
127
|
+
result = connection.execute(text("SELECT id FROM entity WHERE external_id IS NULL"))
|
|
128
|
+
for row in result:
|
|
129
|
+
new_uuid = str(uuid.uuid4())
|
|
130
|
+
connection.execute(
|
|
131
|
+
text("UPDATE entity SET external_id = :uuid WHERE id = :id"),
|
|
132
|
+
{"uuid": new_uuid, "id": row[0]},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Step 3: Make external_id NOT NULL
|
|
136
|
+
if dialect == "postgresql":
|
|
137
|
+
op.alter_column("entity", "external_id", nullable=False)
|
|
138
|
+
else:
|
|
139
|
+
# SQLite requires batch operations for ALTER COLUMN
|
|
140
|
+
with op.batch_alter_table("entity") as batch_op:
|
|
141
|
+
batch_op.alter_column("external_id", nullable=False)
|
|
142
|
+
|
|
143
|
+
# Step 4: Create unique index on entity.external_id (idempotent)
|
|
144
|
+
if not index_exists(connection, "ix_entity_external_id"):
|
|
145
|
+
op.create_index("ix_entity_external_id", "entity", ["external_id"], unique=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def downgrade() -> None:
|
|
149
|
+
"""Remove external_id columns from project and entity tables."""
|
|
150
|
+
connection = op.get_bind()
|
|
151
|
+
dialect = connection.dialect.name
|
|
152
|
+
|
|
153
|
+
# Drop from entity table
|
|
154
|
+
if index_exists(connection, "ix_entity_external_id"):
|
|
155
|
+
op.drop_index("ix_entity_external_id", table_name="entity")
|
|
156
|
+
|
|
157
|
+
if column_exists(connection, "entity", "external_id"):
|
|
158
|
+
if dialect == "postgresql":
|
|
159
|
+
op.drop_column("entity", "external_id")
|
|
160
|
+
else:
|
|
161
|
+
with op.batch_alter_table("entity") as batch_op:
|
|
162
|
+
batch_op.drop_column("external_id")
|
|
163
|
+
|
|
164
|
+
# Drop from project table
|
|
165
|
+
if index_exists(connection, "ix_project_external_id"):
|
|
166
|
+
op.drop_index("ix_project_external_id", table_name="project")
|
|
167
|
+
|
|
168
|
+
if column_exists(connection, "project", "external_id"):
|
|
169
|
+
if dialect == "postgresql":
|
|
170
|
+
op.drop_column("project", "external_id")
|
|
171
|
+
else:
|
|
172
|
+
with op.batch_alter_table("project") as batch_op:
|
|
173
|
+
batch_op.drop_column("external_id")
|
basic_memory/api/app.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""FastAPI application for basic-memory knowledge graph API."""
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
3
|
from contextlib import asynccontextmanager
|
|
5
4
|
|
|
6
5
|
from fastapi import FastAPI, HTTPException
|
|
@@ -8,7 +7,7 @@ from fastapi.exception_handlers import http_exception_handler
|
|
|
8
7
|
from loguru import logger
|
|
9
8
|
|
|
10
9
|
from basic_memory import __version__ as version
|
|
11
|
-
from basic_memory import
|
|
10
|
+
from basic_memory.api.container import ApiContainer, set_container
|
|
12
11
|
from basic_memory.api.routers import (
|
|
13
12
|
directory_router,
|
|
14
13
|
importer_router,
|
|
@@ -20,42 +19,57 @@ from basic_memory.api.routers import (
|
|
|
20
19
|
search,
|
|
21
20
|
prompt_router,
|
|
22
21
|
)
|
|
23
|
-
from basic_memory.
|
|
24
|
-
|
|
22
|
+
from basic_memory.api.v2.routers import (
|
|
23
|
+
knowledge_router as v2_knowledge,
|
|
24
|
+
project_router as v2_project,
|
|
25
|
+
memory_router as v2_memory,
|
|
26
|
+
search_router as v2_search,
|
|
27
|
+
resource_router as v2_resource,
|
|
28
|
+
directory_router as v2_directory,
|
|
29
|
+
prompt_router as v2_prompt,
|
|
30
|
+
importer_router as v2_importer,
|
|
31
|
+
)
|
|
32
|
+
from basic_memory.config import init_api_logging
|
|
33
|
+
from basic_memory.services.initialization import initialize_app
|
|
25
34
|
|
|
26
35
|
|
|
27
36
|
@asynccontextmanager
|
|
28
37
|
async def lifespan(app: FastAPI): # pragma: no cover
|
|
29
38
|
"""Lifecycle manager for the FastAPI app. Not called in stdio mcp mode"""
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
# Initialize logging for API (stdout in cloud mode, file otherwise)
|
|
41
|
+
init_api_logging()
|
|
42
|
+
|
|
43
|
+
# --- Composition Root ---
|
|
44
|
+
# Create container and read config (single point of config access)
|
|
45
|
+
container = ApiContainer.create()
|
|
46
|
+
set_container(container)
|
|
47
|
+
app.state.container = container
|
|
48
|
+
|
|
49
|
+
logger.info(f"Starting Basic Memory API (mode={container.mode.name})")
|
|
33
50
|
|
|
34
|
-
await initialize_app(
|
|
51
|
+
await initialize_app(container.config)
|
|
35
52
|
|
|
36
53
|
# Cache database connections in app state for performance
|
|
37
54
|
logger.info("Initializing database and caching connections...")
|
|
38
|
-
engine, session_maker = await
|
|
55
|
+
engine, session_maker = await container.init_database()
|
|
39
56
|
app.state.engine = engine
|
|
40
57
|
app.state.session_maker = session_maker
|
|
41
58
|
logger.info("Database connections cached in app state")
|
|
42
59
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
else:
|
|
48
|
-
logger.info("Sync changes disabled. Skipping file sync service.")
|
|
60
|
+
# Create and start sync coordinator (lifecycle centralized in coordinator)
|
|
61
|
+
sync_coordinator = container.create_sync_coordinator()
|
|
62
|
+
await sync_coordinator.start()
|
|
63
|
+
app.state.sync_coordinator = sync_coordinator
|
|
49
64
|
|
|
50
|
-
#
|
|
65
|
+
# Proceed with startup
|
|
51
66
|
yield
|
|
52
67
|
|
|
68
|
+
# Shutdown - coordinator handles clean task cancellation
|
|
53
69
|
logger.info("Shutting down Basic Memory API")
|
|
54
|
-
|
|
55
|
-
logger.info("Stopping sync...")
|
|
56
|
-
app.state.sync_task.cancel() # pyright: ignore
|
|
70
|
+
await sync_coordinator.stop()
|
|
57
71
|
|
|
58
|
-
await
|
|
72
|
+
await container.shutdown_database()
|
|
59
73
|
|
|
60
74
|
|
|
61
75
|
# Initialize FastAPI app
|
|
@@ -66,8 +80,17 @@ app = FastAPI(
|
|
|
66
80
|
lifespan=lifespan,
|
|
67
81
|
)
|
|
68
82
|
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
# Include v2 routers FIRST (more specific paths must match before /{project} catch-all)
|
|
84
|
+
app.include_router(v2_knowledge, prefix="/v2/projects/{project_id}")
|
|
85
|
+
app.include_router(v2_memory, prefix="/v2/projects/{project_id}")
|
|
86
|
+
app.include_router(v2_search, prefix="/v2/projects/{project_id}")
|
|
87
|
+
app.include_router(v2_resource, prefix="/v2/projects/{project_id}")
|
|
88
|
+
app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
|
|
89
|
+
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
|
|
90
|
+
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
|
|
91
|
+
app.include_router(v2_project, prefix="/v2")
|
|
92
|
+
|
|
93
|
+
# Include v1 routers (/{project} is a catch-all, must come after specific prefixes)
|
|
71
94
|
app.include_router(knowledge.router, prefix="/{project}")
|
|
72
95
|
app.include_router(memory.router, prefix="/{project}")
|
|
73
96
|
app.include_router(resource.router, prefix="/{project}")
|
|
@@ -77,12 +100,10 @@ app.include_router(directory_router.router, prefix="/{project}")
|
|
|
77
100
|
app.include_router(prompt_router.router, prefix="/{project}")
|
|
78
101
|
app.include_router(importer_router.router, prefix="/{project}")
|
|
79
102
|
|
|
80
|
-
# Project resource router works
|
|
103
|
+
# Project resource router works across projects
|
|
81
104
|
app.include_router(project.project_resource_router)
|
|
82
105
|
app.include_router(management.router)
|
|
83
106
|
|
|
84
|
-
# Auth routes are handled by FastMCP automatically when auth is enabled
|
|
85
|
-
|
|
86
107
|
|
|
87
108
|
@app.exception_handler(Exception)
|
|
88
109
|
async def exception_handler(request, exc): # pragma: no cover
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""API composition root for Basic Memory.
|
|
2
|
+
|
|
3
|
+
This container owns reading ConfigManager and environment variables for the
|
|
4
|
+
API entrypoint. Downstream modules receive config/dependencies explicitly
|
|
5
|
+
rather than reading globals.
|
|
6
|
+
|
|
7
|
+
Design principles:
|
|
8
|
+
- Only this module reads ConfigManager directly
|
|
9
|
+
- Runtime mode (cloud/local/test) is resolved here
|
|
10
|
+
- Factories for services are provided, not singletons
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
|
17
|
+
|
|
18
|
+
from basic_memory import db
|
|
19
|
+
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
|
20
|
+
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
23
|
+
from basic_memory.sync import SyncCoordinator
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ApiContainer:
|
|
28
|
+
"""Composition root for the API entrypoint.
|
|
29
|
+
|
|
30
|
+
Holds resolved configuration and runtime context.
|
|
31
|
+
Created once at app startup, then used to wire dependencies.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
config: BasicMemoryConfig
|
|
35
|
+
mode: RuntimeMode
|
|
36
|
+
|
|
37
|
+
# --- Database ---
|
|
38
|
+
# Cached database connections (set during lifespan startup)
|
|
39
|
+
engine: AsyncEngine | None = None
|
|
40
|
+
session_maker: async_sessionmaker[AsyncSession] | None = None
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def create(cls) -> "ApiContainer": # pragma: no cover
|
|
44
|
+
"""Create container by reading ConfigManager.
|
|
45
|
+
|
|
46
|
+
This is the single point where API reads global config.
|
|
47
|
+
"""
|
|
48
|
+
config = ConfigManager().config
|
|
49
|
+
mode = resolve_runtime_mode(
|
|
50
|
+
cloud_mode_enabled=config.cloud_mode_enabled,
|
|
51
|
+
is_test_env=config.is_test_env,
|
|
52
|
+
)
|
|
53
|
+
return cls(config=config, mode=mode)
|
|
54
|
+
|
|
55
|
+
# --- Runtime Mode Properties ---
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def should_sync_files(self) -> bool:
|
|
59
|
+
"""Whether file sync should be started.
|
|
60
|
+
|
|
61
|
+
Sync is enabled when:
|
|
62
|
+
- sync_changes is True in config
|
|
63
|
+
- Not in test mode (tests manage their own sync)
|
|
64
|
+
"""
|
|
65
|
+
return self.config.sync_changes and not self.mode.is_test
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def sync_skip_reason(self) -> str | None: # pragma: no cover
|
|
69
|
+
"""Reason why sync is skipped, or None if sync should run.
|
|
70
|
+
|
|
71
|
+
Useful for logging why sync was disabled.
|
|
72
|
+
"""
|
|
73
|
+
if self.mode.is_test:
|
|
74
|
+
return "Test environment detected"
|
|
75
|
+
if not self.config.sync_changes:
|
|
76
|
+
return "Sync changes disabled"
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def create_sync_coordinator(self) -> "SyncCoordinator": # pragma: no cover
|
|
80
|
+
"""Create a SyncCoordinator with this container's settings.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
SyncCoordinator configured for this runtime environment
|
|
84
|
+
"""
|
|
85
|
+
# Deferred import to avoid circular dependency
|
|
86
|
+
from basic_memory.sync import SyncCoordinator
|
|
87
|
+
|
|
88
|
+
return SyncCoordinator(
|
|
89
|
+
config=self.config,
|
|
90
|
+
should_sync=self.should_sync_files,
|
|
91
|
+
skip_reason=self.sync_skip_reason,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# --- Database Factory ---
|
|
95
|
+
|
|
96
|
+
async def init_database( # pragma: no cover
|
|
97
|
+
self,
|
|
98
|
+
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
|
99
|
+
"""Initialize and cache database connections.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Tuple of (engine, session_maker)
|
|
103
|
+
"""
|
|
104
|
+
engine, session_maker = await db.get_or_create_db(self.config.database_path)
|
|
105
|
+
self.engine = engine
|
|
106
|
+
self.session_maker = session_maker
|
|
107
|
+
return engine, session_maker
|
|
108
|
+
|
|
109
|
+
async def shutdown_database(self) -> None: # pragma: no cover
|
|
110
|
+
"""Clean up database connections."""
|
|
111
|
+
await db.shutdown_db()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Module-level container instance (set by lifespan)
|
|
115
|
+
# This allows deps.py to access the container without reading ConfigManager
|
|
116
|
+
_container: ApiContainer | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_container() -> ApiContainer:
|
|
120
|
+
"""Get the current API container.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
RuntimeError: If container hasn't been initialized
|
|
124
|
+
"""
|
|
125
|
+
if _container is None:
|
|
126
|
+
raise RuntimeError("API container not initialized. Call set_container() first.")
|
|
127
|
+
return _container
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def set_container(container: ApiContainer) -> None:
|
|
131
|
+
"""Set the API container (called by lifespan)."""
|
|
132
|
+
global _container
|
|
133
|
+
_container = container
|