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.

Files changed (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {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 db
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.config import ConfigManager
24
- from basic_memory.services.initialization import initialize_file_sync, initialize_app
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
- app_config = ConfigManager().config
32
- logger.info("Starting Basic Memory API")
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(app_config)
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 db.get_or_create_db(app_config.database_path)
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
- logger.info(f"Sync changes enabled: {app_config.sync_changes}")
44
- if app_config.sync_changes:
45
- # start file sync task in background
46
- app.state.sync_task = asyncio.create_task(initialize_file_sync(app_config))
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
- # proceed with startup
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
- if app.state.sync_task:
55
- logger.info("Stopping sync...")
56
- app.state.sync_task.cancel() # pyright: ignore
70
+ await sync_coordinator.stop()
57
71
 
58
- await db.shutdown_db()
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
- # Include routers
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 accross projects
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