basic-memory 0.10.0__py3-none-any.whl → 0.11.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.

basic_memory/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
2
2
 
3
- __version__ = "0.10.0"
3
+ __version__ = "0.11.0"
@@ -1,7 +1,7 @@
1
1
  """CLI commands for basic-memory."""
2
2
 
3
3
  from . import status, sync, db, import_memory_json, mcp, import_claude_conversations
4
- from . import import_claude_projects, import_chatgpt, tool, project, project_info
4
+ from . import import_claude_projects, import_chatgpt, tool, project
5
5
 
6
6
  __all__ = [
7
7
  "status",
@@ -14,5 +14,4 @@ __all__ = [
14
14
  "import_chatgpt",
15
15
  "tool",
16
16
  "project",
17
- "project_info",
18
17
  ]
@@ -1,10 +1,13 @@
1
1
  """Database management commands."""
2
2
 
3
+ import asyncio
4
+
3
5
  import typer
4
6
  from loguru import logger
5
7
 
6
- from basic_memory.alembic import migrations
8
+ from basic_memory import db
7
9
  from basic_memory.cli.app import app
10
+ from basic_memory.config import config
8
11
 
9
12
 
10
13
  @app.command()
@@ -14,7 +17,17 @@ def reset(
14
17
  """Reset database (drop all tables and recreate)."""
15
18
  if typer.confirm("This will delete all data in your db. Are you sure?"):
16
19
  logger.info("Resetting database...")
17
- migrations.reset_database()
20
+ # Get database path
21
+ db_path = config.database_path
22
+
23
+ # Delete the database file if it exists
24
+ if db_path.exists():
25
+ db_path.unlink()
26
+ logger.info(f"Database file deleted: {db_path}")
27
+
28
+ # Create a new empty database
29
+ asyncio.run(db.run_migrations(config))
30
+ logger.info("Database reset complete")
18
31
 
19
32
  if reindex:
20
33
  # Import and run sync
@@ -1,5 +1,6 @@
1
1
  """Command module for basic-memory project management."""
2
2
 
3
+ import asyncio
3
4
  import os
4
5
  from pathlib import Path
5
6
 
@@ -9,6 +10,12 @@ from rich.table import Table
9
10
 
10
11
  from basic_memory.cli.app import app
11
12
  from basic_memory.config import ConfigManager, config
13
+ from basic_memory.mcp.tools.project_info import project_info
14
+ import json
15
+ from datetime import datetime
16
+
17
+ from rich.panel import Panel
18
+ from rich.tree import Tree
12
19
 
13
20
  console = Console()
14
21
 
@@ -92,12 +99,22 @@ def remove_project(
92
99
  def set_default_project(
93
100
  name: str = typer.Argument(..., help="Name of the project to set as default"),
94
101
  ) -> None:
95
- """Set the default project."""
102
+ """Set the default project and activate it for the current session."""
96
103
  config_manager = ConfigManager()
97
104
 
98
105
  try:
106
+ # Set the default project
99
107
  config_manager.set_default_project(name)
100
- console.print(f"[green]Project '{name}' set as default[/green]")
108
+
109
+ # Also activate it for the current session by setting the environment variable
110
+ os.environ["BASIC_MEMORY_PROJECT"] = name
111
+
112
+ # Reload configuration to apply the change
113
+ from importlib import reload
114
+ from basic_memory import config as config_module
115
+
116
+ reload(config_module)
117
+ console.print(f"[green]Project '{name}' set as default and activated[/green]")
101
118
  except ValueError as e: # pragma: no cover
102
119
  console.print(f"[red]Error: {e}[/red]")
103
120
  raise typer.Exit(1)
@@ -117,3 +134,152 @@ def show_current_project() -> None:
117
134
  except ValueError: # pragma: no cover
118
135
  console.print(f"[yellow]Warning: Project '{current}' not found in configuration[/yellow]")
119
136
  console.print(f"Using default project: [cyan]{config_manager.default_project}[/cyan]")
137
+
138
+
139
+ @project_app.command("info")
140
+ def display_project_info(
141
+ json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
142
+ ):
143
+ """Display detailed information and statistics about the current project."""
144
+ try:
145
+ # Get project info
146
+ info = asyncio.run(project_info())
147
+
148
+ if json_output:
149
+ # Convert to JSON and print
150
+ print(json.dumps(info.model_dump(), indent=2, default=str))
151
+ else:
152
+ # Create rich display
153
+ console = Console()
154
+
155
+ # Project configuration section
156
+ console.print(
157
+ Panel(
158
+ f"[bold]Project:[/bold] {info.project_name}\n"
159
+ f"[bold]Path:[/bold] {info.project_path}\n"
160
+ f"[bold]Default Project:[/bold] {info.default_project}\n",
161
+ title="📊 Basic Memory Project Info",
162
+ expand=False,
163
+ )
164
+ )
165
+
166
+ # Statistics section
167
+ stats_table = Table(title="📈 Statistics")
168
+ stats_table.add_column("Metric", style="cyan")
169
+ stats_table.add_column("Count", style="green")
170
+
171
+ stats_table.add_row("Entities", str(info.statistics.total_entities))
172
+ stats_table.add_row("Observations", str(info.statistics.total_observations))
173
+ stats_table.add_row("Relations", str(info.statistics.total_relations))
174
+ stats_table.add_row(
175
+ "Unresolved Relations", str(info.statistics.total_unresolved_relations)
176
+ )
177
+ stats_table.add_row("Isolated Entities", str(info.statistics.isolated_entities))
178
+
179
+ console.print(stats_table)
180
+
181
+ # Entity types
182
+ if info.statistics.entity_types:
183
+ entity_types_table = Table(title="📑 Entity Types")
184
+ entity_types_table.add_column("Type", style="blue")
185
+ entity_types_table.add_column("Count", style="green")
186
+
187
+ for entity_type, count in info.statistics.entity_types.items():
188
+ entity_types_table.add_row(entity_type, str(count))
189
+
190
+ console.print(entity_types_table)
191
+
192
+ # Most connected entities
193
+ if info.statistics.most_connected_entities:
194
+ connected_table = Table(title="🔗 Most Connected Entities")
195
+ connected_table.add_column("Title", style="blue")
196
+ connected_table.add_column("Permalink", style="cyan")
197
+ connected_table.add_column("Relations", style="green")
198
+
199
+ for entity in info.statistics.most_connected_entities:
200
+ connected_table.add_row(
201
+ entity["title"], entity["permalink"], str(entity["relation_count"])
202
+ )
203
+
204
+ console.print(connected_table)
205
+
206
+ # Recent activity
207
+ if info.activity.recently_updated:
208
+ recent_table = Table(title="🕒 Recent Activity")
209
+ recent_table.add_column("Title", style="blue")
210
+ recent_table.add_column("Type", style="cyan")
211
+ recent_table.add_column("Last Updated", style="green")
212
+
213
+ for entity in info.activity.recently_updated[:5]: # Show top 5
214
+ updated_at = (
215
+ datetime.fromisoformat(entity["updated_at"])
216
+ if isinstance(entity["updated_at"], str)
217
+ else entity["updated_at"]
218
+ )
219
+ recent_table.add_row(
220
+ entity["title"],
221
+ entity["entity_type"],
222
+ updated_at.strftime("%Y-%m-%d %H:%M"),
223
+ )
224
+
225
+ console.print(recent_table)
226
+
227
+ # System status
228
+ system_tree = Tree("🖥️ System Status")
229
+ system_tree.add(f"Basic Memory version: [bold green]{info.system.version}[/bold green]")
230
+ system_tree.add(
231
+ f"Database: [cyan]{info.system.database_path}[/cyan] ([green]{info.system.database_size}[/green])"
232
+ )
233
+
234
+ # Watch status
235
+ if info.system.watch_status: # pragma: no cover
236
+ watch_branch = system_tree.add("Watch Service")
237
+ running = info.system.watch_status.get("running", False)
238
+ status_color = "green" if running else "red"
239
+ watch_branch.add(
240
+ f"Status: [bold {status_color}]{'Running' if running else 'Stopped'}[/bold {status_color}]"
241
+ )
242
+
243
+ if running:
244
+ start_time = (
245
+ datetime.fromisoformat(info.system.watch_status.get("start_time", ""))
246
+ if isinstance(info.system.watch_status.get("start_time"), str)
247
+ else info.system.watch_status.get("start_time")
248
+ )
249
+ watch_branch.add(
250
+ f"Running since: [cyan]{start_time.strftime('%Y-%m-%d %H:%M')}[/cyan]"
251
+ )
252
+ watch_branch.add(
253
+ f"Files synced: [green]{info.system.watch_status.get('synced_files', 0)}[/green]"
254
+ )
255
+ watch_branch.add(
256
+ f"Errors: [{'red' if info.system.watch_status.get('error_count', 0) > 0 else 'green'}]{info.system.watch_status.get('error_count', 0)}[/{'red' if info.system.watch_status.get('error_count', 0) > 0 else 'green'}]"
257
+ )
258
+ else:
259
+ system_tree.add("[yellow]Watch service not running[/yellow]")
260
+
261
+ console.print(system_tree)
262
+
263
+ # Available projects
264
+ projects_table = Table(title="📁 Available Projects")
265
+ projects_table.add_column("Name", style="blue")
266
+ projects_table.add_column("Path", style="cyan")
267
+ projects_table.add_column("Default", style="green")
268
+
269
+ for name, path in info.available_projects.items():
270
+ is_default = name == info.default_project
271
+ projects_table.add_row(name, path, "✓" if is_default else "")
272
+
273
+ console.print(projects_table)
274
+
275
+ # Timestamp
276
+ current_time = (
277
+ datetime.fromisoformat(str(info.system.timestamp))
278
+ if isinstance(info.system.timestamp, str)
279
+ else info.system.timestamp
280
+ )
281
+ console.print(f"\nTimestamp: [cyan]{current_time.strftime('%Y-%m-%d %H:%M:%S')}[/cyan]")
282
+
283
+ except Exception as e: # pragma: no cover
284
+ typer.echo(f"Error getting project info: {e}", err=True)
285
+ raise typer.Exit(1)
@@ -12,7 +12,7 @@ from basic_memory.cli.app import app
12
12
  from basic_memory.mcp.tools import build_context as mcp_build_context
13
13
  from basic_memory.mcp.tools import read_note as mcp_read_note
14
14
  from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
15
- from basic_memory.mcp.tools import search as mcp_search
15
+ from basic_memory.mcp.tools import search_notes as mcp_search
16
16
  from basic_memory.mcp.tools import write_note as mcp_write_note
17
17
 
18
18
  # Import prompts
@@ -29,7 +29,7 @@ from basic_memory.schemas.memory import MemoryUrl
29
29
  from basic_memory.schemas.search import SearchQuery, SearchItemType
30
30
 
31
31
  tool_app = typer.Typer()
32
- app.add_typer(tool_app, name="tool", help="Direct access to MCP tools via CLI")
32
+ app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
33
33
 
34
34
 
35
35
  @tool_app.command()
@@ -103,6 +103,7 @@ def write_note(
103
103
 
104
104
  @tool_app.command()
105
105
  def read_note(identifier: str, page: int = 1, page_size: int = 10):
106
+ """Read a markdown note from the knowledge base."""
106
107
  try:
107
108
  note = asyncio.run(mcp_read_note(identifier, page, page_size))
108
109
  rprint(note)
@@ -122,6 +123,7 @@ def build_context(
122
123
  page_size: int = 10,
123
124
  max_related: int = 10,
124
125
  ):
126
+ """Get context needed to continue a discussion."""
125
127
  try:
126
128
  context = asyncio.run(
127
129
  mcp_build_context(
@@ -154,6 +156,7 @@ def recent_activity(
154
156
  page_size: int = 10,
155
157
  max_related: int = 10,
156
158
  ):
159
+ """Get recent activity across the knowledge base."""
157
160
  try:
158
161
  context = asyncio.run(
159
162
  mcp_recent_activity(
@@ -177,8 +180,8 @@ def recent_activity(
177
180
  raise
178
181
 
179
182
 
180
- @tool_app.command()
181
- def search(
183
+ @tool_app.command("search-notes")
184
+ def search_notes(
182
185
  query: str,
183
186
  permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
184
187
  title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
@@ -189,6 +192,7 @@ def search(
189
192
  page: int = 1,
190
193
  page_size: int = 10,
191
194
  ):
195
+ """Search across all content in the knowledge base."""
192
196
  if permalink and title: # pragma: no cover
193
197
  print("Cannot search both permalink and title")
194
198
  raise typer.Abort()
basic_memory/config.py CHANGED
@@ -5,12 +5,13 @@ import os
5
5
  from pathlib import Path
6
6
  from typing import Any, Dict, Literal, Optional
7
7
 
8
- import basic_memory
9
- from basic_memory.utils import setup_logging
10
8
  from loguru import logger
11
9
  from pydantic import Field, field_validator
12
10
  from pydantic_settings import BaseSettings, SettingsConfigDict
13
11
 
12
+ import basic_memory
13
+ from basic_memory.utils import setup_logging
14
+
14
15
  DATABASE_NAME = "memory.db"
15
16
  DATA_DIR_NAME = ".basic-memory"
16
17
  CONFIG_FILE_NAME = "config.json"
@@ -213,11 +214,45 @@ user_home = Path.home()
213
214
  log_dir = user_home / DATA_DIR_NAME
214
215
  log_dir.mkdir(parents=True, exist_ok=True)
215
216
 
216
- setup_logging(
217
- env=config.env,
218
- home_dir=user_home, # Use user home for logs
219
- log_level=config.log_level,
220
- log_file=f"{DATA_DIR_NAME}/basic-memory.log",
221
- console=False,
222
- )
223
- logger.info(f"Starting Basic Memory {basic_memory.__version__} (Project: {config.project})")
217
+
218
+ def get_process_name(): # pragma: no cover
219
+ """
220
+ get the type of process for logging
221
+ """
222
+ import sys
223
+
224
+ if "sync" in sys.argv:
225
+ return "sync"
226
+ elif "mcp" in sys.argv:
227
+ return "mcp"
228
+ else:
229
+ return "cli"
230
+
231
+
232
+ process_name = get_process_name()
233
+
234
+ # Global flag to track if logging has been set up
235
+ _LOGGING_SETUP = False
236
+
237
+ def setup_basic_memory_logging(): # pragma: no cover
238
+ """Set up logging for basic-memory, ensuring it only happens once."""
239
+ global _LOGGING_SETUP
240
+ if _LOGGING_SETUP:
241
+ # We can't log before logging is set up
242
+ # print("Skipping duplicate logging setup")
243
+ return
244
+
245
+ setup_logging(
246
+ env=config.env,
247
+ home_dir=user_home, # Use user home for logs
248
+ log_level=config.log_level,
249
+ log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
250
+ console=False,
251
+ )
252
+
253
+ logger.info(f"Starting Basic Memory {basic_memory.__version__} (Project: {config.project})")
254
+ _LOGGING_SETUP = True
255
+
256
+
257
+ # Set up logging
258
+ setup_basic_memory_logging()
@@ -4,21 +4,22 @@ Uses markdown-it with plugins to parse structured data from markdown content.
4
4
  """
5
5
 
6
6
  from dataclasses import dataclass, field
7
- from pathlib import Path
8
7
  from datetime import datetime
8
+ from pathlib import Path
9
9
  from typing import Any, Optional
10
- import dateparser
11
10
 
12
- from markdown_it import MarkdownIt
11
+ import dateparser
13
12
  import frontmatter
13
+ from markdown_it import MarkdownIt
14
14
 
15
15
  from basic_memory.markdown.plugins import observation_plugin, relation_plugin
16
16
  from basic_memory.markdown.schemas import (
17
- EntityMarkdown,
18
17
  EntityFrontmatter,
18
+ EntityMarkdown,
19
19
  Observation,
20
20
  Relation,
21
21
  )
22
+ from basic_memory.utils import parse_tags
22
23
 
23
24
  md = MarkdownIt().use(observation_plugin).use(relation_plugin)
24
25
 
@@ -56,11 +57,11 @@ def parse(content: str) -> EntityContent:
56
57
  )
57
58
 
58
59
 
59
- def parse_tags(tags: Any) -> list[str]:
60
- """Parse tags into list of strings."""
61
- if isinstance(tags, (list, tuple)):
62
- return [str(t).strip() for t in tags if str(t).strip()]
63
- return [t.strip() for t in tags.split(",") if t.strip()]
60
+ # def parse_tags(tags: Any) -> list[str]:
61
+ # """Parse tags into list of strings."""
62
+ # if isinstance(tags, (list, tuple)):
63
+ # return [str(t).strip() for t in tags if str(t).strip()]
64
+ # return [t.strip() for t in tags.split(",") if t.strip()]
64
65
 
65
66
 
66
67
  class EntityParser:
@@ -101,7 +102,9 @@ class EntityParser:
101
102
  metadata = post.metadata
102
103
  metadata["title"] = post.metadata.get("title", absolute_path.name)
103
104
  metadata["type"] = post.metadata.get("type", "note")
104
- metadata["tags"] = parse_tags(post.metadata.get("tags", []))
105
+ tags = parse_tags(post.metadata.get("tags", [])) # pyright: ignore
106
+ if tags:
107
+ metadata["tags"] = tags
105
108
 
106
109
  # frontmatter
107
110
  entity_frontmatter = EntityFrontmatter(
@@ -42,7 +42,7 @@ class EntityFrontmatter(BaseModel):
42
42
 
43
43
  @property
44
44
  def tags(self) -> List[str]:
45
- return self.metadata.get("tags") if self.metadata else [] # pyright: ignore
45
+ return self.metadata.get("tags") if self.metadata else None # pyright: ignore
46
46
 
47
47
  @property
48
48
  def title(self) -> str:
@@ -19,9 +19,7 @@ def ai_assistant_guide() -> str:
19
19
  A focused guide on Basic Memory usage.
20
20
  """
21
21
  logger.info("Loading AI assistant guide resource")
22
- guide_doc = (
23
- Path(__file__).parent.parent.parent.parent.parent / "static" / "ai_assistant_guide.md"
24
- )
22
+ guide_doc = Path(__file__).parent.parent / "resources" / "ai_assistant_guide.md"
25
23
  content = guide_doc.read_text(encoding="utf-8")
26
24
  logger.info(f"Loaded AI assistant guide ({len(content)} chars)")
27
25
  return content
@@ -14,7 +14,7 @@ from basic_memory.mcp.prompts.utils import format_prompt_context, PromptContext,
14
14
  from basic_memory.mcp.server import mcp
15
15
  from basic_memory.mcp.tools.build_context import build_context
16
16
  from basic_memory.mcp.tools.recent_activity import recent_activity
17
- from basic_memory.mcp.tools.search import search
17
+ from basic_memory.mcp.tools.search import search_notes
18
18
  from basic_memory.schemas.base import TimeFrame
19
19
  from basic_memory.schemas.memory import GraphContext
20
20
  from basic_memory.schemas.search import SearchQuery, SearchItemType
@@ -47,7 +47,7 @@ async def continue_conversation(
47
47
 
48
48
  # If topic provided, search for it
49
49
  if topic:
50
- search_results = await search(
50
+ search_results = await search_notes(
51
51
  SearchQuery(text=topic, after_date=timeframe, types=[SearchItemType.ENTITY])
52
52
  )
53
53
 
@@ -93,7 +93,7 @@ async def continue_conversation(
93
93
  ## Next Steps
94
94
 
95
95
  You can:
96
- - Explore more with: `search({{"text": "{topic}"}})`
96
+ - Explore more with: `search_notes({{"text": "{topic}"}})`
97
97
  - See what's changed: `recent_activity(timeframe="{timeframe or "7d"}")`
98
98
  - **Record new learnings or decisions from this conversation:** `write_note(title="[Create a meaningful title]", content="[Content with observations and relations]")`
99
99
 
@@ -10,7 +10,7 @@ from loguru import logger
10
10
  from pydantic import Field
11
11
 
12
12
  from basic_memory.mcp.server import mcp
13
- from basic_memory.mcp.tools.search import search as search_tool
13
+ from basic_memory.mcp.tools.search import search_notes as search_tool
14
14
  from basic_memory.schemas.base import TimeFrame
15
15
  from basic_memory.schemas.search import SearchQuery, SearchResponse
16
16
 
@@ -144,9 +144,9 @@ def format_search_results(
144
144
  ## Next Steps
145
145
 
146
146
  You can:
147
- - Refine your search: `search("{query} AND additional_term")`
148
- - Exclude terms: `search("{query} NOT exclude_term")`
149
- - View more results: `search("{query}", after_date=None)`
147
+ - Refine your search: `search_notes("{query} AND additional_term")`
148
+ - Exclude terms: `search_notes("{query} NOT exclude_term")`
149
+ - View more results: `search_notes("{query}", after_date=None)`
150
150
  - Check recent activity: `recent_activity()`
151
151
 
152
152
  ## Synthesize and Capture Knowledge