1"""MCPBaseSettings-based configuration for Session Buddy MCP Server.

2

3 Configuration Loading:

4 Settings are loaded with layered priority (highest to lowest):

5 1. Environment variables SESSION_BUDDY_*

6 2. settings/local.yaml (local overrides, gitignored)

7 3. settings/session-buddy.yaml (base configuration)

8 4. Defaults from this class (lowest)

9

10 Settings Directory Structure:

11 settings/

12 ├── session-buddy.yaml # Base configuration (committed)

13 └── local.yaml # Local overrides (gitignored)

14"""

15

16 from __future__ import annotations

17

18 import os

19 import typing as t

20 from pathlib import Path

21

22 from mcp_common import MCPBaseSettings

23 from pydantic import Field, field_validator, model_validator

24

25

26 class SessionMgmtSettings(MCPBaseSettings):

27 """Unified MCPBaseSettings for session-buddy.

28

29 All configuration consolidated into a single flat structure

30 for Oneiric/mcp-common compatibility and simplicity.

31 """

32

33 # === Core MCP settings ===

34 server_name: str = Field(

35 default="Session Buddy MCP",

36 description="Display name for the MCP server",

37 )

38 server_description: str = Field(

39 default="Session management and tooling MCP server",

40 description="Brief description of server functionality",

41 )

42 log_level: t.Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(

43 default="INFO",

44 description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",

45 )

46 enable_debug_mode: bool = Field(

47 default=False,

48 description="Enable debug features (verbose logging, additional validation)",

49 )

50

51 # === Core Paths ===

52 data_dir: Path = Field(

53 default=Path("~/.claude/data"),

54 description="Base data directory",

55 )

56 log_dir: Path = Field(

57 default=Path("~/.claude/logs"),

58 description="Base log directory",

59 )

60

61 # === Database Settings ===

62 database_path: Path = Field(

63 default=Path("~/.claude/data/reflection.duckdb"),

64 description="Path to the DuckDB database file",

65 )

66 database_connection_timeout: int = Field(

67 default=30,

68 ge=1,

69 le=300,

70 description="Database connection timeout in seconds",

71 )

72 database_query_timeout: int = Field(

73 default=120,

74 ge=1,

75 le=3600,

76 description="Database query timeout in seconds",

77 )

78 database_max_connections: int = Field(

79 default=10,

80 ge=1,

81 le=100,

82 description="Maximum number of database connections",

83 )

84

85 # Multi-project settings

86 enable_multi_project: bool = Field(

87 default=True,

88 description="Enable multi-project coordination features",

89 )

90 auto_detect_projects: bool = Field(

91 default=True,

92 description="Auto-detect project relationships",

93 )

94 project_groups_enabled: bool = Field(

95 default=True,

96 description="Enable project grouping functionality",

97 )

98

99 # Database search settings

100 enable_full_text_search: bool = Field(

101 default=True,

102 description="Enable full-text search capabilities",

103 )

104 search_index_update_interval: int = Field(

105 default=3600,

106 ge=60,

107 le=86400,

108 description="Search index update interval in seconds",

109 )

110 max_search_results: int = Field(

111 default=100,

112 ge=1,

113 le=10000,

114 description="Maximum number of search results to return",

115 )

116

117 # === Search Settings ===

118 enable_semantic_search: bool = Field(

119 default=True,

120 description="Enable semantic search using embeddings",

121 )

122 embedding_model: str = Field(

123 default="all-MiniLM-L6-v2",

124 description="Embedding model for semantic search",

125 )

126 embedding_cache_size: int = Field(

127 default=1000,

128 ge=10,

129 le=100000,

130 description="Number of embeddings to cache in memory",

131 )

132

133 # Advanced search

134 enable_faceted_search: bool = Field(

135 default=True,

136 description="Enable faceted search capabilities",

137 )

138 max_facet_values: int = Field(

139 default=50,

140 ge=1,

141 le=1000,

142 description="Maximum number of facet values to return",

143 )

144 enable_search_suggestions: bool = Field(

145 default=True,

146 description="Enable search suggestions and autocomplete",

147 )

148 suggestion_limit: int = Field(

149 default=10,

150 ge=1,

151 le=100,

152 description="Maximum number of search suggestions",

153 )

154 enable_stemming: bool = Field(

155 default=True,

156 description="Enable word stemming in search",

157 )

