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

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.9.0"
3
+ __version__ = "0.10.1"
@@ -3,9 +3,6 @@
3
3
  import json
4
4
  from datetime import datetime
5
5
 
6
- from fastapi import APIRouter
7
- from sqlalchemy import text
8
-
9
6
  from basic_memory.config import config, config_manager
10
7
  from basic_memory.deps import (
11
8
  ProjectInfoRepositoryDep,
@@ -18,6 +15,8 @@ from basic_memory.schemas import (
18
15
  SystemStatus,
19
16
  )
20
17
  from basic_memory.sync.watch_service import WATCH_STATUS_JSON
18
+ from fastapi import APIRouter
19
+ from sqlalchemy import text
21
20
 
22
21
  router = APIRouter(prefix="/stats", tags=["statistics"])
23
22
 
@@ -262,7 +261,7 @@ async def get_system_status() -> SystemStatus:
262
261
  watch_status_path = config.home / ".basic-memory" / WATCH_STATUS_JSON
263
262
  if watch_status_path.exists():
264
263
  try:
265
- watch_status = json.loads(watch_status_path.read_text())
264
+ watch_status = json.loads(watch_status_path.read_text(encoding="utf-8"))
266
265
  except Exception: # pragma: no cover
267
266
  pass
268
267
 
@@ -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
  ]
@@ -7,15 +7,14 @@ from pathlib import Path
7
7
  from typing import Dict, Any, List, Annotated, Set, Optional
8
8
 
9
9
  import typer
10
- from loguru import logger
11
- from rich.console import Console
12
- from rich.panel import Panel
13
- from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
14
-
15
10
  from basic_memory.cli.app import import_app
16
11
  from basic_memory.config import config
17
12
  from basic_memory.markdown import EntityParser, MarkdownProcessor
18
13
  from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
14
+ from loguru import logger
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
19
18
 
20
19
  console = Console()
21
20
 
