basic-memory 0.17.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.
- basic_memory/__init__.py +7 -0
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +185 -0
- basic_memory/alembic/migrations.py +24 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/api/__init__.py +5 -0
- basic_memory/api/app.py +131 -0
- basic_memory/api/routers/__init__.py +11 -0
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +318 -0
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +90 -0
- basic_memory/api/routers/project_router.py +448 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +249 -0
- basic_memory/api/routers/search_router.py +36 -0
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +182 -0
- basic_memory/api/v2/routers/knowledge_router.py +413 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +342 -0
- basic_memory/api/v2/routers/prompt_router.py +270 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/__init__.py +1 -0
- basic_memory/cli/app.py +84 -0
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +18 -0
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +77 -0
- basic_memory/cli/commands/db.py +44 -0
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +84 -0
- basic_memory/cli/commands/import_claude_conversations.py +87 -0
- basic_memory/cli/commands/import_claude_projects.py +86 -0
- basic_memory/cli/commands/import_memory_json.py +87 -0
- basic_memory/cli/commands/mcp.py +76 -0
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +174 -0
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +28 -0
- basic_memory/config.py +616 -0
- basic_memory/db.py +394 -0
- basic_memory/deps.py +705 -0
- basic_memory/file_utils.py +478 -0
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +180 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/__init__.py +21 -0
- basic_memory/markdown/entity_parser.py +279 -0
- basic_memory/markdown/markdown_processor.py +160 -0
- basic_memory/markdown/plugins.py +242 -0
- basic_memory/markdown/schemas.py +70 -0
- basic_memory/markdown/utils.py +117 -0
- basic_memory/mcp/__init__.py +1 -0
- basic_memory/mcp/async_client.py +139 -0
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +81 -0
- basic_memory/mcp/tools/__init__.py +48 -0
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +242 -0
- basic_memory/mcp/tools/edit_note.py +324 -0
- basic_memory/mcp/tools/list_directory.py +168 -0
- basic_memory/mcp/tools/move_note.py +551 -0
- basic_memory/mcp/tools/project_management.py +201 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +267 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +385 -0
- basic_memory/mcp/tools/utils.py +540 -0
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +15 -0
- basic_memory/models/base.py +10 -0
- basic_memory/models/knowledge.py +226 -0
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +85 -0
- basic_memory/repository/__init__.py +11 -0
- basic_memory/repository/entity_repository.py +503 -0
- basic_memory/repository/observation_repository.py +73 -0
- basic_memory/repository/postgres_search_repository.py +379 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +128 -0
- basic_memory/repository/relation_repository.py +146 -0
- basic_memory/repository/repository.py +385 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +94 -0
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +439 -0
- basic_memory/schemas/__init__.py +86 -0
- basic_memory/schemas/base.py +297 -0
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/delete.py +37 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +285 -0
- basic_memory/schemas/project_info.py +212 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +112 -0
- basic_memory/schemas/response.py +229 -0
- basic_memory/schemas/search.py +117 -0
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +129 -0
- basic_memory/schemas/v2/resource.py +46 -0
- basic_memory/services/__init__.py +8 -0
- basic_memory/services/context_service.py +601 -0
- basic_memory/services/directory_service.py +308 -0
- basic_memory/services/entity_service.py +864 -0
- basic_memory/services/exceptions.py +37 -0
- basic_memory/services/file_service.py +541 -0
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +121 -0
- basic_memory/services/project_service.py +880 -0
- basic_memory/services/search_service.py +404 -0
- basic_memory/services/service.py +15 -0
- basic_memory/sync/__init__.py +6 -0
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1259 -0
- basic_memory/sync/watch_service.py +510 -0
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +468 -0
- basic_memory-0.17.1.dist-info/METADATA +617 -0
- basic_memory-0.17.1.dist-info/RECORD +171 -0
- basic_memory-0.17.1.dist-info/WHEEL +4 -0
- basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
- basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
basic_memory/cli/app.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Suppress Logfire "not configured" warning - we only use Logfire in cloud/server contexts
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
os.environ.setdefault("LOGFIRE_IGNORE_NO_CONFIG", "1")
|
|
5
|
+
|
|
6
|
+
# Remove loguru's default handler IMMEDIATELY, before any other imports.
|
|
7
|
+
# This prevents DEBUG logs from appearing on stdout during module-level
|
|
8
|
+
# initialization (e.g., template_loader.TemplateLoader() logs at DEBUG level).
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
logger.remove()
|
|
12
|
+
|
|
13
|
+
from typing import Optional # noqa: E402
|
|
14
|
+
|
|
15
|
+
import typer # noqa: E402
|
|
16
|
+
|
|
17
|
+
from basic_memory.config import ConfigManager, init_cli_logging # noqa: E402
|
|
18
|
+
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def version_callback(value: bool) -> None:
|
|
22
|
+
"""Show version and exit."""
|
|
23
|
+
if value: # pragma: no cover
|
|
24
|
+
import basic_memory
|
|
25
|
+
|
|
26
|
+
typer.echo(f"Basic Memory version: {basic_memory.__version__}")
|
|
27
|
+
raise typer.Exit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(name="basic-memory")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback()
|
|
34
|
+
def app_callback(
|
|
35
|
+
ctx: typer.Context,
|
|
36
|
+
version: Optional[bool] = typer.Option(
|
|
37
|
+
None,
|
|
38
|
+
"--version",
|
|
39
|
+
"-v",
|
|
40
|
+
help="Show version and exit.",
|
|
41
|
+
callback=version_callback,
|
|
42
|
+
is_eager=True,
|
|
43
|
+
),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Basic Memory - Local-first personal knowledge management."""
|
|
46
|
+
|
|
47
|
+
# Initialize logging for CLI (file only, no stdout)
|
|
48
|
+
init_cli_logging()
|
|
49
|
+
|
|
50
|
+
# Show telemetry notice and track CLI startup
|
|
51
|
+
# Skip for 'mcp' command - it handles its own telemetry in lifespan
|
|
52
|
+
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
|
|
53
|
+
if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
|
|
54
|
+
show_notice_if_needed()
|
|
55
|
+
track_app_started("cli")
|
|
56
|
+
|
|
57
|
+
# Run initialization for commands that don't use the API
|
|
58
|
+
# Skip for 'mcp' command - it has its own lifespan that handles initialization
|
|
59
|
+
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
|
|
60
|
+
api_commands = {"mcp", "status", "sync", "project", "tool"}
|
|
61
|
+
if (
|
|
62
|
+
not version
|
|
63
|
+
and ctx.invoked_subcommand is not None
|
|
64
|
+
and ctx.invoked_subcommand not in api_commands
|
|
65
|
+
):
|
|
66
|
+
from basic_memory.services.initialization import ensure_initialization
|
|
67
|
+
|
|
68
|
+
app_config = ConfigManager().config
|
|
69
|
+
ensure_initialization(app_config)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
## import
|
|
73
|
+
# Register sub-command groups
|
|
74
|
+
import_app = typer.Typer(help="Import data from various sources")
|
|
75
|
+
app.add_typer(import_app, name="import")
|
|
76
|
+
|
|
77
|
+
claude_app = typer.Typer(help="Import Conversations from Claude JSON export.")
|
|
78
|
+
import_app.add_typer(claude_app, name="claude")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
## cloud
|
|
82
|
+
|
|
83
|
+
cloud_app = typer.Typer(help="Access Basic Memory Cloud")
|
|
84
|
+
app.add_typer(cloud_app, name="cloud")
|
basic_memory/cli/auth.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""WorkOS OAuth Device Authorization for CLI."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from basic_memory.config import ConfigManager
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CLIAuth:
|
|
20
|
+
"""Handles WorkOS OAuth Device Authorization for CLI tools."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, client_id: str, authkit_domain: str):
|
|
23
|
+
self.client_id = client_id
|
|
24
|
+
self.authkit_domain = authkit_domain
|
|
25
|
+
app_config = ConfigManager().config
|
|
26
|
+
# Store tokens in data dir
|
|
27
|
+
self.token_file = app_config.data_dir_path / "basic-memory-cloud.json"
|
|
28
|
+
# PKCE parameters
|
|
29
|
+
self.code_verifier = None
|
|
30
|
+
self.code_challenge = None
|
|
31
|
+
|
|
32
|
+
def generate_pkce_pair(self) -> tuple[str, str]:
|
|
33
|
+
"""Generate PKCE code verifier and challenge."""
|
|
34
|
+
# Generate code verifier (43-128 characters)
|
|
35
|
+
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8")
|
|
36
|
+
code_verifier = code_verifier.rstrip("=")
|
|
37
|
+
|
|
38
|
+
# Generate code challenge (SHA256 hash of verifier)
|
|
39
|
+
challenge_bytes = hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
|
40
|
+
code_challenge = base64.urlsafe_b64encode(challenge_bytes).decode("utf-8")
|
|
41
|
+
code_challenge = code_challenge.rstrip("=")
|
|
42
|
+
|
|
43
|
+
return code_verifier, code_challenge
|
|
44
|
+
|
|
45
|
+
async def request_device_authorization(self) -> dict | None:
|
|
46
|
+
"""Request device authorization from WorkOS with PKCE."""
|
|
47
|
+
device_auth_url = f"{self.authkit_domain}/oauth2/device_authorization"
|
|
48
|
+
|
|
49
|
+
# Generate PKCE pair
|
|
50
|
+
self.code_verifier, self.code_challenge = self.generate_pkce_pair()
|
|
51
|
+
|
|
52
|
+
data = {
|
|
53
|
+
"client_id": self.client_id,
|
|
54
|
+
"scope": "openid profile email offline_access",
|
|
55
|
+
"code_challenge": self.code_challenge,
|
|
56
|
+
"code_challenge_method": "S256",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
async with httpx.AsyncClient() as client:
|
|
61
|
+
response = await client.post(device_auth_url, data=data)
|
|
62
|
+
|
|
63
|
+
if response.status_code == 200:
|
|
64
|
+
return response.json()
|
|
65
|
+
else:
|
|
66
|
+
console.print(
|
|
67
|
+
f"[red]Device authorization failed: {response.status_code} - {response.text}[/red]"
|
|
68
|
+
)
|
|
69
|
+
return None
|
|
70
|
+
except Exception as e:
|
|
71
|
+
console.print(f"[red]Device authorization error: {e}[/red]")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def display_user_instructions(self, device_response: dict) -> None:
|
|
75
|
+
"""Display user instructions for device authorization."""
|
|
76
|
+
user_code = device_response["user_code"]
|
|
77
|
+
verification_uri = device_response["verification_uri"]
|
|
78
|
+
verification_uri_complete = device_response.get("verification_uri_complete")
|
|
79
|
+
|
|
80
|
+
console.print("\n[bold blue]Authentication Required[/bold blue]")
|
|
81
|
+
console.print("\nTo authenticate, please visit:")
|
|
82
|
+
console.print(f"[bold cyan]{verification_uri}[/bold cyan]")
|
|
83
|
+
console.print(f"\nAnd enter this code: [bold yellow]{user_code}[/bold yellow]")
|
|
84
|
+
|
|
85
|
+
if verification_uri_complete:
|
|
86
|
+
console.print("\nOr for one-click access, visit:")
|
|
87
|
+
console.print(f"[bold green]{verification_uri_complete}[/bold green]")
|
|
88
|
+
|
|
89
|
+
# Try to open browser automatically
|
|
90
|
+
try:
|
|
91
|
+
console.print("\n[dim]Opening browser automatically...[/dim]")
|
|
92
|
+
webbrowser.open(verification_uri_complete)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass # Silently fail if browser can't be opened
|
|
95
|
+
|
|
96
|
+
console.print("\n[dim]Waiting for you to complete authentication in your browser...[/dim]")
|
|
97
|
+
|
|
98
|
+
async def poll_for_token(self, device_code: str, interval: int = 5) -> dict | None:
|
|
99
|
+
"""Poll the token endpoint until user completes authentication."""
|
|
100
|
+
token_url = f"{self.authkit_domain}/oauth2/token"
|
|
101
|
+
|
|
102
|
+
data = {
|
|
103
|
+
"client_id": self.client_id,
|
|
104
|
+
"device_code": device_code,
|
|
105
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
106
|
+
"code_verifier": self.code_verifier,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
max_attempts = 60 # 5 minutes with 5-second intervals
|
|
110
|
+
current_interval = interval
|
|
111
|
+
|
|
112
|
+
for _attempt in range(max_attempts):
|
|
113
|
+
try:
|
|
114
|
+
async with httpx.AsyncClient() as client:
|
|
115
|
+
response = await client.post(token_url, data=data)
|
|
116
|
+
|
|
117
|
+
if response.status_code == 200:
|
|
118
|
+
return response.json()
|
|
119
|
+
|
|
120
|
+
# Parse error response
|
|
121
|
+
try:
|
|
122
|
+
error_data = response.json()
|
|
123
|
+
error = error_data.get("error")
|
|
124
|
+
except Exception:
|
|
125
|
+
error = "unknown_error"
|
|
126
|
+
|
|
127
|
+
if error == "authorization_pending":
|
|
128
|
+
# User hasn't completed auth yet, keep polling
|
|
129
|
+
pass
|
|
130
|
+
elif error == "slow_down":
|
|
131
|
+
# Increase polling interval
|
|
132
|
+
current_interval += 5
|
|
133
|
+
console.print("[yellow]Slowing down polling rate...[/yellow]")
|
|
134
|
+
elif error == "access_denied":
|
|
135
|
+
console.print("[red]Authentication was denied by user[/red]")
|
|
136
|
+
return None
|
|
137
|
+
elif error == "expired_token":
|
|
138
|
+
console.print("[red]Device code has expired. Please try again.[/red]")
|
|
139
|
+
return None
|
|
140
|
+
else:
|
|
141
|
+
console.print(f"[red]Token polling error: {error}[/red]")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
console.print(f"[red]Token polling request error: {e}[/red]")
|
|
146
|
+
|
|
147
|
+
# Wait before next poll
|
|
148
|
+
await self._async_sleep(current_interval)
|
|
149
|
+
|
|
150
|
+
console.print("[red]Authentication timeout. Please try again.[/red]")
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
async def _async_sleep(self, seconds: int) -> None:
|
|
154
|
+
"""Async sleep utility."""
|
|
155
|
+
import asyncio
|
|
156
|
+
|
|
157
|
+
await asyncio.sleep(seconds)
|
|
158
|
+
|
|
159
|
+
def save_tokens(self, tokens: dict) -> None:
|
|
160
|
+
"""Save tokens to project root as .bm-auth.json."""
|
|
161
|
+
token_data = {
|
|
162
|
+
"access_token": tokens["access_token"],
|
|
163
|
+
"refresh_token": tokens.get("refresh_token"),
|
|
164
|
+
"expires_at": int(time.time()) + tokens.get("expires_in", 3600),
|
|
165
|
+
"token_type": tokens.get("token_type", "Bearer"),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
with open(self.token_file, "w") as f:
|
|
169
|
+
json.dump(token_data, f, indent=2)
|
|
170
|
+
|
|
171
|
+
# Secure the token file
|
|
172
|
+
os.chmod(self.token_file, 0o600)
|
|
173
|
+
|
|
174
|
+
console.print(f"[green]Tokens saved to {self.token_file}[/green]")
|
|
175
|
+
|
|
176
|
+
def load_tokens(self) -> dict | None:
|
|
177
|
+
"""Load tokens from .bm-auth.json file."""
|
|
178
|
+
if not self.token_file.exists():
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
with open(self.token_file) as f:
|
|
183
|
+
return json.load(f)
|
|
184
|
+
except (OSError, json.JSONDecodeError):
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def is_token_valid(self, tokens: dict) -> bool:
|
|
188
|
+
"""Check if stored token is still valid."""
|
|
189
|
+
expires_at = tokens.get("expires_at", 0)
|
|
190
|
+
# Add 60 second buffer for clock skew
|
|
191
|
+
return time.time() < (expires_at - 60)
|
|
192
|
+
|
|
193
|
+
async def refresh_token(self, refresh_token: str) -> dict | None:
|
|
194
|
+
"""Refresh access token using refresh token."""
|
|
195
|
+
token_url = f"{self.authkit_domain}/oauth2/token"
|
|
196
|
+
|
|
197
|
+
data = {
|
|
198
|
+
"client_id": self.client_id,
|
|
199
|
+
"grant_type": "refresh_token",
|
|
200
|
+
"refresh_token": refresh_token,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
async with httpx.AsyncClient() as client:
|
|
205
|
+
response = await client.post(token_url, data=data)
|
|
206
|
+
|
|
207
|
+
if response.status_code == 200:
|
|
208
|
+
return response.json()
|
|
209
|
+
else:
|
|
210
|
+
console.print(
|
|
211
|
+
f"[red]Token refresh failed: {response.status_code} - {response.text}[/red]"
|
|
212
|
+
)
|
|
213
|
+
return None
|
|
214
|
+
except Exception as e:
|
|
215
|
+
console.print(f"[red]Token refresh error: {e}[/red]")
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
async def get_valid_token(self) -> str | None:
|
|
219
|
+
"""Get valid access token, refresh if needed."""
|
|
220
|
+
tokens = self.load_tokens()
|
|
221
|
+
if not tokens:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
if self.is_token_valid(tokens):
|
|
225
|
+
return tokens["access_token"]
|
|
226
|
+
|
|
227
|
+
# Token expired - try to refresh if we have a refresh token
|
|
228
|
+
refresh_token = tokens.get("refresh_token")
|
|
229
|
+
if refresh_token:
|
|
230
|
+
console.print("[yellow]Access token expired, refreshing...[/yellow]")
|
|
231
|
+
|
|
232
|
+
new_tokens = await self.refresh_token(refresh_token)
|
|
233
|
+
if new_tokens:
|
|
234
|
+
# Save new tokens (may include rotated refresh token)
|
|
235
|
+
self.save_tokens(new_tokens)
|
|
236
|
+
console.print("[green]Token refreshed successfully[/green]")
|
|
237
|
+
return new_tokens["access_token"]
|
|
238
|
+
else:
|
|
239
|
+
console.print("[yellow]Token refresh failed. Please run 'login' again.[/yellow]")
|
|
240
|
+
return None
|
|
241
|
+
else:
|
|
242
|
+
console.print("[yellow]No refresh token available. Please run 'login' again.[/yellow]")
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
async def login(self) -> bool:
|
|
246
|
+
"""Perform OAuth Device Authorization login flow."""
|
|
247
|
+
console.print("[blue]Initiating authentication...[/blue]")
|
|
248
|
+
|
|
249
|
+
# Step 1: Request device authorization
|
|
250
|
+
device_response = await self.request_device_authorization()
|
|
251
|
+
if not device_response:
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
# Step 2: Display user instructions
|
|
255
|
+
self.display_user_instructions(device_response)
|
|
256
|
+
|
|
257
|
+
# Step 3: Poll for token
|
|
258
|
+
device_code = device_response["device_code"]
|
|
259
|
+
interval = device_response.get("interval", 5)
|
|
260
|
+
|
|
261
|
+
tokens = await self.poll_for_token(device_code, interval)
|
|
262
|
+
if not tokens:
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
# Step 4: Save tokens
|
|
266
|
+
self.save_tokens(tokens)
|
|
267
|
+
|
|
268
|
+
console.print("\n[green]Successfully authenticated with Basic Memory Cloud![/green]")
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
def logout(self) -> None:
|
|
272
|
+
"""Remove stored authentication tokens."""
|
|
273
|
+
if self.token_file.exists():
|
|
274
|
+
self.token_file.unlink()
|
|
275
|
+
console.print("[green]Logged out successfully[/green]")
|
|
276
|
+
else:
|
|
277
|
+
console.print("[yellow]No stored authentication found[/yellow]")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""CLI commands for basic-memory."""
|
|
2
|
+
|
|
3
|
+
from . import status, db, import_memory_json, mcp, import_claude_conversations
|
|
4
|
+
from . import import_claude_projects, import_chatgpt, tool, project, format, telemetry
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"status",
|
|
8
|
+
"db",
|
|
9
|
+
"import_memory_json",
|
|
10
|
+
"mcp",
|
|
11
|
+
"import_claude_conversations",
|
|
12
|
+
"import_claude_projects",
|
|
13
|
+
"import_chatgpt",
|
|
14
|
+
"tool",
|
|
15
|
+
"project",
|
|
16
|
+
"format",
|
|
17
|
+
"telemetry",
|
|
18
|
+
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Cloud commands package."""
|
|
2
|
+
|
|
3
|
+
# Import all commands to register them with typer
|
|
4
|
+
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
|
|
5
|
+
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
|
|
6
|
+
from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Cloud API client utilities."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from basic_memory.cli.auth import CLIAuth
|
|
10
|
+
from basic_memory.config import ConfigManager
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CloudAPIError(Exception):
|
|
16
|
+
"""Exception raised for cloud API errors."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, message: str, status_code: Optional[int] = None, detail: Optional[dict] = None
|
|
20
|
+
):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.detail = detail or {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SubscriptionRequiredError(CloudAPIError):
|
|
27
|
+
"""Exception raised when user needs an active subscription."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, subscribe_url: str):
|
|
30
|
+
super().__init__(message, status_code=403, detail={"error": "subscription_required"})
|
|
31
|
+
self.subscribe_url = subscribe_url
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_cloud_config() -> tuple[str, str, str]:
|
|
35
|
+
"""Get cloud OAuth configuration from config."""
|
|
36
|
+
config_manager = ConfigManager()
|
|
37
|
+
config = config_manager.config
|
|
38
|
+
return config.cloud_client_id, config.cloud_domain, config.cloud_host
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def get_authenticated_headers() -> dict[str, str]:
|
|
42
|
+
"""
|
|
43
|
+
Get authentication headers with JWT token.
|
|
44
|
+
handles jwt refresh if needed.
|
|
45
|
+
"""
|
|
46
|
+
client_id, domain, _ = get_cloud_config()
|
|
47
|
+
auth = CLIAuth(client_id=client_id, authkit_domain=domain)
|
|
48
|
+
token = await auth.get_valid_token()
|
|
49
|
+
if not token:
|
|
50
|
+
console.print("[red]Not authenticated. Please run 'basic-memory cloud login' first.[/red]")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
return {"Authorization": f"Bearer {token}"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def make_api_request(
|
|
57
|
+
method: str,
|
|
58
|
+
url: str,
|
|
59
|
+
headers: Optional[dict] = None,
|
|
60
|
+
json_data: Optional[dict] = None,
|
|
61
|
+
timeout: float = 30.0,
|
|
62
|
+
) -> httpx.Response:
|
|
63
|
+
"""Make an API request to the cloud service."""
|
|
64
|
+
headers = headers or {}
|
|
65
|
+
auth_headers = await get_authenticated_headers()
|
|
66
|
+
headers.update(auth_headers)
|
|
67
|
+
# Add debug headers to help with compression issues
|
|
68
|
+
headers.setdefault("Accept-Encoding", "identity") # Disable compression for debugging
|
|
69
|
+
|
|
70
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
71
|
+
try:
|
|
72
|
+
response = await client.request(method=method, url=url, headers=headers, json=json_data)
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
return response
|
|
75
|
+
except httpx.HTTPError as e:
|
|
76
|
+
# Check if this is a response error with response details
|
|
77
|
+
if hasattr(e, "response") and e.response is not None: # pyright: ignore [reportAttributeAccessIssue]
|
|
78
|
+
response = e.response # type: ignore
|
|
79
|
+
|
|
80
|
+
# Try to parse error detail from response
|
|
81
|
+
error_detail = None
|
|
82
|
+
try:
|
|
83
|
+
error_detail = response.json()
|
|
84
|
+
except Exception:
|
|
85
|
+
# If JSON parsing fails, we'll handle it as a generic error
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
# Check for subscription_required error (403)
|
|
89
|
+
if response.status_code == 403 and isinstance(error_detail, dict):
|
|
90
|
+
# Handle both FastAPI HTTPException format (nested under "detail")
|
|
91
|
+
# and direct format
|
|
92
|
+
detail_obj = error_detail.get("detail", error_detail)
|
|
93
|
+
if (
|
|
94
|
+
isinstance(detail_obj, dict)
|
|
95
|
+
and detail_obj.get("error") == "subscription_required"
|
|
96
|
+
):
|
|
97
|
+
message = detail_obj.get("message", "Active subscription required")
|
|
98
|
+
subscribe_url = detail_obj.get(
|
|
99
|
+
"subscribe_url", "https://basicmemory.com/subscribe"
|
|
100
|
+
)
|
|
101
|
+
raise SubscriptionRequiredError(
|
|
102
|
+
message=message, subscribe_url=subscribe_url
|
|
103
|
+
) from e
|
|
104
|
+
|
|
105
|
+
# Raise generic CloudAPIError with status code and detail
|
|
106
|
+
raise CloudAPIError(
|
|
107
|
+
f"API request failed: {e}",
|
|
108
|
+
status_code=response.status_code,
|
|
109
|
+
detail=error_detail if isinstance(error_detail, dict) else {},
|
|
110
|
+
) from e
|
|
111
|
+
|
|
112
|
+
raise CloudAPIError(f"API request failed: {e}") from e
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Cloud bisync utility functions for Basic Memory CLI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from basic_memory.cli.commands.cloud.api_client import make_api_request
|
|
6
|
+
from basic_memory.config import ConfigManager
|
|
7
|
+
from basic_memory.ignore_utils import create_default_bmignore, get_bmignore_path
|
|
8
|
+
from basic_memory.schemas.cloud import MountCredentials, TenantMountInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BisyncError(Exception):
|
|
12
|
+
"""Exception raised for bisync-related errors."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def get_mount_info() -> TenantMountInfo:
|
|
18
|
+
"""Get current tenant information from cloud API."""
|
|
19
|
+
try:
|
|
20
|
+
config_manager = ConfigManager()
|
|
21
|
+
config = config_manager.config
|
|
22
|
+
host_url = config.cloud_host.rstrip("/")
|
|
23
|
+
|
|
24
|
+
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
|
|
25
|
+
|
|
26
|
+
return TenantMountInfo.model_validate(response.json())
|
|
27
|
+
except Exception as e:
|
|
28
|
+
raise BisyncError(f"Failed to get tenant info: {e}") from e
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
|
|
32
|
+
"""Generate scoped credentials for syncing."""
|
|
33
|
+
try:
|
|
34
|
+
config_manager = ConfigManager()
|
|
35
|
+
config = config_manager.config
|
|
36
|
+
host_url = config.cloud_host.rstrip("/")
|
|
37
|
+
|
|
38
|
+
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
|
|
39
|
+
|
|
40
|
+
return MountCredentials.model_validate(response.json())
|
|
41
|
+
except Exception as e:
|
|
42
|
+
raise BisyncError(f"Failed to generate credentials: {e}") from e
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def convert_bmignore_to_rclone_filters() -> Path:
|
|
46
|
+
"""Convert .bmignore patterns to rclone filter format.
|
|
47
|
+
|
|
48
|
+
Reads ~/.basic-memory/.bmignore (gitignore-style) and converts to
|
|
49
|
+
~/.basic-memory/.bmignore.rclone (rclone filter format).
|
|
50
|
+
|
|
51
|
+
Only regenerates if .bmignore has been modified since last conversion.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Path to converted rclone filter file
|
|
55
|
+
"""
|
|
56
|
+
# Ensure .bmignore exists
|
|
57
|
+
create_default_bmignore()
|
|
58
|
+
|
|
59
|
+
bmignore_path = get_bmignore_path()
|
|
60
|
+
# Create rclone filter path: ~/.basic-memory/.bmignore -> ~/.basic-memory/.bmignore.rclone
|
|
61
|
+
rclone_filter_path = bmignore_path.parent / f"{bmignore_path.name}.rclone"
|
|
62
|
+
|
|
63
|
+
# Skip regeneration if rclone file is newer than bmignore
|
|
64
|
+
if rclone_filter_path.exists():
|
|
65
|
+
bmignore_mtime = bmignore_path.stat().st_mtime
|
|
66
|
+
rclone_mtime = rclone_filter_path.stat().st_mtime
|
|
67
|
+
if rclone_mtime >= bmignore_mtime:
|
|
68
|
+
return rclone_filter_path
|
|
69
|
+
|
|
70
|
+
# Read .bmignore patterns
|
|
71
|
+
patterns = []
|
|
72
|
+
try:
|
|
73
|
+
with bmignore_path.open("r", encoding="utf-8") as f:
|
|
74
|
+
for line in f:
|
|
75
|
+
line = line.strip()
|
|
76
|
+
# Keep comments and empty lines
|
|
77
|
+
if not line or line.startswith("#"):
|
|
78
|
+
patterns.append(line)
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# Convert gitignore pattern to rclone filter syntax
|
|
82
|
+
# gitignore: node_modules → rclone: - node_modules/**
|
|
83
|
+
# gitignore: *.pyc → rclone: - *.pyc
|
|
84
|
+
if "*" in line:
|
|
85
|
+
# Pattern already has wildcard, just add exclude prefix
|
|
86
|
+
patterns.append(f"- {line}")
|
|
87
|
+
else:
|
|
88
|
+
# Directory pattern - add /** for recursive exclude
|
|
89
|
+
patterns.append(f"- {line}/**")
|
|
90
|
+
|
|
91
|
+
except Exception:
|
|
92
|
+
# If we can't read the file, create a minimal filter
|
|
93
|
+
patterns = ["# Error reading .bmignore, using minimal filters", "- .git/**"]
|
|
94
|
+
|
|
95
|
+
# Write rclone filter file
|
|
96
|
+
rclone_filter_path.write_text("\n".join(patterns) + "\n")
|
|
97
|
+
|
|
98
|
+
return rclone_filter_path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_bisync_filter_path() -> Path:
|
|
102
|
+
"""Get path to bisync filter file.
|
|
103
|
+
|
|
104
|
+
Uses ~/.basic-memory/.bmignore (converted to rclone format).
|
|
105
|
+
The file is automatically created with default patterns on first use.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Path to rclone filter file
|
|
109
|
+
"""
|
|
110
|
+
return convert_bmignore_to_rclone_filters()
|