158 enable_fuzzy_matching: bool = Field(

159 default=True,

160 description="Enable fuzzy matching for typos",

161 )

162 fuzzy_threshold: float = Field(

163 default=0.8,

164 ge=0.1,

165 le=1.0,

166 description="Fuzzy matching similarity threshold",

167 )

168

169 # === Token Optimization Settings ===

170 enable_token_optimization: bool = Field(

171 default=True,

172 description="Enable token optimization features",

173 )

174 default_max_tokens: int = Field(

175 default=4000,

176 ge=100,

177 le=200000,

178 description="Default maximum tokens for responses",

179 )

180 default_chunk_size: int = Field(

181 default=2000,

182 ge=50,

183 le=100000,

184 description="Default chunk size for response splitting",

185 )

186 optimization_strategy: str = Field(

187 default="auto",

188 description="Preferred optimization strategy (auto, truncate_old, summarize_content, compress)",

189 )

190 enable_response_chunking: bool = Field(

191 default=True,

192 description="Enable automatic response chunking for large outputs",

193 )

194 enable_duplicate_filtering: bool = Field(

195 default=True,

196 description="Filter out duplicate content in responses",

197 )

198 track_token_usage: bool = Field(

199 default=True,

200 description="Track token usage statistics",

201 )

202 usage_retention_days: int = Field(

203 default=90,

204 ge=1,

205 le=3650,

206 description="Number of days to retain usage statistics",

207 )

208

209 # === Session Management Settings ===

210 auto_checkpoint_interval: int = Field(

211 default=1800,

212 ge=60,

213 le=86400,

214 description="Auto-checkpoint interval in seconds (default: 30 minutes)",

215 )

216 enable_auto_commit: bool = Field(

217 default=True,

218 description="Enable automatic git commits during checkpoints",

219 )

220 commit_message_template: str = Field(

221 default="checkpoint: Session checkpoint - {timestamp}",

222 min_length=10,

223 description="Template for automatic commit messages",

224 )

225 enable_permission_system: bool = Field(

226 default=True,

227 description="Enable the permission system for trusted operations",

228 )

229 default_trusted_operations: list[str] = Field(

230 default_factory=lambda: ["git_commit", "uv_sync", "file_operations"],

231 description="List of operations that are trusted by default",

232 )

233 auto_cleanup_old_sessions: bool = Field(

234 default=True,

235 description="Automatically clean up old session data",

236 )

237 session_retention_days: int = Field(

238 default=365,

239 ge=1,

240 le=3650,

241 description="Number of days to retain session data",

242 )

243

244 # Selective auto-store

245 enable_auto_store_reflections: bool = Field(

246 default=True,

247 description="Enable automatic reflection storage at meaningful checkpoints",

248 )

249 auto_store_quality_delta_threshold: int = Field(

250 default=10,

251 ge=5,

252 le=50,

253 description="Minimum quality score change to trigger auto-store",

254 )

255 auto_store_exceptional_quality_threshold: int = Field(

256 default=90,

257 ge=70,

258 le=100,

259 description="Quality score threshold for exceptional sessions",

260 )

261 auto_store_manual_checkpoints: bool = Field(

262 default=True,

263 description="Always store reflections for manually-triggered checkpoints",

264 )

265 auto_store_session_end: bool = Field(

266 default=True,

267 description="Always store reflections at session end",

268 )

269

270 # === Integration Settings ===

271 enable_crackerjack: bool = Field(

272 default=True,

273 description="Enable Crackerjack code quality integration",

274 )

275 crackerjack_command: str = Field(

276 default="crackerjack",

277 min_length=1,

278 description="Command to run Crackerjack",

279 )

280 enable_git_integration: bool = Field(

281 default=True,

282 description="Enable Git integration features",

283 )

284 git_auto_stage: bool = Field(

285 default=False,

286 description="Automatically stage changes before commits",

287 )

288 global_workspace_path: Path = Field(

289 default=Path("~/Projects/claude"),

290 description="Path to global workspace directory",

291 )

292 enable_global_toolkits: bool = Field(

293 default=True,

294 description="Enable global toolkit discovery and usage",

295 )

296

297 # === LLM API Keys (optional, overrides env vars) ===

298 openai_api_key: str | None = Field(

299 default=None,

300 description="OpenAI API key (overrides OPENAI_API_KEY)",

301 )