@@ -167,7 +166,7 @@ async def process_chatgpt_json(
167
166
  read_task = progress.add_task("Reading chat data...", total=None)
168
167
 
169
168
  # Read conversations
170
- conversations = json.loads(json_path.read_text())
169
+ conversations = json.loads(json_path.read_text(encoding="utf-8"))
171
170
  progress.update(read_task, total=len(conversations))
172
171
 
173
172
  # Process each conversation
@@ -7,15 +7,14 @@ from pathlib import Path
7
7
  from typing import Dict, Any, List, Annotated
8
8
 
9
9
  import typer
10
- from loguru import logger
11
- from rich.console import Console
12
- from rich.panel import Panel
13
- from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
14
-
15
10
  from basic_memory.cli.app import claude_app
16
11
  from basic_memory.config import config
17
12
  from basic_memory.markdown import EntityParser, MarkdownProcessor
18
13
  from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
14
+ from loguru import logger
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
19
18
 
20
19
  console = Console()
21
20
 
@@ -124,7 +123,7 @@ async def process_conversations_json(
124
123
  read_task = progress.add_task("Reading chat data...", total=None)
125
124
 
126
125
  # Read chat data - handle array of arrays format
127
- data = json.loads(json_path.read_text())
126
+ data = json.loads(json_path.read_text(encoding="utf-8"))
128
127
  conversations = [chat for chat in data]
129
128
  progress.update(read_task, total=len(conversations))
130
129
 
@@ -6,15 +6,14 @@ from pathlib import Path
6
6
  from typing import Dict, Any, Annotated, Optional
7
7
 
8
8
  import typer
9
- from loguru import logger
10
- from rich.console import Console
11
- from rich.panel import Panel
12
- from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
13
-
14
9
  from basic_memory.cli.app import claude_app
15
10
  from basic_memory.config import config
16
11
  from basic_memory.markdown import EntityParser, MarkdownProcessor
17
12
  from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
13
+ from loguru import logger
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
18
17
 
19
18
  console = Console()
20
19
 
@@ -103,7 +102,7 @@ async def process_projects_json(
103
102
  read_task = progress.add_task("Reading project data...", total=None)
104
103
 
105
104
  # Read project data
106
- data = json.loads(json_path.read_text())
105
+ data = json.loads(json_path.read_text(encoding="utf-8"))
107
106
  progress.update(read_task, total=len(data))
108
107
 
109
108
  # Track import counts
@@ -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)
@@ -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(
@@ -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,13 +5,12 @@ 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
8
10
  from loguru import logger
9
11
  from pydantic import Field, field_validator
10
12
  from pydantic_settings import BaseSettings, SettingsConfigDict
11
13
 
12
- import basic_memory
13
- from basic_memory.utils import setup_logging
14
-
15
14
  DATABASE_NAME = "memory.db"
16
15
  DATA_DIR_NAME = ".basic-memory"
17
16
  CONFIG_FILE_NAME = "config.json"
@@ -111,7 +110,7 @@ class ConfigManager:
111
110
  """Load configuration from file or create default."""
112
111
  if self.config_file.exists():
113
112
  try:
114
- data = json.loads(self.config_file.read_text())
113
+ data = json.loads(self.config_file.read_text(encoding="utf-8"))
115
114
  return BasicMemoryConfig(**data)
116
115
  except Exception as e:
117
116
  logger.error(f"Failed to load config: {e}")
@@ -85,7 +85,7 @@ async def write_file_atomic(path: FilePath, content: str) -> None:
85
85
  temp_path = path_obj.with_suffix(".tmp")
86
86
 
87
87
  try:
88
- temp_path.write_text(content)
88
+ temp_path.write_text(content, encoding="utf-8")
89
89
  temp_path.replace(path_obj)
90
90
  logger.debug("Wrote file atomically", path=str(path_obj), content_length=len(content))
91
91
  except Exception as e: # pragma: no cover
@@ -203,7 +203,7 @@ async def update_frontmatter(path: FilePath, updates: Dict[str, Any]) -> str:
203
203
  path_obj = Path(path) if isinstance(path, str) else path
204
204
 
205
205
  # Read current content
206
- content = path_obj.read_text()
206
+ content = path_obj.read_text(encoding="utf-8")
207
207
 
208
208
  # Parse current frontmatter
209
209
  current_fm = {}
@@ -215,7 +215,7 @@ async def update_frontmatter(path: FilePath, updates: Dict[str, Any]) -> str:
215
215
  new_fm = {**current_fm, **updates}
216
216
 
217
217
  # Write new file with updated frontmatter
218
- yaml_fm = yaml.dump(new_fm, sort_keys=False)
218
+ yaml_fm = yaml.dump(new_fm, sort_keys=False, allow_unicode=True)
219
219
  final_content = f"---\n{yaml_fm}---\n\n{content.strip()}"
220
220
 
221
221
  logger.debug("Updating frontmatter", path=str(path_obj), update_keys=list(updates.keys()))
@@ -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(
@@ -83,7 +83,7 @@ class MarkdownProcessor:
83
83
  """
84
84
  # Dirty check if needed
85
85
  if expected_checksum is not None:
86
- current_content = path.read_text()
86
+ current_content = path.read_text(encoding="utf-8")
87
87
  current_checksum = await file_utils.compute_checksum(current_content)
88
88
  if current_checksum != expected_checksum:
89
89
  raise DirtyFileError(f"File {path} has been modified")
@@ -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:
@@ -1,8 +1,7 @@
1
1
  from pathlib import Path
2
2
 
3
- from loguru import logger
4
-
5
3
  from basic_memory.mcp.server import mcp
4
+ from loguru import logger
6
5
 
7
6
 
8
7
  @mcp.resource(
@@ -20,7 +19,7 @@ def ai_assistant_guide() -> str:
20
19
  A focused guide on Basic Memory usage.
21
20
  """
22
21
  logger.info("Loading AI assistant guide resource")
23
- guide_doc = Path(__file__).parent.parent.parent.parent.parent / "static" / "ai_assistant_guide.md"
24
- content = guide_doc.read_text()
22
+ guide_doc = Path(__file__).parent.parent / "resources" / "ai_assistant_guide.md"
23
+ content = guide_doc.read_text(encoding="utf-8")
25
24
  logger.info(f"Loaded AI assistant guide ({len(content)} chars)")
26
25
  return content