basic-memory 0.14.4__py3-none-any.whl → 0.15.1__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 (84) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/directory_router.py +23 -2
  5. basic_memory/api/routers/knowledge_router.py +25 -8
  6. basic_memory/api/routers/project_router.py +100 -4
  7. basic_memory/cli/app.py +9 -28
  8. basic_memory/cli/auth.py +277 -0
  9. basic_memory/cli/commands/cloud/__init__.py +5 -0
  10. basic_memory/cli/commands/cloud/api_client.py +112 -0
  11. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  12. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  13. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  14. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  15. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  16. basic_memory/cli/commands/command_utils.py +43 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +77 -60
  19. basic_memory/cli/commands/project.py +154 -152
  20. basic_memory/cli/commands/status.py +25 -22
  21. basic_memory/cli/commands/sync.py +45 -228
  22. basic_memory/cli/commands/tool.py +87 -16
  23. basic_memory/cli/main.py +1 -0
  24. basic_memory/config.py +131 -21
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +27 -8
  27. basic_memory/file_utils.py +37 -13
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/markdown/plugins.py +9 -7
  30. basic_memory/mcp/async_client.py +124 -14
  31. basic_memory/mcp/project_context.py +141 -0
  32. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  33. basic_memory/mcp/prompts/continue_conversation.py +17 -16
  34. basic_memory/mcp/prompts/recent_activity.py +116 -32
  35. basic_memory/mcp/prompts/search.py +13 -12
  36. basic_memory/mcp/prompts/utils.py +11 -4
  37. basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
  38. basic_memory/mcp/resources/project_info.py +27 -11
  39. basic_memory/mcp/server.py +0 -37
  40. basic_memory/mcp/tools/__init__.py +5 -6
  41. basic_memory/mcp/tools/build_context.py +67 -56
  42. basic_memory/mcp/tools/canvas.py +38 -26
  43. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  44. basic_memory/mcp/tools/delete_note.py +81 -47
  45. basic_memory/mcp/tools/edit_note.py +155 -138
  46. basic_memory/mcp/tools/list_directory.py +112 -99
  47. basic_memory/mcp/tools/move_note.py +181 -101
  48. basic_memory/mcp/tools/project_management.py +113 -277
  49. basic_memory/mcp/tools/read_content.py +91 -74
  50. basic_memory/mcp/tools/read_note.py +152 -115
  51. basic_memory/mcp/tools/recent_activity.py +471 -68
  52. basic_memory/mcp/tools/search.py +105 -92
  53. basic_memory/mcp/tools/sync_status.py +136 -130
  54. basic_memory/mcp/tools/utils.py +4 -0
  55. basic_memory/mcp/tools/view_note.py +44 -33
  56. basic_memory/mcp/tools/write_note.py +151 -90
  57. basic_memory/models/knowledge.py +12 -6
  58. basic_memory/models/project.py +6 -2
  59. basic_memory/repository/entity_repository.py +89 -82
  60. basic_memory/repository/relation_repository.py +13 -0
  61. basic_memory/repository/repository.py +18 -5
  62. basic_memory/repository/search_repository.py +46 -2
  63. basic_memory/schemas/__init__.py +6 -0
  64. basic_memory/schemas/base.py +39 -11
  65. basic_memory/schemas/cloud.py +46 -0
  66. basic_memory/schemas/memory.py +90 -21
  67. basic_memory/schemas/project_info.py +9 -10
  68. basic_memory/schemas/sync_report.py +48 -0
  69. basic_memory/services/context_service.py +25 -11
  70. basic_memory/services/directory_service.py +124 -3
  71. basic_memory/services/entity_service.py +100 -48
  72. basic_memory/services/initialization.py +30 -11
  73. basic_memory/services/project_service.py +101 -24
  74. basic_memory/services/search_service.py +16 -8
  75. basic_memory/sync/sync_service.py +173 -34
  76. basic_memory/sync/watch_service.py +101 -40
  77. basic_memory/utils.py +14 -4
  78. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
  79. basic_memory-0.15.1.dist-info/RECORD +146 -0
  80. basic_memory/mcp/project_session.py +0 -120
  81. basic_memory-0.14.4.dist-info/RECORD +0 -133
  82. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  83. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  84. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
basic_memory/config.py CHANGED
@@ -54,6 +54,10 @@ class BasicMemoryConfig(BaseSettings):
54
54
  default="main",
55
55
  description="Name of the default project to use",