302 anthropic_api_key: str | None = Field(

303 default=None,

304 description="Anthropic API key (overrides ANTHROPIC_API_KEY)",

305 )

306 gemini_api_key: str | None = Field(

307 default=None,

308 description="Gemini API key (overrides GEMINI_API_KEY/GOOGLE_API_KEY)",

309 )

310

311 # === Logging Settings ===

312 log_format: str = Field(

313 default="%(asctime)s - %(name)s - %(levelname)s - %(message)s",

314 min_length=10,

315 description="Log message format string",

316 )

317 enable_file_logging: bool = Field(

318 default=True,

319 description="Enable logging to file",

320 )

321 log_file_path: Path = Field(

322 default=Path("~/.claude/logs/session-buddy.log"),

323 description="Path to log file",

324 )

325 log_file_max_size: int = Field(

326 default=10 * 1024 * 1024,

327 ge=1024,

328 le=1024 * 1024 * 1024,

329 description="Maximum log file size in bytes (default: 10MB)",

330 )

331 log_file_backup_count: int = Field(

332 default=5,

333 ge=0,

334 le=100,

335 description="Number of backup log files to keep",

336 )

337 enable_performance_logging: bool = Field(

338 default=False,

339 description="Enable detailed performance logging",

340 )

341 log_slow_queries: bool = Field(

342 default=True,

343 description="Log slow database queries",

344 )

345 slow_query_threshold: float = Field(

346 default=1.0,

347 ge=0.1,

348 le=60.0,

349 description="Threshold for slow query logging in seconds",

350 )

351

352 # === Security Settings ===

353 anonymize_paths: bool = Field(

354 default=False,

355 description="Anonymize file paths in logs and data",

356 )

357 enable_rate_limiting: bool = Field(

358 default=True,

359 description="Enable rate limiting for API requests",

360 )

361 max_requests_per_minute: int = Field(

362 default=100,

363 ge=1,

364 le=10000,

365 description="Maximum requests per minute per client",

366 )

367 max_query_length: int = Field(

368 default=10000,

369 ge=100,

370 le=1000000,

371 description="Maximum length for search queries",

372 )

373 max_content_length: int = Field(

374 default=1000000,

375 ge=1000,

376 le=100000000,

377 description="Maximum content length in bytes (default: 1MB)",

378 )

379

380 # === MCP Server Settings ===

381 server_host: str = Field(

382 default="localhost",

383 description="MCP server host address",

384 )

385 server_port: int = Field(

386 default=3000,

387 ge=1024,

388 le=65535,

389 description="MCP server port number",

390 )

391 enable_websockets: bool = Field(

392 default=True,

393 description="Enable WebSocket support for MCP server",

394 )

395

396 # === Development Settings ===

397 enable_hot_reload: bool = Field(

398 default=False,

399 description="Enable hot reloading during development",

400 )

401

402 # === Feature Flags (rollout) ===

403 # Default to False; enable gradually and flip to True post-rollout

404 use_schema_v2: bool = Field(

405 default=True,

406 description="Use enhanced schema v2 for memory tables",

407 )

408 enable_llm_entity_extraction: bool = Field(

409 default=True,

410 description="Enable multi-provider LLM entity extraction",

411 )

412 enable_anthropic: bool = Field(

413 default=True,

414 description="Enable Anthropic provider in cascade",

415 )

416 enable_ollama: bool = Field(

417 default=False,

418 description="Enable Ollama provider in cascade",

419 )

420 enable_conscious_agent: bool = Field(

421 default=True,

422 description="Enable background Conscious Agent",

423 )

424 enable_filesystem_extraction: bool = Field(

425 default=True,

426 description="Enable filesystem-triggered entity extraction",

427 )

428

429 # === Extraction Controls ===

430 llm_extraction_timeout: int = Field(

431 default=10,

432 ge=1,

433 le=120,

434 description="Timeout in seconds for LLM extraction requests",

435 )

436 llm_extraction_retries: int = Field(

437 default=1,

438 ge=0,

439 le=3,

440 description="Retry attempts per provider before cascading",

441 )

442

443 # === Filesystem Extraction Settings ===

444 filesystem_dedupe_ttl_seconds: int = Field(

445 default=120,

446 ge=10,

447 le=3600,

448 description="Time window to skip reprocessing the same file",

449 )

