basic-memory 0.7.0__py3-none-any.whl → 0.9.0__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 (89) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +23 -1
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  7. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +106 -0
  8. basic_memory/api/app.py +9 -10
  9. basic_memory/api/routers/__init__.py +2 -1
  10. basic_memory/api/routers/knowledge_router.py +31 -5
  11. basic_memory/api/routers/memory_router.py +18 -17
  12. basic_memory/api/routers/project_info_router.py +275 -0
  13. basic_memory/api/routers/resource_router.py +105 -4
  14. basic_memory/api/routers/search_router.py +22 -4
  15. basic_memory/cli/app.py +54 -5
  16. basic_memory/cli/commands/__init__.py +15 -2
  17. basic_memory/cli/commands/db.py +9 -13
  18. basic_memory/cli/commands/import_chatgpt.py +26 -30
  19. basic_memory/cli/commands/import_claude_conversations.py +27 -29
  20. basic_memory/cli/commands/import_claude_projects.py +29 -31
  21. basic_memory/cli/commands/import_memory_json.py +26 -28
  22. basic_memory/cli/commands/mcp.py +7 -1
  23. basic_memory/cli/commands/project.py +119 -0
  24. basic_memory/cli/commands/project_info.py +167 -0
  25. basic_memory/cli/commands/status.py +14 -28
  26. basic_memory/cli/commands/sync.py +63 -22
  27. basic_memory/cli/commands/tool.py +253 -0
  28. basic_memory/cli/main.py +39 -1
  29. basic_memory/config.py +166 -4
  30. basic_memory/db.py +19 -4
  31. basic_memory/deps.py +10 -3
  32. basic_memory/file_utils.py +37 -19
  33. basic_memory/markdown/entity_parser.py +3 -3
  34. basic_memory/markdown/utils.py +5 -0
  35. basic_memory/mcp/async_client.py +1 -1
  36. basic_memory/mcp/main.py +24 -0
  37. basic_memory/mcp/prompts/__init__.py +19 -0
  38. basic_memory/mcp/prompts/ai_assistant_guide.py +26 -0
  39. basic_memory/mcp/prompts/continue_conversation.py +111 -0
  40. basic_memory/mcp/prompts/recent_activity.py +88 -0
  41. basic_memory/mcp/prompts/search.py +182 -0
  42. basic_memory/mcp/prompts/utils.py +155 -0
  43. basic_memory/mcp/server.py +2 -6
  44. basic_memory/mcp/tools/__init__.py +12 -21
  45. basic_memory/mcp/tools/build_context.py +85 -0
  46. basic_memory/mcp/tools/canvas.py +97 -0
  47. basic_memory/mcp/tools/delete_note.py +28 -0
  48. basic_memory/mcp/tools/project_info.py +51 -0
  49. basic_memory/mcp/tools/read_content.py +229 -0
  50. basic_memory/mcp/tools/read_note.py +190 -0
  51. basic_memory/mcp/tools/recent_activity.py +100 -0
  52. basic_memory/mcp/tools/search.py +56 -17
  53. basic_memory/mcp/tools/utils.py +245 -16
  54. basic_memory/mcp/tools/write_note.py +124 -0
  55. basic_memory/models/knowledge.py +27 -11
  56. basic_memory/models/search.py +2 -1
  57. basic_memory/repository/entity_repository.py +3 -2
  58. basic_memory/repository/project_info_repository.py +9 -0
  59. basic_memory/repository/repository.py +24 -7
  60. basic_memory/repository/search_repository.py +47 -14
  61. basic_memory/schemas/__init__.py +10 -9
  62. basic_memory/schemas/base.py +4 -1
  63. basic_memory/schemas/memory.py +14 -4
  64. basic_memory/schemas/project_info.py +96 -0
  65. basic_memory/schemas/search.py +29 -33
  66. basic_memory/services/context_service.py +3 -3
  67. basic_memory/services/entity_service.py +26 -13
  68. basic_memory/services/file_service.py +145 -26
  69. basic_memory/services/link_resolver.py +9 -46
  70. basic_memory/services/search_service.py +95 -22
  71. basic_memory/sync/__init__.py +3 -2
  72. basic_memory/sync/sync_service.py +523 -117
  73. basic_memory/sync/watch_service.py +258 -132
  74. basic_memory/utils.py +51 -36
  75. basic_memory-0.9.0.dist-info/METADATA +736 -0
  76. basic_memory-0.9.0.dist-info/RECORD +99 -0
  77. basic_memory/alembic/README +0 -1
  78. basic_memory/cli/commands/tools.py +0 -157
  79. basic_memory/mcp/tools/knowledge.py +0 -68
  80. basic_memory/mcp/tools/memory.py +0 -170
  81. basic_memory/mcp/tools/notes.py +0 -202
  82. basic_memory/schemas/discovery.py +0 -28
  83. basic_memory/sync/file_change_scanner.py +0 -158
  84. basic_memory/sync/utils.py +0 -31
  85. basic_memory-0.7.0.dist-info/METADATA +0 -378
  86. basic_memory-0.7.0.dist-info/RECORD +0 -82
  87. {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/WHEEL +0 -0
  88. {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/entry_points.txt +0 -0
  89. {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -25,8 +25,8 @@ from basic_memory.repository.search_repository import SearchRepository
25
25
  from basic_memory.services import EntityService, FileService
26
26
  from basic_memory.services.link_resolver import LinkResolver
27
27
  from basic_memory.services.search_service import SearchService
28
- from basic_memory.sync import SyncService, FileChangeScanner
29
- from basic_memory.sync.utils import SyncReport
28
+ from basic_memory.sync import SyncService
29
+ from basic_memory.sync.sync_service import SyncReport
30
30
  from basic_memory.sync.watch_service import WatchService
31
31
 
32
32
  console = Console()
@@ -58,9 +58,6 @@ async def get_sync_service(): # pragma: no cover
58
58
  search_service = SearchService(search_repository, entity_repository, file_service)
59
59
  link_resolver = LinkResolver(entity_repository, search_service)
60
60
 
61
- # Initialize scanner
62
- file_change_scanner = FileChangeScanner(entity_repository)
63
-
64
61
  # Initialize services
65
62
  entity_service = EntityService(
66
63
  entity_parser,
@@ -73,12 +70,12 @@ async def get_sync_service(): # pragma: no cover
73
70
 
74
71
  # Create sync service
75
72
  sync_service = SyncService(
76
- scanner=file_change_scanner,
77
73
  entity_service=entity_service,
78
74
  entity_parser=entity_parser,
79
75
  entity_repository=entity_repository,
80
76
  relation_repository=relation_repository,
81
77
  search_service=search_service,
78
+ file_service=file_service,
82
79
  )
83
80
 
84
81
  return sync_service
@@ -95,9 +92,11 @@ def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[V
95
92
 
96
93
  def display_sync_summary(knowledge: SyncReport):
97
94
  """Display a one-line summary of sync changes."""
98
- total_changes = knowledge.total_changes
95
+ total_changes = knowledge.total
96
+ project_name = config.project
97
+
99
98
  if total_changes == 0:
100
- console.print("[green]Everything up to date[/green]")
99
+ console.print(f"[green]Project '{project_name}': Everything up to date[/green]")
101
100
  return
102
101
 
103
102
  # Format as: "Synced X files (A new, B modified, C moved, D deleted)"
@@ -116,18 +115,20 @@ def display_sync_summary(knowledge: SyncReport):
116
115
  if del_count:
117
116
  changes.append(f"[red]{del_count} deleted[/red]")
118
117
 
119
- console.print(f"Synced {total_changes} files ({', '.join(changes)})")
118
+ console.print(f"Project '{project_name}': Synced {total_changes} files ({', '.join(changes)})")
120
119
 
121
120
 
122
121
  def display_detailed_sync_results(knowledge: SyncReport):
123
122
  """Display detailed sync results with trees."""
124
- if knowledge.total_changes == 0:
125
- console.print("\n[green]Everything up to date[/green]")
123
+ project_name = config.project
124
+
125
+ if knowledge.total == 0:
126
+ console.print(f"\n[green]Project '{project_name}': Everything up to date[/green]")
126
127
  return
127
128
 
128
- console.print("\n[bold]Sync Results[/bold]")
129
+ console.print(f"\n[bold]Sync Results for Project '{project_name}'[/bold]")
129
130
 
130
- if knowledge.total_changes > 0:
131
+ if knowledge.total > 0:
131
132
  knowledge_tree = Tree("[bold]Knowledge Files[/bold]")
132
133
  if knowledge.new:
133
134
  created = knowledge_tree.add("[green]Created[/green]")
@@ -153,21 +154,52 @@ def display_detailed_sync_results(knowledge: SyncReport):
153
154
 
154
155
  async def run_sync(verbose: bool = False, watch: bool = False, console_status: bool = False):
155
156
  """Run sync operation."""
157
+ import time
158
+
159
+ start_time = time.time()
160
+
161
+ logger.info(
162
+ "Sync command started",
163
+ project=config.project,
164
+ watch_mode=watch,
165
+ verbose=verbose,
166
+ directory=str(config.home),
167
+ )
156
168
 
157
169
  sync_service = await get_sync_service()
158
170
 
159
171
  # Start watching if requested
160
172
  if watch:
173
+ logger.info("Starting watch service after initial sync")
161
174
  watch_service = WatchService(
162
175
  sync_service=sync_service,
163
176
  file_service=sync_service.entity_service.file_service,
164
177
  config=config,
165
178
  )
166
- await watch_service.handle_changes(config.home)
167
- await watch_service.run(console_status=console_status) # pragma: no cover
179
+
180
+ # full sync - no progress bars in watch mode
181
+ await sync_service.sync(config.home, show_progress=False)
182
+
183
+ # watch changes
184
+ await watch_service.run() # pragma: no cover
168
185
  else:
169
- # one time sync
170
- knowledge_changes = await sync_service.sync(config.home)
186
+ # one time sync - use progress bars for better UX
187
+ logger.info("Running one-time sync")
188
+ knowledge_changes = await sync_service.sync(config.home, show_progress=True)
189
+
190
+ # Log results
191
+ duration_ms = int((time.time() - start_time) * 1000)
192
+ logger.info(
193
+ "Sync command completed",
194
+ project=config.project,
195
+ total_changes=knowledge_changes.total,
196
+ new_files=len(knowledge_changes.new),
197
+ modified_files=len(knowledge_changes.modified),
198
+ deleted_files=len(knowledge_changes.deleted),
199
+ moved_files=len(knowledge_changes.moves),
200
+ duration_ms=duration_ms,
201
+ )
202
+
171
203
  # Display results
172
204
  if verbose:
173
205
  display_detailed_sync_results(knowledge_changes)
@@ -189,18 +221,27 @@ def sync(
189
221
  "-w",
190
222
  help="Start watching for changes after sync.",
191
223
  ),
192
- console_status: bool = typer.Option(
193
- False, "--console-status", "-c", help="Show live console status"
194
- ),
195
224
  ) -> None:
196
225
  """Sync knowledge files with the database."""
197
226
  try:
227
+ # Show which project we're syncing
228
+ if not watch: # Don't show in watch mode as it would break the UI
229
+ typer.echo(f"Syncing project: {config.project}")
230
+ typer.echo(f"Project path: {config.home}")
231
+
198
232
  # Run sync
199
- asyncio.run(run_sync(verbose=verbose, watch=watch, console_status=console_status))
233
+ asyncio.run(run_sync(verbose=verbose, watch=watch))
200
234
 
201
235
  except Exception as e: # pragma: no cover
202
236
  if not isinstance(e, typer.Exit):
203
- logger.exception("Sync failed")
237
+ logger.exception(
238
+ "Sync command failed",
239
+ project=config.project,
240
+ error=str(e),
241
+ error_type=type(e).__name__,
242
+ watch_mode=watch,
243
+ directory=str(config.home),
244
+ )
204
245
  typer.echo(f"Error during sync: {e}", err=True)
205
246
  raise typer.Exit(1)
206
247
  raise
@@ -0,0 +1,253 @@
1
+ """CLI tool commands for Basic Memory."""
2
+
3
+ import asyncio
4
+ import sys
5
+ from typing import Optional, List, Annotated
6
+
7
+ import typer
8
+ from loguru import logger
9
+ from rich import print as rprint
10
+
11
+ from basic_memory.cli.app import app
12
+ from basic_memory.mcp.tools import build_context as mcp_build_context
13
+ from basic_memory.mcp.tools import read_note as mcp_read_note
14
+ from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
15
+ from basic_memory.mcp.tools import search as mcp_search
16
+ from basic_memory.mcp.tools import write_note as mcp_write_note
17
+
18
+ # Import prompts
19
+ from basic_memory.mcp.prompts.continue_conversation import (
20
+ continue_conversation as mcp_continue_conversation,
21
+ )
22
+
23
+ from basic_memory.mcp.prompts.recent_activity import (
24
+ recent_activity_prompt as recent_activity_prompt,
25
+ )
26
+
27
+ from basic_memory.schemas.base import TimeFrame
28
+ from basic_memory.schemas.memory import MemoryUrl
29
+ from basic_memory.schemas.search import SearchQuery, SearchItemType
30
+
31
+ tool_app = typer.Typer()
32
+ app.add_typer(tool_app, name="tool", help="Direct access to MCP tools via CLI")
33
+
34
+
35
+ @tool_app.command()
36
+ def write_note(
37
+ title: Annotated[str, typer.Option(help="The title of the note")],
38
+ folder: Annotated[str, typer.Option(help="The folder to create the note in")],
39
+ content: Annotated[
40
+ Optional[str],
41
+ typer.Option(
42
+ help="The content of the note. If not provided, content will be read from stdin. This allows piping content from other commands, e.g.: cat file.md | basic-memory tools write-note"
43
+ ),
44
+ ] = None,
45
+ tags: Annotated[
46
+ Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
47
+ ] = None,
48
+ ):
49
+ """Create or update a markdown note. Content can be provided as an argument or read from stdin.
50
+
51
+ Content can be provided in two ways:
52
+ 1. Using the --content parameter
53
+ 2. Piping content through stdin (if --content is not provided)
54
+
55
+ Examples:
56
+
57
+ # Using content parameter
58
+ basic-memory tools write-note --title "My Note" --folder "notes" --content "Note content"
59
+
60
+ # Using stdin pipe
61
+ echo "# My Note Content" | basic-memory tools write-note --title "My Note" --folder "notes"
62
+
63
+ # Using heredoc
64
+ cat << EOF | basic-memory tools write-note --title "My Note" --folder "notes"
65
+ # My Document
66
+
67
+ This is my document content.
68
+
69
+ - Point 1
70
+ - Point 2
71
+ EOF
72
+
73
+ # Reading from a file
74
+ cat document.md | basic-memory tools write-note --title "Document" --folder "docs"
75
+ """
76
+ try:
77
+ # If content is not provided, read from stdin
78
+ if content is None:
79
+ # Check if we're getting data from a pipe or redirect
80
+ if not sys.stdin.isatty():
81
+ content = sys.stdin.read()
82
+ else: # pragma: no cover
83
+ # If stdin is a terminal (no pipe/redirect), inform the user
84
+ typer.echo(
85
+ "No content provided. Please provide content via --content or by piping to stdin.",
86
+ err=True,
87
+ )
88
+ raise typer.Exit(1)
89
+
90
+ # Also check for empty content
91
+ if content is not None and not content.strip():
92
+ typer.echo("Empty content provided. Please provide non-empty content.", err=True)
93
+ raise typer.Exit(1)
94
+
95
+ note = asyncio.run(mcp_write_note(title, content, folder, tags))
96
+ rprint(note)
97
+ except Exception as e: # pragma: no cover
98
+ if not isinstance(e, typer.Exit):
99
+ typer.echo(f"Error during write_note: {e}", err=True)
100
+ raise typer.Exit(1)
101
+ raise
102
+
103
+
104
+ @tool_app.command()
105
+ def read_note(identifier: str, page: int = 1, page_size: int = 10):
106
+ try:
107
+ note = asyncio.run(mcp_read_note(identifier, page, page_size))
108
+ rprint(note)
109
+ except Exception as e: # pragma: no cover
110
+ if not isinstance(e, typer.Exit):
111
+ typer.echo(f"Error during read_note: {e}", err=True)
112
+ raise typer.Exit(1)
113
+ raise
114
+
115
+
116
+ @tool_app.command()
117
+ def build_context(
118
+ url: MemoryUrl,
119
+ depth: Optional[int] = 1,
120
+ timeframe: Optional[TimeFrame] = "7d",
121
+ page: int = 1,
122
+ page_size: int = 10,
123
+ max_related: int = 10,
124
+ ):
125
+ try:
126
+ context = asyncio.run(
127
+ mcp_build_context(
128
+ url=url,
129
+ depth=depth,
130
+ timeframe=timeframe,
131
+ page=page,
132
+ page_size=page_size,
133
+ max_related=max_related,
134
+ )
135
+ )
136
+ # Use json module for more controlled serialization
137
+ import json
138
+
139
+ context_dict = context.model_dump(exclude_none=True)
140
+ print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
141
+ except Exception as e: # pragma: no cover
142
+ if not isinstance(e, typer.Exit):
143
+ typer.echo(f"Error during build_context: {e}", err=True)
144
+ raise typer.Exit(1)
145
+ raise
146
+
147
+
148
+ @tool_app.command()
149
+ def recent_activity(
150
+ type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
151
+ depth: Optional[int] = 1,
152
+ timeframe: Optional[TimeFrame] = "7d",
153
+ page: int = 1,
154
+ page_size: int = 10,
155
+ max_related: int = 10,
156
+ ):
157
+ try:
158
+ context = asyncio.run(
159
+ mcp_recent_activity(
160
+ type=type, # pyright: ignore [reportArgumentType]
161
+ depth=depth,
162
+ timeframe=timeframe,
163
+ page=page,
164
+ page_size=page_size,
165
+ max_related=max_related,
166
+ )
167
+ )
168
+ # Use json module for more controlled serialization
169
+ import json
170
+
171
+ context_dict = context.model_dump(exclude_none=True)
172
+ print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
173
+ except Exception as e: # pragma: no cover
174
+ if not isinstance(e, typer.Exit):
175
+ typer.echo(f"Error during build_context: {e}", err=True)
176
+ raise typer.Exit(1)
177
+ raise
178
+
179
+
180
+ @tool_app.command()
181
+ def search(
182
+ query: str,
183
+ permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
184
+ title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
185
+ after_date: Annotated[
186
+ Optional[str],
187
+ typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
188
+ ] = None,
189
+ page: int = 1,
190
+ page_size: int = 10,
191
+ ):
192
+ if permalink and title: # pragma: no cover
193
+ print("Cannot search both permalink and title")
194
+ raise typer.Abort()
195
+
196
+ try:
197
+ search_query = SearchQuery(
198
+ permalink_match=query if permalink else None,
199
+ text=query if not (permalink or title) else None,
200
+ title=query if title else None,
201
+ after_date=after_date,
202
+ )
203
+ results = asyncio.run(mcp_search(query=search_query, page=page, page_size=page_size))
204
+ # Use json module for more controlled serialization
205
+ import json
206
+
207
+ results_dict = results.model_dump(exclude_none=True)
208
+ print(json.dumps(results_dict, indent=2, ensure_ascii=True, default=str))
209
+ except Exception as e: # pragma: no cover
210
+ if not isinstance(e, typer.Exit):
211
+ logger.exception("Error during search", e)
212
+ typer.echo(f"Error during search: {e}", err=True)
213
+ raise typer.Exit(1)
214
+ raise
215
+
216
+
217
+ @tool_app.command(name="continue-conversation")
218
+ def continue_conversation(
219
+ topic: Annotated[Optional[str], typer.Option(help="Topic or keyword to search for")] = None,
220
+ timeframe: Annotated[
221
+ Optional[str], typer.Option(help="How far back to look for activity")
222
+ ] = None,
223
+ ):
224
+ """Prompt to continue a previous conversation or work session."""
225
+ try:
226
+ # Prompt functions return formatted strings directly
227
+ session = asyncio.run(mcp_continue_conversation(topic=topic, timeframe=timeframe))
228
+ rprint(session)
229
+ except Exception as e: # pragma: no cover
230
+ if not isinstance(e, typer.Exit):
231
+ logger.exception("Error continuing conversation", e)
232
+ typer.echo(f"Error continuing conversation: {e}", err=True)
233
+ raise typer.Exit(1)
234
+ raise
235
+
236
+
237
+ # @tool_app.command(name="show-recent-activity")
238
+ # def show_recent_activity(
239
+ # timeframe: Annotated[
240
+ # str, typer.Option(help="How far back to look for activity")
241
+ # ] = "7d",
242
+ # ):
243
+ # """Prompt to show recent activity."""
244
+ # try:
245
+ # # Prompt functions return formatted strings directly
246
+ # session = asyncio.run(recent_activity_prompt(timeframe=timeframe))
247
+ # rprint(session)
248
+ # except Exception as e: # pragma: no cover
249
+ # if not isinstance(e, typer.Exit):
250
+ # logger.exception("Error continuing conversation", e)
251
+ # typer.echo(f"Error continuing conversation: {e}", err=True)
252
+ # raise typer.Exit(1)
253
+ # raise
basic_memory/cli/main.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Main CLI entry point for basic-memory.""" # pragma: no cover
2
2
 
3
3
  from basic_memory.cli.app import app # pragma: no cover
4
+ import typer
4
5
 
5
6
  # Register commands
6
7
  from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
@@ -12,9 +13,46 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
12
13
  import_claude_conversations,
13
14
  import_claude_projects,
14
15
  import_chatgpt,
15
- tools,
16
+ tool,
17
+ project,
16
18
  )
17
19
 
18
20
 
21
+ # Version command
22
+ @app.callback(invoke_without_command=True)
23
+ def main(
24
+ ctx: typer.Context,
25
+ project: str = typer.Option( # noqa
26
+ "main",
27
+ "--project",
28
+ "-p",
29
+ help="Specify which project to use",
30
+ envvar="BASIC_MEMORY_PROJECT",
31
+ ),
32
+ version: bool = typer.Option(
33
+ False,
34
+ "--version",
35
+ "-V",
36
+ help="Show version information and exit.",
37
+ is_eager=True,
38
+ ),
39
+ ):
40
+ """Basic Memory - Local-first personal knowledge management system."""
41
+ if version: # pragma: no cover
42
+ from basic_memory import __version__
43
+ from basic_memory.config import config
44
+
45
+ typer.echo(f"Basic Memory v{__version__}")
46
+ typer.echo(f"Current project: {config.project}")
47
+ typer.echo(f"Project path: {config.home}")
48
+ raise typer.Exit()
49
+
50
+ # Handle project selection via environment variable
51
+ if project:
52
+ import os
53
+
54
+ os.environ["BASIC_MEMORY_PROJECT"] = project
55
+
56
+
19
57
  if __name__ == "__main__": # pragma: no cover
20
58
  app()
basic_memory/config.py CHANGED
@@ -1,13 +1,20 @@
1
1
  """Configuration management for basic-memory."""
2
2
 
3
+ import json
4
+ import os
3
5
  from pathlib import Path
4
- from typing import Literal
6
+ from typing import Any, Dict, Literal, Optional
5
7
 
8
+ from loguru import logger
6
9
  from pydantic import Field, field_validator
7
10
  from pydantic_settings import BaseSettings, SettingsConfigDict
8
11
 
12
+ import basic_memory
13
+ from basic_memory.utils import setup_logging
14
+
9
15
  DATABASE_NAME = "memory.db"
10
16
  DATA_DIR_NAME = ".basic-memory"
17
+ CONFIG_FILE_NAME = "config.json"
11
18
 
12
19
  Environment = Literal["test", "dev", "user"]
13
20
 
@@ -31,7 +38,7 @@ class ProjectConfig(BaseSettings):
31
38
  default=500, description="Milliseconds to wait after changes before syncing", gt=0
32
39
  )
33
40
 
34
- log_level: str = "INFO"
41
+ log_level: str = "DEBUG"
35
42
 
36
43
  model_config = SettingsConfigDict(
37
44
  env_prefix="BASIC_MEMORY_",
@@ -58,5 +65,160 @@ class ProjectConfig(BaseSettings):
58
65
  return v
59
66
 
60
67
 
61
- # Load project config
62
- config = ProjectConfig()
68
+ class BasicMemoryConfig(BaseSettings):
69
+ """Pydantic model for Basic Memory global configuration."""
70
+
71
+ projects: Dict[str, str] = Field(
72
+ default_factory=lambda: {"main": str(Path.home() / "basic-memory")},
73
+ description="Mapping of project names to their filesystem paths",
74
+ )
75
+ default_project: str = Field(
76
+ default="main",
77
+ description="Name of the default project to use",
78
+ )
79
+
80
+ model_config = SettingsConfigDict(
81
+ env_prefix="BASIC_MEMORY_",
82
+ extra="ignore",
83
+ )
84
+
85
+ def model_post_init(self, __context: Any) -> None:
86
+ """Ensure configuration is valid after initialization."""
87
+ # Ensure main project exists
88
+ if "main" not in self.projects:
89
+ self.projects["main"] = str(Path.home() / "basic-memory")
90
+
91
+ # Ensure default project is valid
92
+ if self.default_project not in self.projects:
93
+ self.default_project = "main"
94
+
95
+
96
+ class ConfigManager:
97
+ """Manages Basic Memory configuration."""
98
+
99
+ def __init__(self) -> None:
100
+ """Initialize the configuration manager."""
101
+ self.config_dir = Path.home() / DATA_DIR_NAME
102
+ self.config_file = self.config_dir / CONFIG_FILE_NAME
103
+
104
+ # Ensure config directory exists
105
+ self.config_dir.mkdir(parents=True, exist_ok=True)
106
+
107
+ # Load or create configuration
108
+ self.config = self.load_config()
109
+
110
+ def load_config(self) -> BasicMemoryConfig:
111
+ """Load configuration from file or create default."""
112
+ if self.config_file.exists():
113
+ try:
114
+ data = json.loads(self.config_file.read_text())
115
+ return BasicMemoryConfig(**data)
116
+ except Exception as e:
117
+ logger.error(f"Failed to load config: {e}")
118
+ config = BasicMemoryConfig()
119
+ self.save_config(config)
120
+ return config
121
+ else:
122
+ config = BasicMemoryConfig()
123
+ self.save_config(config)
124
+ return config
125
+
126
+ def save_config(self, config: BasicMemoryConfig) -> None:
127
+ """Save configuration to file."""
128
+ try:
129
+ self.config_file.write_text(json.dumps(config.model_dump(), indent=2))
130
+ except Exception as e: # pragma: no cover
131
+ logger.error(f"Failed to save config: {e}")
132
+
133
+ @property
134
+ def projects(self) -> Dict[str, str]:
135
+ """Get all configured projects."""
136
+ return self.config.projects.copy()
137
+
138
+ @property
139
+ def default_project(self) -> str:
140
+ """Get the default project name."""
141
+ return self.config.default_project
142
+
143
+ def get_project_path(self, project_name: Optional[str] = None) -> Path:
144
+ """Get the path for a specific project or the default project."""
145
+ name = project_name or self.config.default_project
146
+
147
+ # Check if specified in environment variable
148
+ if not project_name and "BASIC_MEMORY_PROJECT" in os.environ:
149
+ name = os.environ["BASIC_MEMORY_PROJECT"]
150
+
151
+ if name not in self.config.projects:
152
+ raise ValueError(f"Project '{name}' not found in configuration")
153
+
154
+ return Path(self.config.projects[name])
155
+
156
+ def add_project(self, name: str, path: str) -> None:
157
+ """Add a new project to the configuration."""
158
+ if name in self.config.projects:
159
+ raise ValueError(f"Project '{name}' already exists")
160
+
161
+ # Ensure the path exists
162
+ project_path = Path(path)
163
+ project_path.mkdir(parents=True, exist_ok=True)
164
+
165
+ self.config.projects[name] = str(project_path)
166
+ self.save_config(self.config)
167
+
168
+ def remove_project(self, name: str) -> None:
169
+ """Remove a project from the configuration."""
170
+ if name not in self.config.projects:
171
+ raise ValueError(f"Project '{name}' not found")
172
+
173
+ if name == self.config.default_project:
174
+ raise ValueError(f"Cannot remove the default project '{name}'")
175
+
176
+ del self.config.projects[name]
177
+ self.save_config(self.config)
178
+
179
+ def set_default_project(self, name: str) -> None:
180
+ """Set the default project."""
181
+ if name not in self.config.projects: # pragma: no cover
182
+ raise ValueError(f"Project '{name}' not found")
183
+
184
+ self.config.default_project = name
185
+ self.save_config(self.config)
186
+
187
+
188
+ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
189
+ """Get a project configuration for the specified project."""
190
+ config_manager = ConfigManager()
191
+
192
+ # Get project name from environment variable or use provided name or default
193
+ actual_project_name = os.environ.get(
194
+ "BASIC_MEMORY_PROJECT", project_name or config_manager.default_project
195
+ )
196
+
197
+ try:
198
+ project_path = config_manager.get_project_path(actual_project_name)
199
+ return ProjectConfig(home=project_path, project=actual_project_name)
200
+ except ValueError: # pragma: no cover
201
+ logger.warning(f"Project '{actual_project_name}' not found, using default")
202
+ project_path = config_manager.get_project_path(config_manager.default_project)
203
+ return ProjectConfig(home=project_path, project=config_manager.default_project)
204
+
205
+
206
+ # Create config manager
207
+ config_manager = ConfigManager()
208
+
209
+ # Load project config for current context
210
+ config = get_project_config()
211
+
212
+ # setup logging to a single log file in user home directory
213
+ user_home = Path.home()
214
+ log_dir = user_home / DATA_DIR_NAME
215
+ log_dir.mkdir(parents=True, exist_ok=True)
216
+
217
+ setup_logging(
218
+ env=config.env,
219
+ home_dir=user_home, # Use user home for logs
220
+ log_level=config.log_level,
221
+ log_file=f"{DATA_DIR_NAME}/basic-memory.log",
222
+ console=False,
223
+ )
224
+ logger.info(f"Starting Basic Memory {basic_memory.__version__} (Project: {config.project})")