56
56
  )
57
+ default_project_mode: bool = Field(
58
+ default=False,
59
+ description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.",
60
+ )
57
61
 
58
62
  # overridden by ~/.basic-memory/config.json
59
63
  log_level: str = "INFO"
@@ -63,6 +67,10 @@ class BasicMemoryConfig(BaseSettings):
63
67
  default=1000, description="Milliseconds to wait after changes before syncing", gt=0
64
68
  )
65
69
 
70
+ watch_project_reload_interval: int = Field(
71
+ default=30, description="Seconds between reloading project list in watch service", gt=0
72
+ )
73
+
66
74
  # update permalinks on move
67
75
  update_permalinks_on_move: bool = Field(
68
76
  default=False,
@@ -74,22 +82,83 @@ class BasicMemoryConfig(BaseSettings):
74
82
  description="Whether to sync changes in real time. default (True)",
75
83
  )
76
84
 
85
+ sync_thread_pool_size: int = Field(
86
+ default=4,
87
+ description="Size of thread pool for file I/O operations in sync service",
88
+ gt=0,
89
+ )
90
+
77
91
  kebab_filenames: bool = Field(
78
92
  default=False,
79
93
  description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
80
94
  )
81
95
 
82
- # API connection configuration
83
- api_url: Optional[str] = Field(
96
+ disable_permalinks: bool = Field(
97
+ default=False,
98
+ description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.",
99
+ )
100
+
101
+ skip_initialization_sync: bool = Field(
102
+ default=False,
103
+ description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
104
+ )
105
+
106
+ # Project path constraints
107
+ project_root: Optional[str] = Field(
84
108
  default=None,
85
- description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
109
+ description="If set, all projects must be created underneath this directory. Paths will be sanitized and constrained to this root. If not set, projects can be created anywhere (default behavior).",
110
+ )
111
+
112
+ # Cloud configuration
113
+ cloud_client_id: str = Field(
114
+ default="client_01K6KWQPW6J1M8VV7R3TZP5A6M",
115
+ description="OAuth client ID for Basic Memory Cloud",
116
+ )
117
+
118
+ cloud_domain: str = Field(
119
+ default="https://eloquent-lotus-05.authkit.app",
120
+ description="AuthKit domain for Basic Memory Cloud",
121
+ )
122
+
123
+ cloud_host: str = Field(
124
+ default_factory=lambda: os.getenv(
125
+ "BASIC_MEMORY_CLOUD_HOST", "https://cloud.basicmemory.com"
126
+ ),
127
+ description="Basic Memory Cloud host URL",
128
+ )
129
+
130
+ cloud_mode: bool = Field(
131
+ default=False,
132
+ description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
133
+ )
134
+
135
+ @property
136
+ def cloud_mode_enabled(self) -> bool:
137
+ """Check if cloud mode is enabled.
138
+
139
+ Priority:
140
+ 1. BASIC_MEMORY_CLOUD_MODE environment variable
141
+ 2. Config file value (cloud_mode)
142
+ """
143
+ env_value = os.environ.get("BASIC_MEMORY_CLOUD_MODE", "").lower()
144
+ if env_value in ("true", "1", "yes"):
145
+ return True
146
+ elif env_value in ("false", "0", "no"):
147
+ return False
148
+ # Fall back to config file value
149
+ return self.cloud_mode
150
+
151
+ bisync_config: Dict[str, Any] = Field(
152
+ default_factory=lambda: {
153
+ "profile": "balanced",
154
+ "sync_dir": str(Path.home() / "basic-memory-cloud-sync"),
155
+ },
156
+ description="Bisync configuration for cloud sync",
86
157
  )
87
158
 
88
159
  model_config = SettingsConfigDict(
89
160
  env_prefix="BASIC_MEMORY_",
90
161
  extra="ignore",
91
- env_file=".env",
92
- env_file_encoding="utf-8",
93
162
  )
94
163
 
95
164
  def get_project_path(self, project_name: Optional[str] = None) -> Path: # pragma: no cover
@@ -158,6 +227,14 @@ class BasicMemoryConfig(BaseSettings):
158
227
  raise e
159
228
  return v
160
229
 
230
+ @property
231
+ def data_dir_path(self):
232
+ return Path.home() / DATA_DIR_NAME
233
+
234
+
235
+ # Module-level cache for configuration
236
+ _CONFIG_CACHE: Optional[BasicMemoryConfig] = None
237
+
161
238
 
162
239
  class ConfigManager:
163
240
  """Manages Basic Memory configuration."""
@@ -168,7 +245,12 @@ class ConfigManager:
168
245
  if isinstance(home, str):
169
246
  home = Path(home)
170
247
 
171
- self.config_dir = home / DATA_DIR_NAME
248
+ # Allow override via environment variable
249
+ if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
250
+ self.config_dir = Path(config_dir)
251
+ else:
252
+ self.config_dir = home / DATA_DIR_NAME
253
+
172
254
  self.config_file = self.config_dir / CONFIG_FILE_NAME
173
255
 
174
256
  # Ensure config directory exists
@@ -180,12 +262,45 @@ class ConfigManager:
180
262
  return self.load_config()
181
263
 
182
264
  def load_config(self) -> BasicMemoryConfig:
183
- """Load configuration from file or create default."""
265
+ """Load configuration from file or create default.
266
+
267
+ Environment variables take precedence over file config values,
268
+ following Pydantic Settings best practices.
269
+
270
+ Uses module-level cache for performance across ConfigManager instances.
271
+ """
272
+ global _CONFIG_CACHE
273
+
274
+ # Return cached config if available
275
+ if _CONFIG_CACHE is not None:
276
+ return _CONFIG_CACHE
184
277
 
185
278
  if self.config_file.exists():
186
279
  try:
187
- data = json.loads(self.config_file.read_text(encoding="utf-8"))
188
- return BasicMemoryConfig(**data)
280
+ file_data = json.loads(self.config_file.read_text(encoding="utf-8"))
281
+
282
+ # First, create config from environment variables (Pydantic will read them)
283
+ # Then overlay with file data for fields that aren't set via env vars
284
+ # This ensures env vars take precedence
285
+
286
+ # Get env-based config fields that are actually set
287
+ env_config = BasicMemoryConfig()
288
+ env_dict = env_config.model_dump()
289
+
290
+ # Merge: file data as base, but only use it for fields not set by env
291
+ # We detect env-set fields by comparing to default values
292
+ merged_data = file_data.copy()
293
+
294
+ # For fields that have env var overrides, use those instead of file values
295
+ # The env_prefix is "BASIC_MEMORY_" so we check those
296
+ for field_name in BasicMemoryConfig.model_fields.keys():
297
+ env_var_name = f"BASIC_MEMORY_{field_name.upper()}"
298
+ if env_var_name in os.environ:
299
+ # Environment variable is set, use it
300
+ merged_data[field_name] = env_dict[field_name]
301
+
302
+ _CONFIG_CACHE = BasicMemoryConfig(**merged_data)
303
+ return _CONFIG_CACHE
189
304
  except Exception as e: # pragma: no cover
190
305
  logger.exception(f"Failed to load config: {e}")
191
306
  raise e
@@ -195,8 +310,11 @@ class ConfigManager:
195
310
  return config
196
311
 
197
312
  def save_config(self, config: BasicMemoryConfig) -> None:
198
- """Save configuration to file."""
313
+ """Save configuration to file and invalidate cache."""
314
+ global _CONFIG_CACHE
199
315
  save_basic_memory_config(self.config_file, config)
316
+ # Invalidate cache so next load_config() reads fresh data
317
+ _CONFIG_CACHE = None
200
318
 
201
319
  @property
202
320
  def projects(self) -> Dict[str, str]:
@@ -236,7 +354,8 @@ class ConfigManager:
236
354
  if project_name == config.default_project: # pragma: no cover
237
355
  raise ValueError(f"Cannot remove the default project '{name}'")
238
356
 
239
- del config.projects[name]
357
+ # Use the found project_name (which may differ from input name due to permalink matching)
358
+ del config.projects[project_name]
240
359
  self.save_config(config)
241
360
 
242
361
  def set_default_project(self, name: str) -> None:
@@ -276,7 +395,7 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
276
395
  os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
277
396
  if os_project_name: # pragma: no cover
278
397
  logger.warning(
279
- f"BASIC_MEMORY_PROJECT is not supported anymore. Use the --project flag or set the default project in the config instead. Setting default project to {os_project_name}"
398
+ f"BASIC_MEMORY_PROJECT is not supported anymore. Set the default project in the config instead. Setting default project to {os_project_name}"
280
399
  )
281
400
  actual_project_name = project_name
282
401
  # if the project_name is passed in, use it
@@ -307,15 +426,6 @@ def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None
307
426
  logger.error(f"Failed to save config: {e}")
308
427
 
309
428
 
310
- def update_current_project(project_name: str) -> None:
311
- """Update the global config to use a different project.
312
-
313
- This is used by the CLI when --project flag is specified.
314
- """
315
- global config
316
- config = get_project_config(project_name) # pragma: no cover
317
-
318
-
319
429
  # setup logging to a single log file in user home directory
320
430
  user_home = Path.home()
321
431
  log_dir = user_home / DATA_DIR_NAME
basic_memory/db.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import os
2
3
  from contextlib import asynccontextmanager
3
4
  from enum import Enum, auto
4
5
  from pathlib import Path
@@ -9,7 +10,7 @@ from alembic import command
9
10
  from alembic.config import Config
10
11
 
11
12
  from loguru import logger
12
- from sqlalchemy import text
13
+ from sqlalchemy import text, event
13
14
  from sqlalchemy.ext.asyncio import (
14
15
  create_async_engine,
15
16
  async_sessionmaker,
@@ -17,6 +18,7 @@ from sqlalchemy.ext.asyncio import (
17
18
  AsyncEngine,
18
19
  async_scoped_session,
19
20
  )
21
+ from sqlalchemy.pool import NullPool
20
22
 
21
23
  from basic_memory.repository.search_repository import SearchRepository
22
24
 
@@ -73,13 +75,77 @@ async def scoped_session(
73
75
  await factory.remove()
74
76
 
75
77
 
78
+ def _configure_sqlite_connection(dbapi_conn, enable_wal: bool = True) -> None:
79
+ """Configure SQLite connection with WAL mode and optimizations.
80
+
81
+ Args:
82
+ dbapi_conn: Database API connection object
83
+ enable_wal: Whether to enable WAL mode (should be False for in-memory databases)
84
+ """
85
+ cursor = dbapi_conn.cursor()
86
+ try:
87
+ # Enable WAL mode for better concurrency (not supported for in-memory databases)
88
+ if enable_wal:
89
+ cursor.execute("PRAGMA journal_mode=WAL")
90
+ # Set busy timeout to handle locked databases
91
+ cursor.execute("PRAGMA busy_timeout=10000") # 10 seconds
92
+ # Optimize for performance
93
+ cursor.execute("PRAGMA synchronous=NORMAL")
94
+ cursor.execute("PRAGMA cache_size=-64000") # 64MB cache
95
+ cursor.execute("PRAGMA temp_store=MEMORY")
96
+ # Windows-specific optimizations
97
+ if os.name == "nt":
98
+ cursor.execute("PRAGMA locking_mode=NORMAL") # Ensure normal locking on Windows
99
+ except Exception as e:
100
+ # Log but don't fail - some PRAGMAs may not be supported
101
+ logger.warning(f"Failed to configure SQLite connection: {e}")
102
+ finally:
103
+ cursor.close()
104
+
105
+
76
106
  def _create_engine_and_session(
77
107
  db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
78
108
  ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
79
109
  """Internal helper to create engine and session maker."""
80
110
  db_url = DatabaseType.get_db_url(db_path, db_type)
81
111
  logger.debug(f"Creating engine for db_url: {db_url}")
82
- engine = create_async_engine(db_url, connect_args={"check_same_thread": False})
112
+
113
+ # Configure connection args with Windows-specific settings
114
+ connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
115
+
116
+ # Add Windows-specific parameters to improve reliability
117
+ if os.name == "nt": # Windows
118
+ connect_args.update(
119
+ {
120
+ "timeout": 30.0, # Increase timeout to 30 seconds for Windows
121
+ "isolation_level": None, # Use autocommit mode
122
+ }
123
+ )
124
+ # Use NullPool for Windows filesystem databases to avoid connection pooling issues
125
+ # Important: Do NOT use NullPool for in-memory databases as it will destroy the database
126
+ # between connections
127
+ if db_type == DatabaseType.FILESYSTEM:
128
+ engine = create_async_engine(
129
+ db_url,
130
+ connect_args=connect_args,
131
+ poolclass=NullPool, # Disable connection pooling on Windows
132
+ echo=False,
133
+ )
134
+ else:
135
+ # In-memory databases need connection pooling to maintain state
136
+ engine = create_async_engine(db_url, connect_args=connect_args)
137
+ else:
138
+ engine = create_async_engine(db_url, connect_args=connect_args)
139
+
140
+ # Enable WAL mode for better concurrency and reliability
141
+ # Note: WAL mode is not supported for in-memory databases
142
+ enable_wal = db_type != DatabaseType.MEMORY
143
+
144
+ @event.listens_for(engine.sync_engine, "connect")
145
+ def enable_wal_mode(dbapi_conn, connection_record):
146
+ """Enable WAL mode on each connection."""
147
+ _configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
148
+
83
149
  session_maker = async_sessionmaker(engine, expire_on_commit=False)
84
150
  return engine, session_maker
85
151
 
@@ -140,7 +206,42 @@ async def engine_session_factory(
140
206
  db_url = DatabaseType.get_db_url(db_path, db_type)
141
207
  logger.debug(f"Creating engine for db_url: {db_url}")
142
208
 
143
- _engine = create_async_engine(db_url, connect_args={"check_same_thread": False})
209
+ # Configure connection args with Windows-specific settings
210
+ connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
211
+
212
+ # Add Windows-specific parameters to improve reliability
213
+ if os.name == "nt": # Windows
214
+ connect_args.update(
215
+ {
216
+ "timeout": 30.0, # Increase timeout to 30 seconds for Windows
217
+ "isolation_level": None, # Use autocommit mode
218
+ }
219
+ )
220
+ # Use NullPool for Windows filesystem databases to avoid connection pooling issues
221
+ # Important: Do NOT use NullPool for in-memory databases as it will destroy the database
222
+ # between connections
223
+ if db_type == DatabaseType.FILESYSTEM:
224
+ _engine = create_async_engine(
225
+ db_url,
226
+ connect_args=connect_args,
227
+ poolclass=NullPool, # Disable connection pooling on Windows
228
+ echo=False,
229
+ )
230
+ else:
231
+ # In-memory databases need connection pooling to maintain state
232
+ _engine = create_async_engine(db_url, connect_args=connect_args)
233
+ else:
234
+ _engine = create_async_engine(db_url, connect_args=connect_args)
235
+
236
+ # Enable WAL mode for better concurrency and reliability
237
+ # Note: WAL mode is not supported for in-memory databases
238
+ enable_wal = db_type != DatabaseType.MEMORY
239
+
240
+ @event.listens_for(_engine.sync_engine, "connect")
241
+ def enable_wal_mode(dbapi_conn, connection_record):
242
+ """Enable WAL mode on each connection."""
243
+ _configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
244
+
144
245
  try:
145
246
  _session_maker = async_sessionmaker(_engine, expire_on_commit=False)
146
247
 
basic_memory/deps.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from typing import Annotated
4
4
  from loguru import logger
5
5
 
6
- from fastapi import Depends, HTTPException, Path, status
6
+ from fastapi import Depends, HTTPException, Path, status, Request
7
7
  from sqlalchemy.ext.asyncio import (
8
8
  AsyncSession,
9
9
  AsyncEngine,
@@ -33,6 +33,7 @@ from basic_memory.services.file_service import FileService
33
33
  from basic_memory.services.link_resolver import LinkResolver
34
34
  from basic_memory.services.search_service import SearchService
35
35
  from basic_memory.sync import SyncService
36
+ from basic_memory.utils import generate_permalink
36
37
 
37
38
 
38
39
  def get_app_config() -> BasicMemoryConfig: # pragma: no cover
@@ -61,8 +62,9 @@ async def get_project_config(
61
62
  Raises:
62
63
  HTTPException: If project is not found
63
64
  """
64
-
65
- project_obj = await project_repository.get_by_permalink(str(project))
65
+ # Convert project name to permalink for lookup
66
+ project_permalink = generate_permalink(str(project))
67
+ project_obj = await project_repository.get_by_permalink(project_permalink)
66
68
  if project_obj:
67
69
  return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
68
70
 
@@ -78,9 +80,24 @@ ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # prag
78
80
 
79
81
 
80
82
  async def get_engine_factory(
81
- app_config: AppConfigDep,
83
+ request: Request,
82
84
  ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
83
- """Get engine and session maker."""
85
+ """Get cached engine and session maker from app state.
86
+
87
+ For API requests, returns cached connections from app.state for optimal performance.
88
+ For non-API contexts (CLI), falls back to direct database connection.
89
+ """
90
+ # Try to get cached connections from app state (API context)
91
+ if (
92
+ hasattr(request, "app")
93
+ and hasattr(request.app.state, "engine")
94
+ and hasattr(request.app.state, "session_maker")
95
+ ):
96
+ return request.app.state.engine, request.app.state.session_maker
97
+
98
+ # Fallback for non-API contexts (CLI)
99
+ logger.debug("Using fallback database connection for non-API context")
100
+ app_config = get_app_config()
84
101
  engine, session_maker = await db.get_or_create_db(app_config.database_path)
85
102
  return engine, session_maker
86
103
 
@@ -132,9 +149,9 @@ async def get_project_id(
132
149
  Raises:
133
150
  HTTPException: If project is not found
134
151
  """
135
-
136
- # Try by permalink first (most common case with URL paths)
137
- project_obj = await project_repository.get_by_permalink(str(project))
152
+ # Convert project name to permalink for lookup
153
+ project_permalink = generate_permalink(str(project))
154
+ project_obj = await project_repository.get_by_permalink(project_permalink)
138
155
  if project_obj:
139
156
  return project_obj.id
140
157
 
@@ -245,6 +262,7 @@ async def get_entity_service(
245
262
  entity_parser: EntityParserDep,
246
263
  file_service: FileServiceDep,
247
264
  link_resolver: "LinkResolverDep",
265
+ app_config: AppConfigDep,
248
266
  ) -> EntityService:
249
267
  """Create EntityService with repository."""
250
268
  return EntityService(
@@ -254,6 +272,7 @@ async def get_entity_service(
254
272
  entity_parser=entity_parser,
255
273
  file_service=file_service,
256
274
  link_resolver=link_resolver,
275
+ app_config=app_config,
257
276
  )
258
277
 
259
278
 
@@ -240,40 +240,37 @@ async def update_frontmatter(path: FilePath, updates: Dict[str, Any]) -> str:
240
240
  def dump_frontmatter(post: frontmatter.Post) -> str:
241
241
  """
242
242
  Serialize frontmatter.Post to markdown with Obsidian-compatible YAML format.
243
-
243
+
244
244
  This function ensures that tags are formatted as YAML lists instead of JSON arrays:
245
-
245
+
246
246
  Good (Obsidian compatible):
247
247
  ---
248
248
  tags:
249
249
  - system
250
- - overview
250
+ - overview
251
251
  - reference
252
252
  ---
253
-
253
+
254
254
  Bad (current behavior):
255
255
  ---
256
256
  tags: ["system", "overview", "reference"]
257
257
  ---
258
-
258
+
259
259
  Args:
260
260
  post: frontmatter.Post object to serialize
261
-
261
+
262
262
  Returns:
263
263
  String containing markdown with properly formatted YAML frontmatter
264
- """
264
+ """
265
265
  if not post.metadata:
266
266
  # No frontmatter, just return content
267
267
  return post.content
268
-
268
+
269
269
  # Serialize YAML with block style for lists
270
270
  yaml_str = yaml.dump(
271
- post.metadata,
272
- sort_keys=False,
273
- allow_unicode=True,
274
- default_flow_style=False
271
+ post.metadata, sort_keys=False, allow_unicode=True, default_flow_style=False
275
272
  )
276
-
273
+
277
274
  # Construct the final markdown with frontmatter
278
275
  if post.content:
279
276
  return f"---\n{yaml_str}---\n\n{post.content}"
@@ -298,3 +295,30 @@ def sanitize_for_filename(text: str, replacement: str = "-") -> str:
298
295
 
299
296
  return text.strip(replacement)
300
297
 
298
+
299
+ def sanitize_for_folder(folder: str) -> str:
300
+ """
301
+ Sanitize folder path to be safe for use in file system paths.
302
+ Removes leading/trailing whitespace, compresses multiple slashes,
303
+ and removes special characters except for /, -, and _.
304
+ """
305
+ if not folder:
306
+ return ""
307
+
308
+ sanitized = folder.strip()
309
+
310
+ if sanitized.startswith("./"):
311
+ sanitized = sanitized[2:]
312
+
313
+ # ensure no special characters (except for a few that are allowed)
314
+ sanitized = "".join(
315
+ c for c in sanitized if c.isalnum() or c in (".", " ", "-", "_", "\\", "/")
316
+ ).rstrip()
317
+
318
+ # compress multiple, repeated instances of path separators
319
+ sanitized = re.sub(r"[\\/]+", "/", sanitized)
320
+
321
+ # trim any leading/trailing path separators
322
+ sanitized = sanitized.strip("\\/")
323
+
324
+ return sanitized