450 filesystem_max_file_size_bytes: int = Field(

451 default=1_000_000,

452 ge=10_000,

453 le=100_000_000,

454 description="Maximum file size to consider for extraction",

455 )

456 filesystem_ignore_dirs: list[str] = Field(

457 default_factory=lambda: [

458 ".git",

459 "__pycache__",

460 "node_modules",

461 ".venv",

462 "venv",

463 ".pytest_cache",

464 ".mypy_cache",

465 ".ruff_cache",

466 "dist",

467 "build",

468 ".DS_Store",

469 ".idea",

470 ".vscode",

471 ],

472 description="Directory names to ignore for extraction",

473 )

474

475 # === Field Validators ===

476 @model_validator(mode="before")

477 def map_legacy_debug_flag(self, data: t.Any) -> t.Any:

478 if (

479 isinstance(data, dict)

480 and "debug" in data

481 and "enable_debug_mode" not in data

482 ):

483 data = dict(data)

484 data["enable_debug_mode"] = bool(data["debug"])

485 return data

486

487 @field_validator(

488 "data_dir",

489 "log_dir",

490 "database_path",

491 "log_file_path",

492 "global_workspace_path",

493 )

494 @classmethod

495 def expand_user_paths(cls, v: Path | str) -> Path:

496 """Expand user paths (~ to home directory)."""

497 path = v if isinstance(v, Path) else Path(v)

498 return Path(os.path.expanduser(str(path)))

499

500 @field_validator("commit_message_template")

501 @classmethod

502 def validate_commit_template(cls, v: str) -> str:

503 """Ensure commit message template contains timestamp placeholder."""

504 if "{timestamp}" not in v:

505 msg = "Commit message template must contain {timestamp} placeholder"

506 raise ValueError(msg)

507 return v

508

509

510# Global settings instance

511_settings: SessionMgmtSettings | None = None

512

513

514 def get_settings(reload: bool = False) -> SessionMgmtSettings:

515 """Get the global settings instance.

516

517 Args:

518 reload: Force reload settings from files

519

520 Returns:

521 Global SessionMgmtSettings instance

522

523 """

524 global _settings

525

526 if _settings is None or reload:

527 _settings = t.cast(

528 "SessionMgmtSettings", SessionMgmtSettings.load("session-buddy")

529 )

530

531 # _settings is guaranteed non-None here

532 assert _settings is not None

533 return _settings

534

535

536 def reload_settings() -> SessionMgmtSettings:

537 """Force reload settings from files.

538

539 Returns:

540 Freshly loaded SessionMgmtSettings instance

541

542 """

543 return get_settings(reload=True)

544

545

546 def get_database_path() -> Path:

547 settings = get_settings()

548 raw = settings.database_path

549 path = raw.expanduser() if isinstance(raw, Path) else Path(str(raw)).expanduser()

550 if not path.is_absolute(): 550 ↛ 551 line 550 didn't jump to line 551 because the condition on line 550 was never true

551 data_dir_raw = settings.data_dir

552 data_dir = (

553 data_dir_raw.expanduser()

554 if isinstance(data_dir_raw, Path)

555 else Path(str(data_dir_raw)).expanduser()

556 )

557 path = data_dir / path

558 return path

559

560

561 def get_log_file_path() -> Path:

562 settings = get_settings()

563 path = settings.log_file_path.expanduser()

564 if not path.is_absolute():

565 path = settings.log_dir.expanduser() / path

566 return path

567

568

569 def get_llm_api_key(provider: str) -> str | None:

570 settings = get_settings()

571 field_map = {

572 "openai": "openai_api_key",

573 "anthropic": "anthropic_api_key",

574 "gemini": "gemini_api_key",

575 }

576 field = field_map.get(provider)

577 if field is None:

578 return None

579 raw = getattr(settings, field, None)

580 if not isinstance(raw, str) or not raw.strip(): 580 ↛ 582 line 580 didn't jump to line 582 because the condition on line 580 was always true

581 return None

582 if provider in ("openai", "anthropic"):

583 return settings.get_api_key_secure(key_name=field, provider=provider)

584 return settings.get_api_key(key_name=field)

585

586

587__all__ = [

588 "SessionMgmtSettings",

589 "get_database_path",

590 "get_llm_api_key",

591 "get_log_file_path",

592 "get_settings",

593 "reload_settings",

594]