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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +23 -1
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +106 -0
- basic_memory/api/app.py +9 -10
- basic_memory/api/routers/__init__.py +2 -1
- basic_memory/api/routers/knowledge_router.py +31 -5
- basic_memory/api/routers/memory_router.py +18 -17
- basic_memory/api/routers/project_info_router.py +275 -0
- basic_memory/api/routers/resource_router.py +105 -4
- basic_memory/api/routers/search_router.py +22 -4
- basic_memory/cli/app.py +54 -5
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/db.py +9 -13
- basic_memory/cli/commands/import_chatgpt.py +26 -30
- basic_memory/cli/commands/import_claude_conversations.py +27 -29
- basic_memory/cli/commands/import_claude_projects.py +29 -31
- basic_memory/cli/commands/import_memory_json.py +26 -28
- basic_memory/cli/commands/mcp.py +7 -1
- basic_memory/cli/commands/project.py +119 -0
- basic_memory/cli/commands/project_info.py +167 -0
- basic_memory/cli/commands/status.py +14 -28
- basic_memory/cli/commands/sync.py +63 -22
- basic_memory/cli/commands/tool.py +253 -0
- basic_memory/cli/main.py +39 -1
- basic_memory/config.py +166 -4
- basic_memory/db.py +19 -4
- basic_memory/deps.py +10 -3
- basic_memory/file_utils.py +37 -19
- basic_memory/markdown/entity_parser.py +3 -3
- basic_memory/markdown/utils.py +5 -0
- basic_memory/mcp/async_client.py +1 -1
- basic_memory/mcp/main.py +24 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +26 -0
- basic_memory/mcp/prompts/continue_conversation.py +111 -0
- basic_memory/mcp/prompts/recent_activity.py +88 -0
- basic_memory/mcp/prompts/search.py +182 -0
- basic_memory/mcp/prompts/utils.py +155 -0
- basic_memory/mcp/server.py +2 -6
- basic_memory/mcp/tools/__init__.py +12 -21
- basic_memory/mcp/tools/build_context.py +85 -0
- basic_memory/mcp/tools/canvas.py +97 -0
- basic_memory/mcp/tools/delete_note.py +28 -0
- basic_memory/mcp/tools/project_info.py +51 -0
- basic_memory/mcp/tools/read_content.py +229 -0
- basic_memory/mcp/tools/read_note.py +190 -0
- basic_memory/mcp/tools/recent_activity.py +100 -0
- basic_memory/mcp/tools/search.py +56 -17
- basic_memory/mcp/tools/utils.py +245 -16
- basic_memory/mcp/tools/write_note.py +124 -0
- basic_memory/models/knowledge.py +27 -11
- basic_memory/models/search.py +2 -1
- basic_memory/repository/entity_repository.py +3 -2
- basic_memory/repository/project_info_repository.py +9 -0
- basic_memory/repository/repository.py +24 -7
- basic_memory/repository/search_repository.py +47 -14
- basic_memory/schemas/__init__.py +10 -9
- basic_memory/schemas/base.py +4 -1
- basic_memory/schemas/memory.py +14 -4
- basic_memory/schemas/project_info.py +96 -0
- basic_memory/schemas/search.py +29 -33
- basic_memory/services/context_service.py +3 -3
- basic_memory/services/entity_service.py +26 -13
- basic_memory/services/file_service.py +145 -26
- basic_memory/services/link_resolver.py +9 -46
- basic_memory/services/search_service.py +95 -22
- basic_memory/sync/__init__.py +3 -2
- basic_memory/sync/sync_service.py +523 -117
- basic_memory/sync/watch_service.py +258 -132
- basic_memory/utils.py +51 -36
- basic_memory-0.9.0.dist-info/METADATA +736 -0
- basic_memory-0.9.0.dist-info/RECORD +99 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/entry_points.txt +0 -0
- {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
|
|
29
|
-
from basic_memory.sync.
|
|
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.
|
|
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
|
-
|
|
125
|
-
|
|
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.
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
62
|
-
|
|
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})")
|