mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c13d4b1511 | |||
| 5b85d33a99 | |||
| e2bd3142dc | |||
| 40b7de9168 | |||
| 455a280707 | |||
| 634486107b | |||
| 23d34289fd | |||
| 5ec4087f7c | |||
| 71941c35dd | |||
| 020957cd76 | |||
| b3af6aa685 | |||
| 9437a5f83b | |||
| 3c1cc346df | |||
| 81616ab42e | |||
| 321471f29d | |||
| 73ea91fe0d | |||
| 03d4e97b90 | |||
| 98622a7a47 | |||
| 54dfa08aba | |||
| 9433065a57 | |||
| 2934176331 |
@@ -0,0 +1,55 @@
|
||||
# OAuth Configuration for Basic Memory MCP Server
|
||||
# Copy this file to .env and update the values
|
||||
|
||||
# Enable OAuth authentication
|
||||
FASTMCP_AUTH_ENABLED=true
|
||||
|
||||
# OAuth provider type: basic, github, google, or supabase
|
||||
# - basic: Built-in OAuth provider with in-memory storage
|
||||
# - github: Integrate with GitHub OAuth
|
||||
# - google: Integrate with Google OAuth
|
||||
# - supabase: Integrate with Supabase Auth (recommended for production)
|
||||
FASTMCP_AUTH_PROVIDER=basic
|
||||
|
||||
# OAuth issuer URL (your MCP server URL)
|
||||
FASTMCP_AUTH_ISSUER_URL=http://localhost:8000
|
||||
|
||||
# Documentation URL for OAuth endpoints
|
||||
FASTMCP_AUTH_DOCS_URL=http://localhost:8000/docs/oauth
|
||||
|
||||
# Required scopes (comma-separated)
|
||||
# Examples: read,write,admin
|
||||
FASTMCP_AUTH_REQUIRED_SCOPES=read,write
|
||||
|
||||
# Secret key for JWT tokens (auto-generated if not set)
|
||||
# FASTMCP_AUTH_SECRET_KEY=your-secret-key-here
|
||||
|
||||
# Enable client registration endpoint
|
||||
FASTMCP_AUTH_CLIENT_REGISTRATION_ENABLED=true
|
||||
|
||||
# Enable token revocation endpoint
|
||||
FASTMCP_AUTH_REVOCATION_ENABLED=true
|
||||
|
||||
# Default scopes for new clients
|
||||
FASTMCP_AUTH_DEFAULT_SCOPES=read
|
||||
|
||||
# Valid scopes that can be requested
|
||||
FASTMCP_AUTH_VALID_SCOPES=read,write,admin
|
||||
|
||||
# Client secret expiry in seconds (optional)
|
||||
# FASTMCP_AUTH_CLIENT_SECRET_EXPIRY=86400
|
||||
|
||||
# GitHub OAuth settings (if using github provider)
|
||||
# GITHUB_CLIENT_ID=your-github-client-id
|
||||
# GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
|
||||
# Google OAuth settings (if using google provider)
|
||||
# GOOGLE_CLIENT_ID=your-google-client-id
|
||||
# GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
|
||||
# Supabase settings (if using supabase provider)
|
||||
# SUPABASE_URL=https://your-project.supabase.co
|
||||
# SUPABASE_ANON_KEY=your-anon-key
|
||||
# SUPABASE_SERVICE_KEY=your-service-key # Optional, for admin operations
|
||||
# SUPABASE_JWT_SECRET=your-jwt-secret # Optional, for token validation
|
||||
# SUPABASE_ALLOWED_CLIENTS=client1,client2 # Comma-separated list of allowed client IDs
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Claude Code Integration
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [ created ]
|
||||
pull_request_review_comment:
|
||||
types: [ created ]
|
||||
|
||||
jobs:
|
||||
claude-integration:
|
||||
uses: basicmachines-co/claude-code-github-action/.github/workflows/claude-full.yml@v0.11.0
|
||||
with:
|
||||
issue-label: 'claude-fix' # Optional: customize the trigger label
|
||||
secrets:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLAUDE_TOKEN }}
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check organization membership
|
||||
id: check_membership
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
let actor;
|
||||
if (context.eventName === 'issue_comment') {
|
||||
actor = context.payload.comment.user.login;
|
||||
} else if (context.eventName === 'pull_request_review_comment') {
|
||||
actor = context.payload.comment.user.login;
|
||||
} else if (context.eventName === 'pull_request_review') {
|
||||
actor = context.payload.review.user.login;
|
||||
} else if (context.eventName === 'issues') {
|
||||
actor = context.payload.issue.user.login;
|
||||
}
|
||||
|
||||
console.log(`Checking membership for user: ${actor}`);
|
||||
|
||||
try {
|
||||
const membership = await github.rest.orgs.getMembershipForUser({
|
||||
org: 'basicmachines-co',
|
||||
username: actor
|
||||
});
|
||||
|
||||
console.log(`Membership status: ${membership.data.state}`);
|
||||
|
||||
// Allow if user is a member (public or private) or admin
|
||||
const allowed = membership.data.state === 'active' &&
|
||||
(membership.data.role === 'member' || membership.data.role === 'admin');
|
||||
|
||||
core.setOutput('is_member', allowed);
|
||||
|
||||
if (!allowed) {
|
||||
core.notice(`User ${actor} is not a member of basicmachines-co organization`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error checking membership: ${error.message}`);
|
||||
core.setOutput('is_member', false);
|
||||
core.notice(`User ${actor} is not a member of basicmachines-co organization`);
|
||||
}
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.check_membership.outputs.is_member == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
if: steps.check_membership.outputs.is_member == 'true'
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_tools: Bash(uv run pytest),Bash(uv run ruff check . --fix),Bash(uv run ruff format .),Bash(uv run pyright),Bash(make test),Bash(make lint),Bash(make format),Bash(make type-check),Bash(make check),Read,Write,Edit,MultiEdit,Glob,Grep,LS
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Dev Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
dev-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
uv venv
|
||||
uv sync
|
||||
uv build
|
||||
|
||||
- name: Check if this is a dev version
|
||||
id: check_version
|
||||
run: |
|
||||
VERSION=$(uv run python -c "import basic_memory; print(basic_memory.__version__)")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
if [[ "$VERSION" == *"dev"* ]]; then
|
||||
echo "is_dev=true" >> $GITHUB_OUTPUT
|
||||
echo "Dev version detected: $VERSION"
|
||||
else
|
||||
echo "is_dev=false" >> $GITHUB_OUTPUT
|
||||
echo "Release version detected: $VERSION, skipping dev release"
|
||||
fi
|
||||
|
||||
- name: Publish dev version to PyPI
|
||||
if: steps.check_version.outputs.is_dev == 'true'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
skip-existing: true # Don't fail if version already exists
|
||||
@@ -1,96 +1,60 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: 'Type of version bump (major, minor, patch)'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
push:
|
||||
tags:
|
||||
- 'v*' # Trigger on version tags like v1.0.0, v0.13.0, etc.
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: release
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
outputs:
|
||||
released: ${{ steps.release.outputs.released }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Python Semantic Release
|
||||
id: release
|
||||
uses: python-semantic-release/python-semantic-release@master
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
if: steps.release.outputs.released == 'true'
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
|
||||
- name: Publish to GitHub Release Assets
|
||||
uses: python-semantic-release/publish-action@v9.8.9
|
||||
if: steps.release.outputs.released == 'true'
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
|
||||
build-macos:
|
||||
needs: release
|
||||
if: needs.release.outputs.released == 'true'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.release.outputs.tag }}
|
||||
|
||||
- name: Set up Python "3.12"
|
||||
uses: actions/setup-python@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install librsvg
|
||||
run: brew install librsvg
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Create virtual env
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync
|
||||
uv build
|
||||
|
||||
- name: Build macOS installer
|
||||
- name: Verify version matches tag
|
||||
run: |
|
||||
make installer-mac
|
||||
xattr -dr com.apple.quarantine "installer/build/Basic Memory Installer.app"
|
||||
# Get version from built package
|
||||
PACKAGE_VERSION=$(uv run python -c "import basic_memory; print(basic_memory.__version__)")
|
||||
TAG_VERSION=${GITHUB_REF_NAME#v} # Remove 'v' prefix from tag
|
||||
echo "Package version: $PACKAGE_VERSION"
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "Version mismatch! Package: $PACKAGE_VERSION, Tag: $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Zip macOS installer
|
||||
run: |
|
||||
cd installer/build
|
||||
zip -ry "Basic-Memory-Installer-${{ needs.release.outputs.tag }}.zip" "Basic Memory Installer.app"
|
||||
|
||||
- name: Upload macOS installer
|
||||
uses: softprops/action-gh-release@v1
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: installer/build/Basic-Memory-Installer-${{ needs.release.outputs.tag }}.zip
|
||||
tag_name: ${{ needs.release.outputs.tag }}
|
||||
files: |
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
generate_release_notes: true
|
||||
tag_name: ${{ github.ref_name }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
+2
-1
@@ -51,4 +51,5 @@ ENV/
|
||||
|
||||
|
||||
# claude action
|
||||
claude-output
|
||||
claude-output
|
||||
**/.claude/settings.local.json
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"basic-memory": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/Users/phernandez/dev/basicmachines/basic-memory",
|
||||
"run",
|
||||
"src/basic_memory/cli/main.py",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# OAuth Quick Start
|
||||
|
||||
Basic Memory supports OAuth authentication for secure access control. For detailed documentation, see [OAuth Authentication Guide](docs/OAuth%20Authentication%20Guide.md).
|
||||
|
||||
## Quick Test with MCP Inspector
|
||||
|
||||
```bash
|
||||
# 1. Set a consistent secret key
|
||||
export FASTMCP_AUTH_SECRET_KEY="test-secret-key"
|
||||
|
||||
# 2. Start server with OAuth
|
||||
FASTMCP_AUTH_ENABLED=true basic-memory mcp --transport streamable-http
|
||||
|
||||
# 3. In another terminal, get a test token
|
||||
export FASTMCP_AUTH_SECRET_KEY="test-secret-key" # Same key!
|
||||
basic-memory auth test-auth
|
||||
|
||||
# 4. Copy the access token and use in MCP Inspector:
|
||||
# - Server URL: http://localhost:8000/mcp
|
||||
# - Transport: streamable-http
|
||||
# - Custom Headers:
|
||||
# Authorization: Bearer YOUR_ACCESS_TOKEN
|
||||
# Accept: application/json, text/event-stream
|
||||
```
|
||||
|
||||
## OAuth Endpoints
|
||||
|
||||
- `GET /authorize` - Authorization endpoint
|
||||
- `POST /token` - Token exchange endpoint
|
||||
- `GET /.well-known/oauth-authorization-server` - OAuth metadata
|
||||
|
||||
## Common Issues
|
||||
|
||||
1. **401 Unauthorized**: Make sure you're using the same secret key for both server and client
|
||||
2. **404 Not Found**: Use `/authorize` not `/auth/authorize`
|
||||
3. **Token Invalid**: Tokens don't persist across server restarts with basic provider
|
||||
|
||||
## Documentation
|
||||
|
||||
- [OAuth Authentication Guide](docs/OAuth%20Authentication%20Guide.md) - Complete setup guide
|
||||
- [Supabase OAuth Setup](docs/Supabase%20OAuth%20Setup.md) - Production deployment
|
||||
- [External OAuth Providers](docs/External%20OAuth%20Providers.md) - GitHub, Google integration
|
||||
+102
@@ -1,6 +1,108 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v0.13.0 (2025-06-03)
|
||||
|
||||
### Features
|
||||
|
||||
- **Multi-Project Management System** - Switch between projects instantly during conversations
|
||||
([`993e88a`](https://github.com/basicmachines-co/basic-memory/commit/993e88a))
|
||||
- Instant project switching with session context
|
||||
- Project-specific operations and isolation
|
||||
- Project discovery and management tools
|
||||
|
||||
- **Advanced Note Editing** - Incremental editing with append, prepend, find/replace, and section operations
|
||||
([`6fc3904`](https://github.com/basicmachines-co/basic-memory/commit/6fc3904))
|
||||
- `edit_note` tool with multiple operation types
|
||||
- Smart frontmatter-aware editing
|
||||
- Validation and error handling
|
||||
|
||||
- **Smart File Management** - Move notes with database consistency and search reindexing
|
||||
([`9fb931c`](https://github.com/basicmachines-co/basic-memory/commit/9fb931c))
|
||||
- `move_note` tool with rollback protection
|
||||
- Automatic folder creation and permalink updates
|
||||
- Full database consistency maintenance
|
||||
|
||||
- **Enhanced Search Capabilities** - Frontmatter tags now searchable, improved content discovery
|
||||
([`3f5368e`](https://github.com/basicmachines-co/basic-memory/commit/3f5368e))
|
||||
- YAML frontmatter tag indexing
|
||||
- Improved FTS5 search functionality
|
||||
- Project-scoped search operations
|
||||
|
||||
- **Production Features** - OAuth authentication, development builds, comprehensive testing
|
||||
([`5f8d945`](https://github.com/basicmachines-co/basic-memory/commit/5f8d945))
|
||||
- Development build automation
|
||||
- MCP integration testing framework
|
||||
- Enhanced CI/CD pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#118**: Fix YAML tag formatting to follow standard specification
|
||||
([`2dc7e27`](https://github.com/basicmachines-co/basic-memory/commit/2dc7e27))
|
||||
|
||||
- **#110**: Make --project flag work consistently across CLI commands
|
||||
([`02dd91a`](https://github.com/basicmachines-co/basic-memory/commit/02dd91a))
|
||||
|
||||
- **#93**: Respect custom permalinks in frontmatter for write_note
|
||||
([`6b6fd76`](https://github.com/basicmachines-co/basic-memory/commit/6b6fd76))
|
||||
|
||||
- Fix list_directory path display to not include leading slash
|
||||
([`6057126`](https://github.com/basicmachines-co/basic-memory/commit/6057126))
|
||||
|
||||
### Technical Improvements
|
||||
|
||||
- **Unified Database Architecture** - Single app-level database for better performance
|
||||
- Migration from per-project databases to unified structure
|
||||
- Project isolation with foreign key relationships
|
||||
- Optimized queries and reduced file I/O
|
||||
|
||||
- **Comprehensive Testing** - 100% test coverage with integration testing
|
||||
([`468a22f`](https://github.com/basicmachines-co/basic-memory/commit/468a22f))
|
||||
- MCP integration test suite
|
||||
- End-to-end testing framework
|
||||
- Performance and edge case validation
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add comprehensive testing documentation (TESTING.md)
|
||||
- Update project management guides (PROJECT_MANAGEMENT.md)
|
||||
- Enhanced note editing documentation (EDIT_NOTE.md)
|
||||
- Updated release workflow documentation
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Database Migration**: Automatic migration from per-project to unified database.
|
||||
Data will be re-index from the filesystem, resulting in no data loss.
|
||||
- **Configuration Changes**: Projects now synced between config.json and database
|
||||
- **Full Backward Compatibility**: All existing setups continue to work seamlessly
|
||||
|
||||
|
||||
## v0.12.3 (2025-04-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add extra logic for permalink generation with mixed Latin unicode and Chinese characters
|
||||
([`73ea91f`](https://github.com/basicmachines-co/basic-memory/commit/73ea91fe0d1f7ab89b99a1b691d59fe608b7fcbb))
|
||||
|
||||
Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
|
||||
- Modify recent_activity args to be strings instead of enums
|
||||
([`3c1cc34`](https://github.com/basicmachines-co/basic-memory/commit/3c1cc346df519e703fae6412d43a92c7232c6226))
|
||||
|
||||
Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
|
||||
|
||||
## v0.12.2 (2025-04-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Utf8 for all file reads/write/open instead of default platform encoding
|
||||
([#91](https://github.com/basicmachines-co/basic-memory/pull/91),
|
||||
[`2934176`](https://github.com/basicmachines-co/basic-memory/commit/29341763318408ea8f1e954a41046c4185f836c6))
|
||||
|
||||
Signed-off-by: phernandez <paul@basicmachines.co>
|
||||
|
||||
|
||||
## v0.12.1 (2025-04-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -65,6 +65,7 @@ See the [README.md](README.md) file for a project overview.
|
||||
- Test database uses in-memory SQLite
|
||||
- Avoid creating mocks in tests in most circumstances.
|
||||
- Each test runs in a standalone environment with in memory SQLite and tmp_file directory
|
||||
- Do not use mocks in tests if possible. Tests run with an in memory sqlite db, so they are not needed. See fixtures in conftest.py
|
||||
|
||||
## BASIC MEMORY PRODUCT USAGE
|
||||
|
||||
@@ -172,4 +173,52 @@ With GitHub integration, the development workflow includes:
|
||||
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
|
||||
|
||||
With this integration, the AI assistant is a full-fledged team member rather than just a tool for generating code
|
||||
snippets.
|
||||
snippets.
|
||||
|
||||
|
||||
### Basic Memory Pro
|
||||
|
||||
Basic Memory Pro is a desktop GUI application that wraps the basic-memory CLI/MCP tools:
|
||||
|
||||
- Built with Tauri (Rust), React (TypeScript), and a Python FastAPI sidecar
|
||||
- Provides visual knowledge graph exploration and project management
|
||||
- Uses the same core codebase but adds a desktop-friendly interface
|
||||
- Project configuration is shared between CLI and Pro versions
|
||||
- Multiple project support with visual switching interface
|
||||
|
||||
local repo: /Users/phernandez/dev/basicmachines/basic-memory-pro
|
||||
github: https://github.com/basicmachines-co/basic-memory-pro
|
||||
|
||||
## Release and Version Management
|
||||
|
||||
Basic Memory uses `uv-dynamic-versioning` for automatic version management based on git tags:
|
||||
|
||||
### Version Types
|
||||
- **Development versions**: Automatically generated from commits (e.g., `0.12.4.dev26+468a22f`)
|
||||
- **Beta releases**: Created by tagging with beta suffixes (e.g., `v0.13.0b1`, `v0.13.0rc1`)
|
||||
- **Stable releases**: Created by tagging with version numbers (e.g., `v0.13.0`)
|
||||
|
||||
### Release Workflows
|
||||
|
||||
#### Development Builds (Automatic)
|
||||
- Triggered on every push to `main` branch
|
||||
- Publishes dev versions like `0.12.4.dev26+468a22f` to PyPI
|
||||
- Allows continuous testing of latest changes
|
||||
- Users install with: `pip install basic-memory --pre --force-reinstall`
|
||||
|
||||
#### Beta/RC Releases (Manual)
|
||||
- Create beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1`
|
||||
- Automatically builds and publishes to PyPI as pre-release
|
||||
- Users install with: `pip install basic-memory --pre`
|
||||
- Use for milestone testing before stable release
|
||||
|
||||
#### Stable Releases (Manual)
|
||||
- Create version tag: `git tag v0.13.0 && git push origin v0.13.0`
|
||||
- Automatically builds, creates GitHub release, and publishes to PyPI
|
||||
- Users install with: `pip install basic-memory`
|
||||
|
||||
### For Development
|
||||
- No manual version bumping required
|
||||
- Versions automatically derived from git tags
|
||||
- `pyproject.toml` uses `dynamic = ["version"]`
|
||||
- `__init__.py` dynamically reads version from package metadata
|
||||
@@ -144,6 +144,40 @@ agreement to the DCO.
|
||||
- **Database Testing**: Use in-memory SQLite for testing database operations
|
||||
- **Fixtures**: Use async pytest fixtures for setup and teardown
|
||||
|
||||
## Release Process
|
||||
|
||||
Basic Memory uses automatic versioning based on git tags with `uv-dynamic-versioning`. Here's how releases work:
|
||||
|
||||
### Version Management
|
||||
- **Development versions**: Automatically generated from git commits (e.g., `0.12.4.dev26+468a22f`)
|
||||
- **Beta releases**: Created by tagging with beta suffixes (e.g., `git tag v0.13.0b1`)
|
||||
- **Stable releases**: Created by tagging with version numbers (e.g., `git tag v0.13.0`)
|
||||
|
||||
### Release Workflows
|
||||
|
||||
#### Development Builds
|
||||
- Automatically published to PyPI on every commit to `main`
|
||||
- Version format: `0.12.4.dev26+468a22f` (base version + dev + commit count + hash)
|
||||
- Users install with: `pip install basic-memory --pre --force-reinstall`
|
||||
|
||||
#### Beta Releases
|
||||
1. Create and push a beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1`
|
||||
2. GitHub Actions automatically builds and publishes to PyPI
|
||||
3. Users install with: `pip install basic-memory --pre`
|
||||
|
||||
#### Stable Releases
|
||||
1. Create and push a version tag: `git tag v0.13.0 && git push origin v0.13.0`
|
||||
2. GitHub Actions automatically:
|
||||
- Builds the package with version `0.13.0`
|
||||
- Creates GitHub release with auto-generated notes
|
||||
- Publishes to PyPI
|
||||
3. Users install with: `pip install basic-memory`
|
||||
|
||||
### For Contributors
|
||||
- No manual version bumping required
|
||||
- Versions are automatically derived from git tags
|
||||
- Focus on code changes, not version management
|
||||
|
||||
## Creating Issues
|
||||
|
||||
If you're planning to work on something, please create an issue first to discuss the approach. Include:
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
.PHONY: install test test-module lint clean format type-check installer-mac installer-win check
|
||||
.PHONY: install test test-module lint clean format type-check installer-mac installer-win check test-int
|
||||
|
||||
install:
|
||||
pip install -e ".[dev]"
|
||||
|
||||
test:
|
||||
test-unit:
|
||||
uv run pytest -p pytest_mock -v
|
||||
|
||||
# Run tests for a specific module
|
||||
# Usage: make test-module m=path/to/module.py [cov=module_path]
|
||||
test-module:
|
||||
@if [ -z "$(m)" ]; then \
|
||||
echo "Usage: make test-module m=path/to/module.py [cov=module_path]"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -z "$(cov)" ]; then \
|
||||
uv run pytest $(m) -v; \
|
||||
else \
|
||||
uv run pytest $(m) -v --cov=$(cov); \
|
||||
fi
|
||||
test-int:
|
||||
uv run pytest -p pytest_mock -v --no-cov test-int
|
||||
|
||||
test: test-unit test-int
|
||||
|
||||
lint:
|
||||
ruff check . --fix
|
||||
@@ -41,7 +33,7 @@ format:
|
||||
|
||||
# run inspector tool
|
||||
run-inspector:
|
||||
uv run mcp dev src/basic_memory/mcp/main.py
|
||||
npx @modelcontextprotocol/inspector
|
||||
|
||||
# Build app installer
|
||||
installer-mac:
|
||||
|
||||
@@ -333,9 +333,9 @@ config:
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"basic-memory",
|
||||
"mcp",
|
||||
"--project",
|
||||
"your-project-name"
|
||||
"your-project-name",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -376,6 +376,24 @@ See the [Documentation](https://memory.basicmachines.co/) for more info, includi
|
||||
- [Managing multiple Projects](https://memory.basicmachines.co/docs/cli-reference#project)
|
||||
- [Importing data from OpenAI/Claude Projects](https://memory.basicmachines.co/docs/cli-reference#import)
|
||||
|
||||
## Installation Options
|
||||
|
||||
### Stable Release
|
||||
```bash
|
||||
pip install basic-memory
|
||||
```
|
||||
|
||||
### Beta/Pre-releases
|
||||
```bash
|
||||
pip install basic-memory --pre
|
||||
```
|
||||
|
||||
### Development Builds
|
||||
Development versions are automatically published on every commit to main with versions like `0.12.4.dev26+468a22f`:
|
||||
```bash
|
||||
pip install basic-memory --pre --force-reinstall
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# Release Notes v0.13.0
|
||||
|
||||
## Overview
|
||||
|
||||
Basic Memory v0.13.0 is a **major release** that transforms Basic Memory into a true multi-project knowledge management system. This release introduces fluid project switching, advanced note editing capabilities, robust file management, and production-ready OAuth authentication - all while maintaining full backward compatibility.
|
||||
|
||||
**What's New for Users:**
|
||||
- 🎯 **Switch between projects instantly** during conversations with Claude
|
||||
- ✏️ **Edit notes incrementally** without rewriting entire documents
|
||||
- 📁 **Move and organize notes** with full database consistency
|
||||
- 🔍 **Search frontmatter tags** to discover content more easily
|
||||
- 🔐 **OAuth authentication** for secure remote access
|
||||
- ⚡ **Development builds** automatically published for beta testing
|
||||
|
||||
**Key v0.13.0 Accomplishments:**
|
||||
- ✅ **Complete Project Management System** - Project switching and project-specific operations
|
||||
- ✅ **Advanced Note Editing** - Incremental editing with append, prepend, find/replace, and section operations
|
||||
- ✅ **File Management System** - Full move operations with database consistency and rollback protection
|
||||
- ✅ **Enhanced Search Capabilities** - Frontmatter tags now searchable, improved content discoverability
|
||||
- ✅ **Unified Database Architecture** - Single app-level database for better performance and project management
|
||||
|
||||
## Major Features
|
||||
|
||||
### 1. Multiple Project Management 🎯
|
||||
|
||||
**Switch between projects instantly during conversations:**
|
||||
|
||||
```
|
||||
💬 "What projects do I have?"
|
||||
🤖 Available projects:
|
||||
• main (current, default)
|
||||
• work-notes
|
||||
• personal-journal
|
||||
• code-snippets
|
||||
|
||||
💬 "Switch to work-notes"
|
||||
🤖 ✓ Switched to work-notes project
|
||||
|
||||
Project Summary:
|
||||
• 47 entities
|
||||
• 125 observations
|
||||
• 23 relations
|
||||
|
||||
💬 "What did I work on yesterday?"
|
||||
🤖 [Shows recent activity from work-notes project]
|
||||
```
|
||||
|
||||
**Key Capabilities:**
|
||||
- **Instant Project Switching**: Change project context mid-conversation without restart
|
||||
- **Project-Specific Operations**: Operations work within the currently active project context
|
||||
- **Project Discovery**: List all available projects with status indicators
|
||||
- **Session Context**: Maintains active project throughout conversation
|
||||
- **Backward Compatibility**: Existing single-project setups continue to work seamlessly
|
||||
|
||||
### 2. Advanced Note Editing ✏️
|
||||
|
||||
**Edit notes incrementally without rewriting entire documents:**
|
||||
|
||||
```python
|
||||
# Append new sections to existing notes
|
||||
edit_note("project-planning", "append", "\n## New Requirements\n- Feature X\n- Feature Y")
|
||||
|
||||
# Prepend timestamps to meeting notes
|
||||
edit_note("meeting-notes", "prepend", "## 2025-05-27 Update\n- Progress update...")
|
||||
|
||||
# Replace specific sections under headers
|
||||
edit_note("api-spec", "replace_section", "New implementation details", section="## Implementation")
|
||||
|
||||
# Find and replace with validation
|
||||
edit_note("config", "find_replace", "v0.13.0", find_text="v0.12.0", expected_replacements=2)
|
||||
```
|
||||
|
||||
**Key Capabilities:**
|
||||
- **Append Operations**: Add content to end of notes (most common use case)
|
||||
- **Prepend Operations**: Add content to beginning of notes
|
||||
- **Section Replacement**: Replace content under specific markdown headers
|
||||
- **Find & Replace**: Simple text replacements with occurrence counting
|
||||
- **Smart Error Handling**: Helpful guidance when operations fail
|
||||
- **Project Context**: Works within the active project with session awareness
|
||||
|
||||
### 3. Smart File Management 📁
|
||||
|
||||
**Move and organize notes:**
|
||||
|
||||
```python
|
||||
# Simple moves with automatic folder creation
|
||||
move_note("my-note", "work/projects/my-note.md")
|
||||
|
||||
# Organize within the active project
|
||||
move_note("shared-doc", "archive/old-docs/shared-doc.md")
|
||||
|
||||
# Rename operations
|
||||
move_note("old-name", "same-folder/new-name.md")
|
||||
```
|
||||
|
||||
**Key Capabilities:**
|
||||
- **Database Consistency**: Updates file paths, permalinks, and checksums automatically
|
||||
- **Search Reindexing**: Maintains search functionality after moves
|
||||
- **Folder Creation**: Automatically creates destination directories
|
||||
- **Project Isolation**: Operates within the currently active project
|
||||
- **Link Preservation**: Maintains internal links and references
|
||||
|
||||
### 4. Enhanced Search & Discovery 🔍
|
||||
|
||||
**Find content more easily with improved search capabilities:**
|
||||
|
||||
- **Frontmatter Tag Search**: Tags from YAML frontmatter are now indexed and searchable
|
||||
- **Improved Content Discovery**: Search across titles, content, tags, and metadata
|
||||
- **Project-Scoped Search**: Search within the currently active project
|
||||
- **Better Search Quality**: Enhanced FTS5 indexing with tag content inclusion
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
---
|
||||
title: Coffee Brewing Methods
|
||||
tags: [coffee, brewing, equipment]
|
||||
---
|
||||
```
|
||||
Now searchable by: "coffee", "brewing", "equipment", or "Coffee Brewing Methods"
|
||||
|
||||
### 5. Unified Database Architecture 🗄️
|
||||
|
||||
**Single app-level database for better performance and project management:**
|
||||
|
||||
- **Migration from Per-Project DBs**: Moved from multiple SQLite files to single app database
|
||||
- **Project Isolation**: Proper data separation with project_id foreign keys
|
||||
- **Better Performance**: Optimized queries and reduced file I/O
|
||||
|
||||
## Complete MCP Tool Suite 🛠️
|
||||
|
||||
### New Project Management Tools
|
||||
- **`list_projects()`** - Discover and list all available projects with status
|
||||
- **`switch_project(project_name)`** - Change active project context during conversations
|
||||
- **`get_current_project()`** - Show currently active project with statistics
|
||||
- **`set_default_project(project_name)`** - Update default project configuration
|
||||
|
||||
### New Note Operations Tools
|
||||
- **`edit_note()`** - Incremental note editing (append, prepend, find/replace, section replace)
|
||||
- **`move_note()`** - Move notes with database consistency and search reindexing
|
||||
|
||||
### Enhanced Existing Tools
|
||||
All existing tools now support:
|
||||
- **Session context awareness** (operates within the currently active project)
|
||||
- **Enhanced error messages** with project context metadata
|
||||
- **Improved response formatting** with project information footers
|
||||
- **Project isolation** ensures operations stay within the correct project boundaries
|
||||
|
||||
|
||||
## User Experience Improvements
|
||||
|
||||
### Installation Options
|
||||
|
||||
**Multiple ways to install and test Basic Memory:**
|
||||
|
||||
```bash
|
||||
# Stable release
|
||||
uv tool install basic-memory
|
||||
|
||||
# Beta/pre-releases
|
||||
uv tool install basic-memory --pre
|
||||
```
|
||||
|
||||
|
||||
### Bug Fixes & Quality Improvements
|
||||
|
||||
**Major issues resolved in v0.13.0:**
|
||||
|
||||
- **#118**: Fixed YAML tag formatting to follow standard specification
|
||||
- **#110**: Fixed `--project` flag consistency across all CLI commands
|
||||
- **#107**: Fixed write_note update failures with existing notes
|
||||
- **#93**: Fixed custom permalink handling in frontmatter
|
||||
- **#52**: Enhanced search capabilities with frontmatter tag indexing
|
||||
- **FTS5 Search**: Fixed special character handling in search queries
|
||||
- **Error Handling**: Improved error messages and validation across all tools
|
||||
|
||||
## Breaking Changes & Migration
|
||||
|
||||
### For Existing Users
|
||||
|
||||
**Automatic Migration**: First run will automatically migrate existing data to the new unified database structure. No manual action required.
|
||||
|
||||
**What Changes:**
|
||||
- Database location: Moved to `~/.basic-memory/memory.db` (unified across projects)
|
||||
- Configuration: Projects defined in `~/.basic-memory/config.json` are synced with database
|
||||
|
||||
**What Stays the Same:**
|
||||
- All existing notes and data remain unchanged
|
||||
- Default project behavior maintained for single-project users
|
||||
- All existing MCP tools continue to work without modification
|
||||
|
||||
|
||||
|
||||
|
||||
## Documentation & Resources
|
||||
|
||||
### New Documentation
|
||||
- [Project Management Guide](docs/Project%20Management.md) - Multi-project workflows
|
||||
- [Note Editing Guide](docs/Note%20Editing.md) - Advanced editing techniques
|
||||
|
||||
### Updated Documentation
|
||||
- [README.md](README.md) - Installation options and beta build instructions
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Release process and version management
|
||||
- [CLAUDE.md](CLAUDE.md) - Development workflow and CI/CD documentation
|
||||
- [Claude.ai Integration](docs/Claude.ai%20Integration.md) - Updated MCP tool examples
|
||||
|
||||
### Quick Start Examples
|
||||
|
||||
**Project Switching:**
|
||||
```
|
||||
💬 "Switch to my work project and show recent activity"
|
||||
🤖 [Calls switch_project("work") then recent_activity()]
|
||||
```
|
||||
|
||||
**Note Editing:**
|
||||
```
|
||||
💬 "Add a section about deployment to my API docs"
|
||||
🤖 [Calls edit_note("api-docs", "append", "## Deployment\n...")]
|
||||
```
|
||||
|
||||
**File Organization:**
|
||||
```
|
||||
💬 "Move my old meeting notes to the archive folder"
|
||||
🤖 [Calls move_note("meeting-notes", "archive/old-meetings.md")]
|
||||
```
|
||||
|
||||
|
||||
### Getting Updates
|
||||
```bash
|
||||
# Stable releases
|
||||
uv tool upgrade basic-memory
|
||||
|
||||
# Beta releases
|
||||
uv tool install basic-memory --pre --force-reinstall
|
||||
|
||||
# Latest development
|
||||
uv tool install basic-memory --pre --force-reinstall
|
||||
```
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
# Manual Testing Suite for Basic Memory
|
||||
|
||||
This document outlines a comprehensive manual testing approach where an AI assistant (Claude) executes real-world usage scenarios using Basic Memory's MCP tools. The unique aspect: **Basic Memory tests itself** - all test observations and results are recorded as notes in a dedicated test project.
|
||||
|
||||
## Philosophy
|
||||
|
||||
- **Integration over Isolation**: Test the full MCP→API→DB→File stack
|
||||
- **Real Usage Patterns**: Creative exploration, not just checklist validation
|
||||
- **Self-Documenting**: Use Basic Memory to record all test observations
|
||||
- **Living Documentation**: Test results become part of the knowledge base
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Environment Preparation
|
||||
|
||||
```bash
|
||||
# Ensure latest basic-memory is installed
|
||||
pip install --upgrade basic-memory
|
||||
|
||||
# Verify MCP server is available
|
||||
basic-memory --version
|
||||
```
|
||||
|
||||
### 2. MCP Integration Setup
|
||||
|
||||
**Option A: Claude Desktop Integration**
|
||||
```json
|
||||
// Add to ~/.config/claude-desktop/claude_desktop_config.json
|
||||
// or
|
||||
// .mcp.json
|
||||
{
|
||||
"mcpServers": {
|
||||
"basic-memory": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/Users/phernandez/dev/basicmachines/basic-memory",
|
||||
"run",
|
||||
"src/basic_memory/cli/main.py",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Claude Code MCP**
|
||||
```bash
|
||||
claude mcp add basic-memory basic-memory mcp
|
||||
```
|
||||
|
||||
### 3. Test Project Creation
|
||||
|
||||
During testing, create a dedicated test project:
|
||||
```
|
||||
- Project name: "basic-memory-testing"
|
||||
- Location: ~/basic-memory-testing
|
||||
- Purpose: Contains all test observations and results
|
||||
```
|
||||
|
||||
## Testing Categories
|
||||
|
||||
### Phase 1: Core Functionality Validation
|
||||
|
||||
**Objective**: Verify all basic operations work correctly
|
||||
|
||||
**Test Areas:**
|
||||
- [ ] **Note Creation**: Various content types, structures, frontmatter
|
||||
- [ ] **Note Reading**: By title, path, memory:// URLs, non-existent notes
|
||||
- [ ] **Search Operations**: Simple queries, boolean operators, tag searches
|
||||
- [ ] **Context Building**: Different depths, timeframes, relation traversal
|
||||
- [ ] **Recent Activity**: Various timeframes, filtering options
|
||||
|
||||
**Success Criteria:**
|
||||
- All operations complete without errors
|
||||
- Files appear correctly in filesystem
|
||||
- Search returns expected results
|
||||
- Context includes appropriate related content
|
||||
|
||||
**Observations to Record:**
|
||||
```markdown
|
||||
# Core Functionality Test Results
|
||||
|
||||
## Test Execution
|
||||
- [timestamp] Test started at 2025-01-06 15:30:00
|
||||
- [setup] Created test project successfully
|
||||
- [environment] MCP connection established
|
||||
|
||||
## write_note Tests
|
||||
- [success] Basic note creation works
|
||||
- [success] Frontmatter tags are preserved
|
||||
- [issue] Special characters in titles need investigation
|
||||
|
||||
## Relations
|
||||
- validates [[Search Operations Test]]
|
||||
- part_of [[Manual Testing Suite]]
|
||||
```
|
||||
|
||||
### Phase 2: v0.13.0 Feature Deep Dive
|
||||
|
||||
**Objective**: Thoroughly test new project management and editing capabilities
|
||||
|
||||
**Project Management Tests:**
|
||||
- [ ] Create multiple projects dynamically
|
||||
- [ ] Switch between projects mid-conversation
|
||||
- [ ] Cross-project operations (create notes in different projects)
|
||||
- [ ] Project discovery and status checking
|
||||
- [ ] Default project behavior
|
||||
|
||||
**Note Editing Tests:**
|
||||
- [ ] Append operations (add content to end)
|
||||
- [ ] Prepend operations (add content to beginning)
|
||||
- [ ] Find/replace operations with validation
|
||||
- [ ] Section replacement under headers
|
||||
- [ ] Edit operations across different projects
|
||||
|
||||
**File Management Tests:**
|
||||
- [ ] Move notes within same project
|
||||
- [ ] Move notes between projects
|
||||
- [ ] Automatic folder creation during moves
|
||||
- [ ] Move operations with special characters
|
||||
- [ ] Database consistency after moves
|
||||
|
||||
**Success Criteria:**
|
||||
- Project switching preserves context correctly
|
||||
- Edit operations modify files as expected
|
||||
- Move operations maintain database consistency
|
||||
- Search indexes update after moves and edits
|
||||
|
||||
### Phase 3: Edge Case Exploration
|
||||
|
||||
**Objective**: Discover limits and handle unusual scenarios gracefully
|
||||
|
||||
**Boundary Testing:**
|
||||
- [ ] Very long note titles and content
|
||||
- [ ] Empty notes and projects
|
||||
- [ ] Special characters: unicode, emojis, symbols
|
||||
- [ ] Deeply nested folder structures
|
||||
- [ ] Circular relations and self-references
|
||||
|
||||
**Error Scenario Testing:**
|
||||
- [ ] Invalid memory:// URLs
|
||||
- [ ] Missing files referenced in database
|
||||
- [ ] Concurrent operations (if possible)
|
||||
- [ ] Invalid project names
|
||||
- [ ] Disk space constraints (if applicable)
|
||||
|
||||
**Performance Testing:**
|
||||
- [ ] Large numbers of notes (100+)
|
||||
- [ ] Complex search queries
|
||||
- [ ] Deep relation chains (5+ levels)
|
||||
- [ ] Rapid successive operations
|
||||
|
||||
### Phase 4: Real-World Workflow Scenarios
|
||||
|
||||
**Objective**: Test realistic usage patterns that users might follow
|
||||
|
||||
**Scenario 1: Meeting Notes Pipeline**
|
||||
1. Create meeting notes with action items
|
||||
2. Extract action items into separate notes
|
||||
3. Link to project planning documents
|
||||
4. Update progress over time using edit operations
|
||||
5. Archive completed items
|
||||
|
||||
**Scenario 2: Research Knowledge Building**
|
||||
1. Create research topic notes
|
||||
2. Build complex relation networks
|
||||
3. Add incremental findings over time
|
||||
4. Search and discover connections
|
||||
5. Reorganize as knowledge grows
|
||||
|
||||
**Scenario 3: Multi-Project Workflow**
|
||||
1. Work project: Technical documentation
|
||||
2. Personal project: Recipe collection
|
||||
3. Learning project: Course notes
|
||||
4. Switch between projects during conversation
|
||||
5. Cross-reference related concepts
|
||||
|
||||
**Scenario 4: Content Evolution**
|
||||
1. Start with basic notes
|
||||
2. Gradually enhance with relations
|
||||
3. Reorganize file structure
|
||||
4. Update existing content incrementally
|
||||
5. Build comprehensive knowledge graph
|
||||
|
||||
### Phase 5: Creative Stress Testing
|
||||
|
||||
**Objective**: Push the system to discover unexpected behaviors
|
||||
|
||||
**Creative Exploration Areas:**
|
||||
- [ ] Rapid project creation and switching
|
||||
- [ ] Unusual but valid markdown structures
|
||||
- [ ] Creative use of observation categories
|
||||
- [ ] Novel relation types and patterns
|
||||
- [ ] Combining tools in unexpected ways
|
||||
|
||||
**Stress Scenarios:**
|
||||
- [ ] Bulk operations (create many notes quickly)
|
||||
- [ ] Complex nested moves and edits
|
||||
- [ ] Deep context building with large graphs
|
||||
- [ ] Search with complex boolean expressions
|
||||
|
||||
## Test Execution Process
|
||||
|
||||
### Pre-Test Checklist
|
||||
- [ ] MCP connection verified
|
||||
- [ ] Test project created
|
||||
- [ ] Baseline notes recorded
|
||||
|
||||
### During Testing
|
||||
1. **Execute test scenarios** using actual MCP tool calls
|
||||
2. **Record observations** immediately in test project
|
||||
3. **Note timestamps** for performance tracking
|
||||
4. **Document any errors** with reproduction steps
|
||||
5. **Explore variations** when something interesting happens
|
||||
|
||||
### Test Observation Format
|
||||
|
||||
Record all observations as Basic Memory notes using this structure:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Test Session YYYY-MM-DD HH:MM
|
||||
tags: [testing, session, v0.13.0]
|
||||
---
|
||||
|
||||
# Test Session YYYY-MM-DD HH:MM
|
||||
|
||||
## Test Focus
|
||||
- Primary objective
|
||||
- Features being tested
|
||||
|
||||
## Observations
|
||||
- [success] Feature X worked as expected #functionality
|
||||
- [performance] Operation Y took 2.3 seconds #timing
|
||||
- [issue] Error with special characters #bug
|
||||
- [enhancement] Could improve UX for scenario Z #improvement
|
||||
|
||||
## Discovered Issues
|
||||
- [bug] Description of problem with reproduction steps
|
||||
- [limitation] Current system boundary encountered
|
||||
|
||||
## Relations
|
||||
- tests [[Feature X]]
|
||||
- part_of [[Manual Testing Suite]]
|
||||
- found_issue [[Bug Report: Special Characters]]
|
||||
```
|
||||
|
||||
### Post-Test Analysis
|
||||
- [ ] Review all test observations
|
||||
- [ ] Create summary report with findings
|
||||
- [ ] Identify patterns in successes/failures
|
||||
- [ ] Generate improvement recommendations
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**Quantitative Measures:**
|
||||
- % of test scenarios completed successfully
|
||||
- Number of bugs discovered and documented
|
||||
- Performance benchmarks established
|
||||
- Coverage of all MCP tools and operations
|
||||
|
||||
**Qualitative Measures:**
|
||||
- Natural conversation flow maintained
|
||||
- Knowledge graph quality and connections
|
||||
- User experience insights captured
|
||||
- System reliability under various conditions
|
||||
|
||||
## Expected Outcomes
|
||||
|
||||
**For the System:**
|
||||
- Validation of v0.13.0 features in real usage
|
||||
- Discovery of edge cases not covered by unit tests
|
||||
- Performance baseline establishment
|
||||
- Bug identification with reproduction cases
|
||||
|
||||
**For the Knowledge Base:**
|
||||
- Comprehensive testing documentation
|
||||
- Real usage examples for documentation
|
||||
- Edge case scenarios for future reference
|
||||
- Performance insights and optimization opportunities
|
||||
|
||||
**For Development:**
|
||||
- Priority list for bug fixes
|
||||
- Enhancement ideas from real usage
|
||||
- Validation of architectural decisions
|
||||
- User experience insights
|
||||
|
||||
## Test Reporting
|
||||
|
||||
All test results will be captured in the Basic Memory test project, creating a living knowledge base of:
|
||||
|
||||
- Test execution logs with detailed observations
|
||||
- Bug reports with reproduction steps
|
||||
- Performance benchmarks and timing data
|
||||
- Feature enhancement ideas discovered during testing
|
||||
- Knowledge graphs showing test coverage relationships
|
||||
- Summary reports for development team review
|
||||
|
||||
This approach ensures that the testing process itself validates Basic Memory's core value proposition: effectively capturing, organizing, and connecting knowledge through natural interaction patterns.
|
||||
|
||||
## Things to note
|
||||
|
||||
### User Experience & Usability:
|
||||
- are tool instructions clear with working examples?
|
||||
- Do error messages provide actionable guidance for resolution?
|
||||
- Are response times acceptable for interactive use?
|
||||
- Do tools feel consistent in their parameter patterns and behavior?
|
||||
- Can users easily discover what tools are available and their capabilities?
|
||||
|
||||
### System Behavior:
|
||||
- Does context preservation work as expected across tool calls?
|
||||
- Do memory:// URLs behave intuitively for knowledge navigation?
|
||||
- How well do tools work together in multi-step workflows?
|
||||
- Does the system gracefully handle edge cases and invalid inputs?
|
||||
|
||||
### Documentation Alignment:
|
||||
- does tool output provide clear results and helpful information?
|
||||
- Do actual tool behaviors match their documented descriptions?
|
||||
- Are the examples in tool help accurate and useful?
|
||||
- Do real-world usage patterns align with documented workflows?
|
||||
|
||||
### Mental Model Validation:
|
||||
- Does the system work the way users would naturally expect?
|
||||
- Are there surprising behaviors that break user assumptions?
|
||||
- Can users easily recover from mistakes or wrong turns?
|
||||
- Do the knowledge graph concepts (entities, relations, observations) feel natural?
|
||||
|
||||
### Performance & Reliability:
|
||||
- Do operations complete in reasonable time for the data size?
|
||||
- Is system behavior consistent across multiple test sessions?
|
||||
- How does performance change as the knowledge base grows?
|
||||
- Are there any operations that feel unexpectedly slow?
|
||||
|
||||
---
|
||||
|
||||
**Ready to begin testing?** Start by creating the test project and recording your first observation about the testing setup process itself.
|
||||
@@ -1,13 +0,0 @@
|
||||
Starting issue-fix mode at Sat Apr 5 18:02:54 UTC 2025
|
||||
Fetching issue #75 details
|
||||
Using repository: basicmachines-co/basic-memory
|
||||
Checking if phernandez is a member of organization basicmachines-co
|
||||
User phernandez is a member of organization basicmachines-co. Proceeding with Claude fix.
|
||||
Creating a new branch: fix-issue-75-20250405180254
|
||||
From https://github.com/basicmachines-co/basic-memory
|
||||
* branch main -> FETCH_HEAD
|
||||
Switched to a new branch 'fix-issue-75-20250405180254'
|
||||
branch 'fix-issue-75-20250405180254' set up to track 'origin/main'.
|
||||
Prompt saved to ./claude-output/claude_prompt_75.txt for debugging
|
||||
Running Claude to fix the issue...
|
||||
Committing changes...
|
||||
@@ -1,35 +0,0 @@
|
||||
Let's summarize the changes we've made to fix issue #75:
|
||||
|
||||
1. We updated the `search_notes` tool in `/src/basic_memory/mcp/tools/search.py` to accept primitive types as parameters instead of a complex Pydantic `SearchQuery` object. This makes it easier for LLMs like Cursor to make proper tool calls.
|
||||
|
||||
2. We converted the internal implementation to create a SearchQuery object from the primitive parameters, maintaining backward compatibility with the existing API.
|
||||
|
||||
3. We updated tests in `/tests/mcp/test_tool_search.py` to use the new function signature with primitive parameters.
|
||||
|
||||
4. We updated code in `/src/basic_memory/mcp/tools/read_note.py` to use the new function signature when making calls to `search_notes`.
|
||||
|
||||
5. We updated code in `/src/basic_memory/mcp/prompts/search.py` to use the new function signature when making calls to `search_notes`.
|
||||
|
||||
These changes should make it easier for Cursor and other LLMs to use the search_notes tool by eliminating the complex Pydantic object parameter in favor of simple primitive parameters.
|
||||
|
||||
---SUMMARY---
|
||||
Fixed issue #75 where Cursor was having errors calling the search_notes tool. The problem was that the search_notes tool was expecting a complex Pydantic object (SearchQuery) as input, which was confusing Cursor.
|
||||
|
||||
Changes:
|
||||
1. Modified the search_notes tool to accept primitive types (strings, lists, etc.) as parameters instead of a complex Pydantic object
|
||||
2. Updated the implementation to create a SearchQuery object internally from these primitive parameters
|
||||
3. Updated all call sites in the codebase that were using the old function signature
|
||||
4. Updated tests to use the new function signature
|
||||
|
||||
The fix makes it easier for LLMs like Cursor to make proper calls to the search_notes tool, which will resolve the reported error messages:
|
||||
- "Parameter 'query' must be of type undefined, got object"
|
||||
- "Parameter 'query' must be of type undefined, got string"
|
||||
- "Invalid type for parameter 'query' in tool search_notes"
|
||||
|
||||
Files modified:
|
||||
- src/basic_memory/mcp/tools/search.py
|
||||
- src/basic_memory/mcp/tools/read_note.py
|
||||
- src/basic_memory/mcp/prompts/search.py
|
||||
- tests/mcp/test_tool_search.py
|
||||
- tests/mcp/test_tool_read_note.py
|
||||
---END SUMMARY---
|
||||
@@ -1,49 +0,0 @@
|
||||
You are Claude, an AI assistant tasked with fixing issues in a GitHub repository.
|
||||
|
||||
Issue #75: [BUG] Cursor has errors calling search tool
|
||||
|
||||
Issue Description:
|
||||
## Bug Description
|
||||
|
||||
|
||||
|
||||
> Cursor cannot figure out how to structure the parameters for that tool call. No matter what Cursor seems to try it gets the errors.
|
||||
>
|
||||
> ```Looking at the error messages more carefully:
|
||||
> - When I pass an object: "Parameter 'query' must be of type undefined, got object"
|
||||
> - When I pass a string: "Parameter 'query' must be of type undefined, got string"
|
||||
>
|
||||
>
|
||||
>
|
||||
> and then it reports: "Invalid type for parameter 'query' in tool search_notes"
|
||||
> Any chance you can give me some guidance with this?
|
||||
>
|
||||
|
||||
## Steps To Reproduce
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
try using search tool in Cursor.
|
||||
|
||||
## Possible Solution
|
||||
|
||||
The tool args should probably be plain text and not json to make it easier to call.
|
||||
Additional Instructions from User Comment:
|
||||
let make a PR to implement option #1.
|
||||
Your task is to:
|
||||
1. Analyze the issue carefully to understand the problem
|
||||
2. Look through the repository to identify the relevant files that need to be modified
|
||||
3. Make precise changes to fix the issue
|
||||
4. Use the Edit tool to modify files directly when needed
|
||||
5. Be minimal in your changes - only modify what's necessary to fix the issue
|
||||
|
||||
After making changes, provide a summary of what you did in this format:
|
||||
|
||||
---SUMMARY---
|
||||
[Your detailed summary of changes, including which files were modified and how]
|
||||
---END SUMMARY---
|
||||
|
||||
Remember:
|
||||
- Be specific in your changes
|
||||
- Only modify files that are necessary to fix the issue
|
||||
- Follow existing code style and conventions
|
||||
- Make the minimal changes needed to resolve the issue
|
||||
+155
-145
@@ -10,6 +10,26 @@ You can [download](https://github.com/basicmachines-co/basic-memory/blob/main/do
|
||||
|
||||
This guide helps you, the AI assistant, use Basic Memory tools effectively when working with users. It covers reading, writing, and navigating knowledge through the Model Context Protocol (MCP).
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Essential Tools:**
|
||||
- `write_note()` - Create/update notes (primary tool)
|
||||
- `read_note()` - Read existing content
|
||||
- `search_notes()` - Find information
|
||||
- `edit_note()` - Modify existing notes incrementally (v0.13.0)
|
||||
- `move_note()` - Organize files with database consistency (v0.13.0)
|
||||
|
||||
**Project Management (v0.13.0):**
|
||||
- `list_projects()` - Show available projects
|
||||
- `switch_project()` - Change active project
|
||||
- `get_current_project()` - Current project info
|
||||
|
||||
**Key Principles:**
|
||||
1. **Build connections** - Rich knowledge graphs > isolated notes
|
||||
2. **Ask permission** - "Would you like me to record this?"
|
||||
3. **Use exact titles** - For accurate `[[WikiLinks]]`
|
||||
4. **Leverage v0.13.0** - Edit incrementally, organize proactively, switch projects contextually
|
||||
|
||||
## Overview
|
||||
|
||||
Basic Memory allows you and users to record context in local Markdown files, building a rich knowledge base through natural conversations. The system automatically creates a semantic knowledge graph from simple text patterns.
|
||||
@@ -37,51 +57,59 @@ Remember that a knowledge graph with 10 heavily connected notes is more valuable
|
||||
|
||||
## Core Tools Reference
|
||||
|
||||
```python
|
||||
# Writing knowledge - THE MOST IMPORTANT TOOL!
|
||||
response = await write_note(
|
||||
title="Search Design", # Required: Note title
|
||||
content="# Search Design\n...", # Required: Note content
|
||||
folder="specs", # Optional: Folder to save in
|
||||
tags=["search", "design"], # Optional: Tags for categorization
|
||||
verbose=True # Optional: Get parsing details
|
||||
)
|
||||
### Essential Content Management
|
||||
|
||||
# Reading knowledge
|
||||
content = await read_note("Search Design") # By title
|
||||
content = await read_note("specs/search-design") # By path
|
||||
content = await read_note("memory://specs/search") # By memory URL
|
||||
|
||||
# Searching for knowledge
|
||||
results = await search_notes(
|
||||
query="authentication system", # Text to search for
|
||||
page=1, # Optional: Pagination
|
||||
page_size=10 # Optional: Results per page
|
||||
**Writing knowledge** (most important tool):
|
||||
```
|
||||
write_note(
|
||||
title="Search Design",
|
||||
content="# Search Design\n...",
|
||||
folder="specs", # Optional
|
||||
tags=["search", "design"], # v0.13.0: now searchable!
|
||||
project="work-notes" # v0.13.0: target specific project
|
||||
)
|
||||
```
|
||||
|
||||
# Building context from the knowledge graph
|
||||
context = await build_context(
|
||||
url="memory://specs/search", # Starting point
|
||||
depth=2, # Optional: How many hops to follow
|
||||
timeframe="1 month" # Optional: Recent timeframe
|
||||
)
|
||||
**Reading knowledge:**
|
||||
```
|
||||
read_note("Search Design") # By title
|
||||
read_note("specs/search-design") # By path
|
||||
read_note("memory://specs/search") # By memory URL
|
||||
```
|
||||
|
||||
# Checking recent changes
|
||||
activity = await recent_activity(
|
||||
type="all", # Optional: Entity types to include
|
||||
depth=1, # Optional: Related items to include
|
||||
timeframe="1 week" # Optional: Time window
|
||||
**Incremental editing** (v0.13.0):
|
||||
```
|
||||
edit_note(
|
||||
identifier="Search Design",
|
||||
operation="append", # append, prepend, find_replace, replace_section
|
||||
content="\n## New Section\nContent here..."
|
||||
)
|
||||
```
|
||||
|
||||
# Creating a knowledge visualization
|
||||
canvas_result = await canvas(
|
||||
nodes=[{"id": "note1", "label": "Search Design"}], # Nodes to display
|
||||
edges=[{"from": "note1", "to": "note2"}], # Connections
|
||||
title="Project Overview", # Canvas title
|
||||
folder="diagrams" # Storage location
|
||||
**File organization** (v0.13.0):
|
||||
```
|
||||
move_note(
|
||||
identifier="Old Note",
|
||||
destination="archive/old-note.md" # Folders created automatically
|
||||
)
|
||||
```
|
||||
|
||||
### Project Management (v0.13.0)
|
||||
|
||||
```
|
||||
list_projects() # Show available projects
|
||||
switch_project("work-notes") # Change active project
|
||||
get_current_project() # Current project info
|
||||
```
|
||||
|
||||
### Search & Discovery
|
||||
|
||||
```
|
||||
search_notes("authentication system") # v0.13.0: includes frontmatter tags
|
||||
build_context("memory://specs/search") # Follow knowledge graph connections
|
||||
recent_activity(timeframe="1 week") # Check what's been updated
|
||||
```
|
||||
|
||||
## memory:// URLs Explained
|
||||
|
||||
Basic Memory uses a special URL format to reference entities in the knowledge graph:
|
||||
@@ -158,6 +186,30 @@ Users will interact with Basic Memory in patterns like:
|
||||
[Then build_context() to understand connections]
|
||||
```
|
||||
|
||||
4. **Editing existing notes (v0.13.0)**:
|
||||
```
|
||||
Human: "Add a section about deployment to my API documentation"
|
||||
|
||||
You: I'll add that section to your existing documentation.
|
||||
[Use edit_note() with operation="append" to add new content]
|
||||
```
|
||||
|
||||
5. **Project management (v0.13.0)**:
|
||||
```
|
||||
Human: "Switch to my work project and show recent activity"
|
||||
|
||||
You: I'll switch to your work project and check what's been updated recently.
|
||||
[Use switch_project() then recent_activity()]
|
||||
```
|
||||
|
||||
6. **File organization (v0.13.0)**:
|
||||
```
|
||||
Human: "Move my old meeting notes to the archive folder"
|
||||
|
||||
You: I'll organize those notes for you.
|
||||
[Use move_note() to relocate files with database consistency]
|
||||
```
|
||||
|
||||
## Key Things to Remember
|
||||
|
||||
1. **Files are Truth**
|
||||
@@ -174,16 +226,27 @@ Users will interact with Basic Memory in patterns like:
|
||||
- Combine related information
|
||||
|
||||
3. **Writing Knowledge Wisely**
|
||||
- Using the same title+folder will overwrite existing notes
|
||||
- Structure content with clear headings and sections
|
||||
- Use semantic markup for observations and relations
|
||||
- Same title+folder overwrites existing notes
|
||||
- Structure with clear headings and semantic markup
|
||||
- Use tags for searchability (v0.13.0: frontmatter tags indexed)
|
||||
- Keep files organized in logical folders
|
||||
|
||||
4. **Leverage v0.13.0 Features**
|
||||
- **Edit incrementally**: Use `edit_note()` for small changes vs rewriting
|
||||
- **Switch projects**: Change context when user mentions different work areas
|
||||
- **Organize proactively**: Move old content to archive folders
|
||||
- **Cross-project operations**: Create notes in specific projects while maintaining context
|
||||
|
||||
## Common Knowledge Patterns
|
||||
|
||||
### Capturing Decisions
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Coffee Brewing Methods
|
||||
tags: [coffee, brewing, pour-over, techniques] # v0.13.0: Now searchable!
|
||||
---
|
||||
|
||||
# Coffee Brewing Methods
|
||||
|
||||
## Context
|
||||
@@ -196,11 +259,13 @@ Pour over is my preferred method for light to medium roasts because it highlight
|
||||
- [technique] Blooming the coffee grounds for 30 seconds improves extraction #brewing
|
||||
- [preference] Water temperature between 195-205°F works best #temperature
|
||||
- [equipment] Gooseneck kettle provides better control of water flow #tools
|
||||
- [timing] Total brew time of 3-4 minutes produces optimal extraction #process
|
||||
|
||||
## Relations
|
||||
- pairs_with [[Light Roast Beans]]
|
||||
- contrasts_with [[French Press Method]]
|
||||
- requires [[Proper Grinding Technique]]
|
||||
- part_of [[Morning Coffee Routine]]
|
||||
```
|
||||
|
||||
### Recording Project Structure
|
||||
@@ -241,119 +306,63 @@ Discussed strategies for improving the chocolate chip cookie recipe.
|
||||
- pairs_with [[Homemade Ice Cream]]
|
||||
```
|
||||
|
||||
## v0.13.0 Workflow Examples
|
||||
|
||||
### Multi-Project Conversations
|
||||
|
||||
**User:** "I need to update my work documentation and also add a personal recipe note."
|
||||
|
||||
**Workflow:**
|
||||
1. `list_projects()` - Check available projects
|
||||
2. `write_note(title="Sprint Planning", project="work-notes")` - Work content
|
||||
3. `write_note(title="Weekend Recipes", project="personal")` - Personal content
|
||||
|
||||
### Incremental Note Building
|
||||
|
||||
**User:** "Add a troubleshooting section to my setup guide."
|
||||
|
||||
**Workflow:**
|
||||
1. `edit_note(identifier="Setup Guide", operation="append", content="\n## Troubleshooting\n...")`
|
||||
|
||||
**User:** "Update the authentication section in my API docs."
|
||||
|
||||
**Workflow:**
|
||||
1. `edit_note(identifier="API Documentation", operation="replace_section", section="## Authentication")`
|
||||
|
||||
### Smart File Organization
|
||||
|
||||
**User:** "My notes are getting messy in the main folder."
|
||||
|
||||
**Workflow:**
|
||||
1. `move_note("Old Meeting Notes", "archive/2024/old-meetings.md")`
|
||||
2. `move_note("Project Notes", "projects/client-work/notes.md")`
|
||||
|
||||
### Creating Effective Relations
|
||||
|
||||
When creating relations, you can:
|
||||
1. Reference existing entities by their exact title
|
||||
2. Create forward references to entities that don't exist yet
|
||||
When creating relations:
|
||||
1. **Reference existing entities** by their exact title: `[[Exact Title]]`
|
||||
2. **Create forward references** to entities that don't exist yet - they'll be linked automatically when created
|
||||
3. **Search first** to find existing entities to reference
|
||||
4. **Use meaningful relation types**: `implements`, `requires`, `part_of` vs generic `relates_to`
|
||||
|
||||
```python
|
||||
# Example workflow for creating notes with effective relations
|
||||
async def create_note_with_effective_relations():
|
||||
# Search for existing entities to reference
|
||||
search_results = await search_notes("travel")
|
||||
existing_entities = [result.title for result in search_results.primary_results]
|
||||
|
||||
# Check if specific entities exist
|
||||
packing_tips_exists = "Packing Tips" in existing_entities
|
||||
japan_travel_exists = "Japan Travel Guide" in existing_entities
|
||||
|
||||
# Prepare relations section - include both existing and forward references
|
||||
relations_section = "## Relations\n"
|
||||
|
||||
# Existing reference - exact match to known entity
|
||||
if packing_tips_exists:
|
||||
relations_section += "- references [[Packing Tips]]\n"
|
||||
else:
|
||||
# Forward reference - will be linked when that entity is created later
|
||||
relations_section += "- references [[Packing Tips]]\n"
|
||||
|
||||
# Another possible reference
|
||||
if japan_travel_exists:
|
||||
relations_section += "- part_of [[Japan Travel Guide]]\n"
|
||||
|
||||
# You can also check recently modified notes to reference them
|
||||
recent = await recent_activity(timeframe="1 week")
|
||||
recent_titles = [item.title for item in recent.primary_results]
|
||||
|
||||
if "Transportation Options" in recent_titles:
|
||||
relations_section += "- relates_to [[Transportation Options]]\n"
|
||||
|
||||
# Always include meaningful forward references, even if they don't exist yet
|
||||
relations_section += "- located_in [[Tokyo]]\n"
|
||||
relations_section += "- visited_during [[Spring 2023 Trip]]\n"
|
||||
|
||||
# Now create the note with both verified and forward relations
|
||||
content = f"""# Tokyo Neighborhood Guide
|
||||
|
||||
## Overview
|
||||
Details about different Tokyo neighborhoods and their unique characteristics.
|
||||
**Example workflow:**
|
||||
1. `search_notes("travel")` to find existing travel-related notes
|
||||
2. Reference found entities: `- part_of [[Japan Travel Guide]]`
|
||||
3. Add forward references: `- located_in [[Tokyo]]` (even if Tokyo note doesn't exist yet)
|
||||
|
||||
## Observations
|
||||
- [area] Shibuya is a busy shopping district #shopping
|
||||
- [transportation] Yamanote Line connects major neighborhoods #transit
|
||||
- [recommendation] Visit Shimokitazawa for vintage shopping #unique
|
||||
- [tip] Get a Suica card for easy train travel #convenience
|
||||
## Common Issues & Solutions
|
||||
|
||||
{relations_section}
|
||||
"""
|
||||
|
||||
result = await write_note(
|
||||
title="Tokyo Neighborhood Guide",
|
||||
content=content,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# You can check which relations were resolved and which are forward references
|
||||
if result and 'relations' in result:
|
||||
resolved = [r['to_name'] for r in result['relations'] if r.get('target_id')]
|
||||
forward_refs = [r['to_name'] for r in result['relations'] if not r.get('target_id')]
|
||||
|
||||
print(f"Resolved relations: {resolved}")
|
||||
print(f"Forward references that will be resolved later: {forward_refs}")
|
||||
```
|
||||
**Missing Content:**
|
||||
- Try `search_notes()` with broader terms if `read_note()` fails
|
||||
- Use fuzzy matching: search for partial titles
|
||||
|
||||
## Error Handling
|
||||
**Forward References:**
|
||||
- These are normal! Basic Memory links them automatically when target notes are created
|
||||
- Inform users: "I've created forward references that will be linked when you create those notes"
|
||||
|
||||
Common issues to watch for:
|
||||
|
||||
1. **Missing Content**
|
||||
```python
|
||||
try:
|
||||
content = await read_note("Document")
|
||||
except:
|
||||
# Try search instead
|
||||
results = await search_notes("Document")
|
||||
if results and results.primary_results:
|
||||
# Found something similar
|
||||
content = await read_note(results.primary_results[0].permalink)
|
||||
```
|
||||
|
||||
2. **Forward References (Unresolved Relations)**
|
||||
```python
|
||||
response = await write_note(..., verbose=True)
|
||||
# Check for forward references (unresolved relations)
|
||||
forward_refs = []
|
||||
for relation in response.get('relations', []):
|
||||
if not relation.get('target_id'):
|
||||
forward_refs.append(relation.get('to_name'))
|
||||
|
||||
if forward_refs:
|
||||
# This is a feature, not an error! Inform the user about forward references
|
||||
print(f"Note created with forward references to: {forward_refs}")
|
||||
print("These will be automatically linked when those notes are created.")
|
||||
|
||||
# Optionally suggest creating those entities now
|
||||
print("Would you like me to create any of these notes now to complete the connections?")
|
||||
```
|
||||
|
||||
3. **Sync Issues**
|
||||
```python
|
||||
# If information seems outdated
|
||||
activity = await recent_activity(timeframe="1 hour")
|
||||
if not activity or not activity.primary_results:
|
||||
print("It seems there haven't been recent updates. You might need to run 'basic-memory sync'.")
|
||||
```
|
||||
**Sync Issues:**
|
||||
- If information seems outdated, suggest `basic-memory sync`
|
||||
- Use `recent_activity()` to check if content is current
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -395,4 +404,5 @@ Common issues to watch for:
|
||||
- Offer to create summaries of scattered information
|
||||
- Suggest potential missing relations: "I notice this might relate to [topic], would you like me to add that connection?"
|
||||
|
||||
|
||||
Built with ♥️ by Basic Machines
|
||||
|
||||
+101
-19
@@ -10,6 +10,25 @@ Basic Memory provides command line tools for managing your knowledge base. This
|
||||
|
||||
## Core Commands
|
||||
|
||||
### auth (New in v0.13.0)
|
||||
|
||||
Manage OAuth authentication for secure remote access:
|
||||
|
||||
```bash
|
||||
# Test authentication setup
|
||||
basic-memory auth test-auth
|
||||
|
||||
# Register OAuth client
|
||||
basic-memory auth register-client
|
||||
```
|
||||
|
||||
Supports multiple authentication providers:
|
||||
- **Basic Provider**: For development and testing
|
||||
- **Supabase Provider**: For production deployments
|
||||
- **External Providers**: GitHub, Google integration framework
|
||||
|
||||
See [[OAuth Authentication Guide]] for complete setup instructions.
|
||||
|
||||
### sync
|
||||
|
||||
Keeps files and the knowledge graph in sync:
|
||||
@@ -45,9 +64,9 @@ To change the properties, set the following values:
|
||||
```
|
||||
|
||||
Thanks for using Basic Memory!
|
||||
### import
|
||||
### import (Enhanced in v0.13.0)
|
||||
|
||||
Imports external knowledge sources:
|
||||
Imports external knowledge sources with support for project targeting:
|
||||
|
||||
```bash
|
||||
# Claude conversations
|
||||
@@ -59,12 +78,19 @@ basic-memory import claude projects
|
||||
# ChatGPT history
|
||||
basic-memory import chatgpt
|
||||
|
||||
# ChatGPT history
|
||||
# Memory JSON format
|
||||
basic-memory import memory-json /path/to/memory.json
|
||||
|
||||
# Import to specific project (v0.13.0)
|
||||
basic-memory --project=work import claude conversations
|
||||
```
|
||||
|
||||
> **Note**: After importing, run `basic-memory sync` to index the new files.
|
||||
**New in v0.13.0:**
|
||||
- **Project Targeting**: Import directly to specific projects
|
||||
- **Real-time Sync**: Imported content available immediately
|
||||
- **Unified Database**: All imports stored in centralized database
|
||||
|
||||
> **Note**: Changes sync automatically - no manual sync required in v0.13.0.
|
||||
### status
|
||||
|
||||
Shows system status information:
|
||||
@@ -81,28 +107,32 @@ basic-memory status --json
|
||||
```
|
||||
|
||||
|
||||
### project
|
||||
### project (Enhanced in v0.13.0)
|
||||
|
||||
Create multiple projects to manage your knowledge.
|
||||
Manage multiple projects with the new unified database architecture. Projects can now be switched instantly during conversations without restart.
|
||||
|
||||
```bash
|
||||
# List all configured projects
|
||||
# List all configured projects with status
|
||||
basic-memory project list
|
||||
|
||||
# Add a new project
|
||||
basic-memory project add work ~/work-basic-memory
|
||||
# Create a new project
|
||||
basic-memory project create work ~/work-basic-memory
|
||||
|
||||
# Set the default project
|
||||
basic-memory project default work
|
||||
basic-memory project set-default work
|
||||
|
||||
# Remove a project (doesn't delete files)
|
||||
basic-memory project remove personal
|
||||
# Delete a project (doesn't delete files)
|
||||
basic-memory project delete personal
|
||||
|
||||
# Show current project
|
||||
basic-memory project current
|
||||
# Show detailed project statistics
|
||||
basic-memory project info
|
||||
```
|
||||
|
||||
> Be sure to restart Claude Desktop after changing projects.
|
||||
**New in v0.13.0:**
|
||||
- **Unified Database**: All projects share a single database for better performance
|
||||
- **Instant Switching**: Switch projects during conversations without restart
|
||||
- **Enhanced Commands**: Updated project commands with better status information
|
||||
- **Project Statistics**: Detailed info about entities, observations, and relations
|
||||
|
||||
#### Using Projects in Commands
|
||||
|
||||
@@ -122,6 +152,34 @@ You can also set the `BASIC_MEMORY_PROJECT` environment variable:
|
||||
BASIC_MEMORY_PROJECT=work basic-memory sync
|
||||
```
|
||||
|
||||
### tool (Enhanced in v0.13.0)
|
||||
|
||||
Direct access to MCP tools via CLI with new editing and file management capabilities:
|
||||
|
||||
```bash
|
||||
# Create notes
|
||||
basic-memory tool write-note --title "My Note" --content "Content here"
|
||||
|
||||
# Edit notes incrementally (v0.13.0)
|
||||
echo "New content" | basic-memory tool edit-note --title "My Note" --operation append
|
||||
|
||||
# Move notes (v0.13.0)
|
||||
basic-memory tool move-note --identifier "My Note" --destination "archive/my-note.md"
|
||||
|
||||
# Search notes
|
||||
basic-memory tool search-notes --query "authentication"
|
||||
|
||||
# Project management (v0.13.0)
|
||||
basic-memory tool list-projects
|
||||
basic-memory tool switch-project --project-name "work"
|
||||
```
|
||||
|
||||
**New in v0.13.0:**
|
||||
- **edit-note**: Incremental editing (append, prepend, find/replace, section replace)
|
||||
- **move-note**: File management with database consistency
|
||||
- **Project tools**: list-projects, switch-project, get-current-project
|
||||
- **Cross-project operations**: Use `--project` flag with any tool
|
||||
|
||||
### help
|
||||
|
||||
The full list of commands and help for each can be viewed with the `--help` argument.
|
||||
@@ -144,10 +202,11 @@ The full list of commands and help for each can be viewed with the `--help` argu
|
||||
│ --help Show this message and exit. │
|
||||
╰───────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Commands ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ sync Sync knowledge files with the database. │
|
||||
│ status Show sync status between files and database. │
|
||||
│ reset Reset database (drop all tables and recreate). │
|
||||
│ mcp Run the MCP server for Claude Desktop integration. │
|
||||
│ auth OAuth authentication management (v0.13.0) │
|
||||
│ sync Sync knowledge files with the database │
|
||||
│ status Show sync status between files and database │
|
||||
│ reset Reset database (drop all tables and recreate) │
|
||||
│ mcp Run the MCP server for Claude Desktop integration │
|
||||
│ import Import data from various sources │
|
||||
│ tool Direct access to MCP tools via CLI │
|
||||
│ project Manage multiple Basic Memory projects │
|
||||
@@ -302,6 +361,29 @@ You can then use the `/mcp` command in the REPL:
|
||||
• basic-memory: connected
|
||||
```
|
||||
|
||||
## Version Management (New in v0.13.0)
|
||||
|
||||
Basic Memory v0.13.0 introduces automatic version management and multiple installation options:
|
||||
|
||||
```bash
|
||||
# Stable releases
|
||||
pip install basic-memory
|
||||
|
||||
# Beta/pre-releases
|
||||
pip install basic-memory --pre
|
||||
|
||||
# Latest development builds (auto-published)
|
||||
pip install basic-memory --pre --force-reinstall
|
||||
|
||||
# Check current version
|
||||
basic-memory --version
|
||||
```
|
||||
|
||||
**Version Types:**
|
||||
- **Stable**: `0.13.0` (manual git tags)
|
||||
- **Beta**: `0.13.0b1` (manual git tags)
|
||||
- **Development**: `0.12.4.dev26+468a22f` (automatic from commits)
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### Sync Conflicts
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# Claude.ai Integration Guide
|
||||
|
||||
This guide explains how to connect Basic Memory to Claude.ai, enabling Claude to read and write to your personal knowledge base.
|
||||
|
||||
## Overview
|
||||
|
||||
When connected to Claude.ai, Basic Memory provides:
|
||||
- Persistent memory across conversations
|
||||
- Knowledge graph navigation
|
||||
- Note-taking and search capabilities
|
||||
- File organization and management
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Basic Memory MCP server with OAuth enabled
|
||||
2. Public HTTPS URL (or tunneling service for testing)
|
||||
3. Claude.ai account (Free, Pro, or Enterprise)
|
||||
|
||||
## Quick Start (Testing)
|
||||
|
||||
### 1. Start MCP Server with OAuth
|
||||
|
||||
```bash
|
||||
# Enable OAuth with basic provider
|
||||
export FASTMCP_AUTH_ENABLED=true
|
||||
export FASTMCP_AUTH_PROVIDER=basic
|
||||
|
||||
# Start server on all interfaces
|
||||
basic-memory mcp --transport streamable-http --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 2. Make Server Accessible
|
||||
|
||||
For testing, use ngrok:
|
||||
|
||||
```bash
|
||||
# Install ngrok
|
||||
brew install ngrok # macOS
|
||||
# or download from https://ngrok.com
|
||||
|
||||
# Create tunnel
|
||||
ngrok http 8000
|
||||
```
|
||||
|
||||
Note the HTTPS URL (e.g., `https://abc123.ngrok.io`)
|
||||
|
||||
### 3. Register OAuth Client
|
||||
|
||||
```bash
|
||||
# Register a client for Claude
|
||||
basic-memory auth register-client --client-id claude-ai
|
||||
|
||||
# Save the credentials!
|
||||
# Client ID: claude-ai
|
||||
# Client Secret: xxx...
|
||||
```
|
||||
|
||||
### 4. Connect in Claude.ai
|
||||
|
||||
1. Go to Claude.ai → Settings → Integrations
|
||||
2. Click "Add More"
|
||||
3. Enter your server URL: `https://abc123.ngrok.io/mcp`
|
||||
4. Click "Connect"
|
||||
5. Authorize the connection
|
||||
|
||||
### 5. Use in Conversations
|
||||
|
||||
- Click the tools icon (🔧) in the chat
|
||||
- Select "Basic Memory"
|
||||
- Try commands like:
|
||||
- "Create a note about our meeting"
|
||||
- "Search for project ideas"
|
||||
- "Show recent notes"
|
||||
|
||||
## Production Setup
|
||||
|
||||
### 1. Deploy with Supabase Auth
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
FASTMCP_AUTH_ENABLED=true
|
||||
FASTMCP_AUTH_PROVIDER=supabase
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_KEY=your-service-key
|
||||
```
|
||||
|
||||
### 2. Deploy to Cloud
|
||||
|
||||
Options for deployment:
|
||||
|
||||
#### Vercel
|
||||
```json
|
||||
// vercel.json
|
||||
{
|
||||
"functions": {
|
||||
"api/mcp.py": {
|
||||
"runtime": "python3.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Railway
|
||||
```bash
|
||||
# Install Railway CLI
|
||||
brew install railway
|
||||
|
||||
# Deploy
|
||||
railway init
|
||||
railway up
|
||||
```
|
||||
|
||||
#### Docker
|
||||
```dockerfile
|
||||
FROM python:3.12
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pip install -e .
|
||||
CMD ["basic-memory", "mcp", "--transport", "streamable-http"]
|
||||
```
|
||||
|
||||
### 3. Configure for Organization
|
||||
|
||||
For Claude.ai Enterprise:
|
||||
|
||||
1. **Admin Setup**:
|
||||
- Go to Organizational Settings
|
||||
- Navigate to Integrations
|
||||
- Add MCP server URL for all users
|
||||
- Configure allowed scopes
|
||||
|
||||
2. **User Permissions**:
|
||||
- Users connect individually
|
||||
- Each user has their own auth token
|
||||
- Scopes determine access level
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. Use HTTPS
|
||||
- Required for OAuth
|
||||
- Encrypt all data in transit
|
||||
- Use proper SSL certificates
|
||||
|
||||
### 2. Implement Scopes
|
||||
```bash
|
||||
# Configure required scopes
|
||||
FASTMCP_AUTH_REQUIRED_SCOPES=read,write
|
||||
|
||||
# User-specific scopes
|
||||
read: Can search and read notes
|
||||
write: Can create and update notes
|
||||
admin: Can manage all data
|
||||
```
|
||||
|
||||
### 3. Token Security
|
||||
- Short-lived access tokens (1 hour)
|
||||
- Refresh token rotation
|
||||
- Secure token storage
|
||||
|
||||
### 4. Rate Limiting
|
||||
```python
|
||||
# In your MCP server
|
||||
from fastapi import HTTPException
|
||||
from slowapi import Limiter
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
@app.get("/mcp")
|
||||
@limiter.limit("100/minute")
|
||||
async def mcp_endpoint():
|
||||
# Handle MCP requests
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### 1. Custom Tools
|
||||
|
||||
Create specialized tools for Claude:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def analyze_notes(topic: str) -> str:
|
||||
"""Analyze all notes on a specific topic."""
|
||||
# Search and analyze implementation
|
||||
return analysis
|
||||
```
|
||||
|
||||
### 2. Context Preservation
|
||||
|
||||
Use memory:// URLs to maintain context:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def continue_conversation(memory_url: str) -> str:
|
||||
"""Continue from a previous conversation."""
|
||||
context = await build_context(memory_url)
|
||||
return context
|
||||
```
|
||||
|
||||
### 3. Multi-User Support
|
||||
|
||||
With Supabase, each user has isolated data:
|
||||
|
||||
```sql
|
||||
-- Row-level security
|
||||
CREATE POLICY "Users see own notes"
|
||||
ON notes FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
|
||||
1. **"Failed to connect"**
|
||||
- Verify server is running
|
||||
- Check HTTPS is working
|
||||
- Confirm OAuth is enabled
|
||||
|
||||
2. **"Authorization failed"**
|
||||
- Check client credentials
|
||||
- Verify redirect URLs
|
||||
- Review OAuth logs
|
||||
|
||||
3. **"No tools available"**
|
||||
- Ensure MCP tools are registered
|
||||
- Check required scopes
|
||||
- Verify transport type
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable detailed logging:
|
||||
|
||||
```bash
|
||||
# Server side
|
||||
export FASTMCP_LOG_LEVEL=DEBUG
|
||||
export LOGURU_LEVEL=DEBUG
|
||||
|
||||
# Check logs
|
||||
tail -f logs/mcp.log
|
||||
```
|
||||
|
||||
### Test Connection
|
||||
|
||||
```bash
|
||||
# Test OAuth flow
|
||||
curl https://your-server.com/mcp/.well-known/oauth-authorization-server
|
||||
|
||||
# Should return OAuth metadata
|
||||
{
|
||||
"issuer": "https://your-server.com",
|
||||
"authorization_endpoint": "https://your-server.com/auth/authorize",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Regular Backups**
|
||||
- Export your knowledge base
|
||||
- Use version control
|
||||
- Multiple storage locations
|
||||
|
||||
2. **Access Control**
|
||||
- Principle of least privilege
|
||||
- Regular token rotation
|
||||
- Audit access logs
|
||||
|
||||
3. **Performance**
|
||||
- Index frequently searched fields
|
||||
- Optimize large knowledge bases
|
||||
- Use caching where appropriate
|
||||
|
||||
4. **User Experience**
|
||||
- Clear tool descriptions
|
||||
- Helpful error messages
|
||||
- Quick response times
|
||||
|
||||
## Examples
|
||||
|
||||
### Creating Notes
|
||||
|
||||
```
|
||||
User: Create a note about the meeting with the product team
|
||||
|
||||
Claude: I'll create a note about your meeting with the product team.
|
||||
|
||||
[Uses write_note tool]
|
||||
|
||||
Note created: "Meeting with Product Team - 2024-01-15"
|
||||
Location: Work/Meetings/
|
||||
|
||||
I've documented the meeting notes. The note includes the date, attendees, and key discussion points.
|
||||
```
|
||||
|
||||
### Searching Knowledge
|
||||
|
||||
```
|
||||
User: What did we discuss about the API redesign?
|
||||
|
||||
Claude: Let me search for information about the API redesign.
|
||||
|
||||
[Uses search_notes tool]
|
||||
|
||||
I found 3 relevant notes about the API redesign:
|
||||
|
||||
1. "API Redesign Proposal" (2024-01-10)
|
||||
- RESTful architecture
|
||||
- Version 2.0 specifications
|
||||
- Migration timeline
|
||||
|
||||
2. "Technical Review: API Changes" (2024-01-12)
|
||||
- Breaking changes documented
|
||||
- Backwards compatibility plan
|
||||
|
||||
3. "Meeting: API Implementation" (2024-01-14)
|
||||
- Team assignments
|
||||
- Q1 deliverables
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Set up production deployment
|
||||
2. Configure organizational access
|
||||
3. Create custom tools for your workflow
|
||||
4. Implement advanced security features
|
||||
5. Monitor usage and performance
|
||||
|
||||
## Resources
|
||||
|
||||
- [Basic Memory Documentation](../README.md)
|
||||
- [OAuth Setup Guide](OAuth%20Authentication.md)
|
||||
- [MCP Specification](https://modelcontextprotocol.io)
|
||||
- [Claude.ai Help Center](https://support.anthropic.com)
|
||||
@@ -20,14 +20,25 @@ The easiest way to install basic memory is via `uv`. See the [uv installation gu
|
||||
|
||||
### 1. Install Basic Memory
|
||||
|
||||
```bash
|
||||
# Install with uv (recommended).
|
||||
uv tool install basic-memory
|
||||
**v0.13.0 offers multiple installation options:**
|
||||
|
||||
# Or with pip
|
||||
pip install basic-memory
|
||||
```bash
|
||||
# Stable release (recommended)
|
||||
uv tool install basic-memory
|
||||
# or: pip install basic-memory
|
||||
|
||||
# Beta releases (new features, testing)
|
||||
pip install basic-memory --pre
|
||||
|
||||
# Development builds (latest changes)
|
||||
pip install basic-memory --pre --force-reinstall
|
||||
```
|
||||
|
||||
**Version Information:**
|
||||
- **Stable**: Latest tested release (e.g., `0.13.0`)
|
||||
- **Beta**: Pre-release versions (e.g., `0.13.0b1`)
|
||||
- **Development**: Auto-published from git commits (e.g., `0.12.4.dev26+468a22f`)
|
||||
|
||||
> **Important**: You need to install Basic Memory using one of the commands above to use the command line tools.
|
||||
|
||||
Using `uv tool install` will install the basic-memory package in a standalone virtual environment. See the [UV docs](https://docs.astral.sh/uv/concepts/tools/) for more info.
|
||||
@@ -99,30 +110,49 @@ To disable realtime sync, you can update the config. See [[CLI Reference#sync]].
|
||||
To update Basic Memory when new versions are released:
|
||||
|
||||
```bash
|
||||
# Update with uv (recommended)
|
||||
# Update stable release
|
||||
uv tool upgrade basic-memory
|
||||
# or: pip install --upgrade basic-memory
|
||||
|
||||
# Or with pip
|
||||
pip install --upgrade basic-memory
|
||||
# Update to latest beta (v0.13.0)
|
||||
pip install --upgrade basic-memory --pre
|
||||
|
||||
# Get latest development build
|
||||
pip install --upgrade basic-memory --pre --force-reinstall
|
||||
```
|
||||
|
||||
> **Note**: After updating, you'll need to restart Claude Desktop and your sync process for changes to take effect.
|
||||
**v0.13.0 Update Benefits:**
|
||||
- **Fluid project switching** during conversations
|
||||
- **Advanced note editing** capabilities
|
||||
- **Smart file management** with move operations
|
||||
- **Enhanced search** with frontmatter tag support
|
||||
|
||||
### 5. Change the default project directory
|
||||
> **Note**: After updating, restart Claude Desktop for changes to take effect. No sync restart needed in v0.13.0.
|
||||
|
||||
By default, Basic Memory will create a project in the `basic-memory` folder in your home directory. You can change this via the `project` [[CLI Reference#project|cli command]].
|
||||
### 5. Multi-Project Setup (Enhanced in v0.13.0)
|
||||
|
||||
By default, Basic Memory creates a project in `~/basic-memory`. v0.13.0 introduces **fluid project management** - switch between projects instantly during conversations.
|
||||
|
||||
```
|
||||
# Add a new project
|
||||
basic-memory project add work ~/work-basic-memory
|
||||
# Create a new project
|
||||
basic-memory project create work ~/work-basic-memory
|
||||
|
||||
# Set the default project
|
||||
basic-memory project default work
|
||||
basic-memory project set-default work
|
||||
|
||||
# List all configured projects
|
||||
basic-memory project list
|
||||
# List all projects with status
|
||||
basic-memory project list
|
||||
|
||||
# Get detailed project information
|
||||
basic-memory project info
|
||||
```
|
||||
|
||||
**New in v0.13.0:**
|
||||
- **Instant switching**: Change projects during conversations without restart
|
||||
- **Unified database**: All projects in single `~/.basic-memory/memory.db`
|
||||
- **Better performance**: Optimized queries and reduced file I/O
|
||||
- **Session context**: Maintains active project throughout conversations
|
||||
|
||||
## Troubleshooting Installation
|
||||
|
||||
### Common Issues
|
||||
@@ -168,6 +198,7 @@ If you encounter permission errors:
|
||||
---
|
||||
title: Coffee Brewing Methods
|
||||
permalink: coffee-brewing-methods
|
||||
tags: [coffee, brewing, equipment] # v0.13.0: Now searchable!
|
||||
---
|
||||
|
||||
# Coffee Brewing Methods
|
||||
@@ -180,11 +211,10 @@ If you encounter permission errors:
|
||||
- relates_to [[Other Coffee Topics]]
|
||||
```
|
||||
|
||||
5. **Start the sync process** in a Terminal window (optional):
|
||||
```bash
|
||||
basic-memory sync --watch
|
||||
```
|
||||
Keep this running in the background.
|
||||
**v0.13.0 Improvements:**
|
||||
- **Real-time sync**: Changes appear immediately, no background sync needed
|
||||
- **Searchable tags**: Frontmatter tags are now indexed for search
|
||||
- **Better file organization**: Enhanced file management capabilities
|
||||
|
||||
## Using Special Prompts
|
||||
|
||||
@@ -250,14 +280,37 @@ Or directly reference notes using memory:// URLs:
|
||||
You: "Take a look at memory://coffee-brewing-methods and let's discuss how to improve my technique."
|
||||
```
|
||||
|
||||
### Building On Previous Knowledge
|
||||
### Building On Previous Knowledge (Enhanced in v0.13.0)
|
||||
|
||||
Basic Memory enables continuous knowledge building:
|
||||
|
||||
1. **Reference previous discussions** in new conversations
|
||||
2. **Add to existing notes** through conversations
|
||||
3. **Create connections** between related topics
|
||||
4. **Follow relationships** to build comprehensive context
|
||||
2. **Edit notes incrementally** without rewriting entire documents
|
||||
3. **Move and organize notes** as your knowledge base grows
|
||||
4. **Switch between projects** instantly during conversations
|
||||
5. **Search by tags** to find related content quickly
|
||||
6. **Create connections** between related topics
|
||||
7. **Follow relationships** to build comprehensive context
|
||||
|
||||
### v0.13.0 Workflow Examples
|
||||
|
||||
**Incremental Editing:**
|
||||
```
|
||||
You: "Add a section about espresso to my coffee brewing notes"
|
||||
Claude: [Uses edit_note to append new section]
|
||||
```
|
||||
|
||||
**File Organization:**
|
||||
```
|
||||
You: "Move my old meeting notes to an archive folder"
|
||||
Claude: [Uses move_note with database consistency]
|
||||
```
|
||||
|
||||
**Project Switching:**
|
||||
```
|
||||
You: "Switch to my work project and show recent activity"
|
||||
Claude: [Switches projects and shows work-specific content]
|
||||
```
|
||||
|
||||
## Importing Existing Conversations
|
||||
|
||||
@@ -271,17 +324,24 @@ basic-memory import claude conversations
|
||||
basic-memory import chatgpt
|
||||
```
|
||||
|
||||
After importing, the changes will be synced. Initial syncs may take a few moments. You can see info about your project by running `basic-memrory project info`.
|
||||
After importing, changes sync automatically in real-time. You can see project statistics by running `basic-memory project info`.
|
||||
|
||||
## Quick Tips
|
||||
|
||||
- Basic Memory will sync changes from your project in real time.
|
||||
### General Usage
|
||||
- Basic Memory syncs changes in real-time (no manual sync needed)
|
||||
- Use special prompts (Continue Conversation, Recent Activity, Search) to start contextual discussions
|
||||
- Build connections between notes for a richer knowledge graph
|
||||
- Use direct `memory://` URLs with a permalink when you need precise context. See [[User Guide#Using memory // URLs]]
|
||||
- Use git to version control your knowledge base (git integration is on the roadmap)
|
||||
- Use direct `memory://` URLs with permalinks for precise context
|
||||
- Review and edit AI-generated notes for accuracy
|
||||
|
||||
### v0.13.0 Features
|
||||
- **Switch projects instantly**: "Switch to my work project" - no restart needed
|
||||
- **Edit notes incrementally**: "Add a section about..." instead of rewriting
|
||||
- **Organize with moves**: "Move this to my archive folder" with database consistency
|
||||
- **Search by tags**: Frontmatter tags are now searchable
|
||||
- **Try beta builds**: `pip install basic-memory --pre` for latest features
|
||||
|
||||
## Next Steps
|
||||
|
||||
After getting started, explore these areas:
|
||||
@@ -290,4 +350,6 @@ After getting started, explore these areas:
|
||||
2. **Understand the [[Knowledge Format]]** to learn how knowledge is structured
|
||||
3. **Set up [[Obsidian Integration]]** for visual knowledge navigation
|
||||
4. **Learn about [[Canvas]]** visualizations for mapping concepts
|
||||
5. **Review the [[CLI Reference]]** for command line tools
|
||||
5. **Review the [[CLI Reference]]** for command line tools
|
||||
6. **Explore [[OAuth Authentication Guide]]** for secure remote access (v0.13.0)
|
||||
7. **Set up multiple projects** for different knowledge areas (v0.13.0)
|
||||
@@ -0,0 +1,259 @@
|
||||
# OAuth Authentication Guide
|
||||
|
||||
Basic Memory MCP server supports OAuth 2.1 authentication for secure access control. This guide covers setup, testing, and production deployment.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Enable OAuth
|
||||
|
||||
```bash
|
||||
# Set environment variable
|
||||
export FASTMCP_AUTH_ENABLED=true
|
||||
|
||||
# Or use .env file
|
||||
echo "FASTMCP_AUTH_ENABLED=true" >> .env
|
||||
```
|
||||
|
||||
### 2. Start the Server
|
||||
|
||||
```bash
|
||||
basic-memory mcp --transport streamable-http
|
||||
```
|
||||
|
||||
### 3. Test with MCP Inspector
|
||||
|
||||
Since the basic auth provider uses in-memory storage with per-instance secret keys, you'll need to use a consistent approach:
|
||||
|
||||
#### Option A: Use Environment Variable for Secret Key
|
||||
|
||||
```bash
|
||||
# Set a fixed secret key for testing
|
||||
export FASTMCP_AUTH_SECRET_KEY="your-test-secret-key"
|
||||
|
||||
# Start the server
|
||||
FASTMCP_AUTH_ENABLED=true basic-memory mcp --transport streamable-http
|
||||
|
||||
# In another terminal, register a client
|
||||
basic-memory auth register-client --client-id=test-client
|
||||
|
||||
# Get a token using the same secret key
|
||||
basic-memory auth test-auth
|
||||
```
|
||||
|
||||
#### Option B: Use the Built-in Test Endpoint
|
||||
|
||||
```bash
|
||||
# Start server with OAuth
|
||||
FASTMCP_AUTH_ENABLED=true basic-memory mcp --transport streamable-http
|
||||
|
||||
# Register a client and get token in one step
|
||||
curl -X POST http://localhost:8000/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"client_metadata": {"client_name": "Test Client"}}'
|
||||
|
||||
# Use the returned client_id and client_secret
|
||||
curl -X POST http://localhost:8000/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET"
|
||||
```
|
||||
|
||||
### 4. Configure MCP Inspector
|
||||
|
||||
1. Open MCP Inspector
|
||||
2. Configure:
|
||||
- Server URL: `http://localhost:8000/mcp/` (note the trailing slash!)
|
||||
- Transport: `streamable-http`
|
||||
- Custom Headers:
|
||||
```
|
||||
Authorization: Bearer YOUR_ACCESS_TOKEN
|
||||
Accept: application/json, text/event-stream
|
||||
```
|
||||
|
||||
## OAuth Endpoints
|
||||
|
||||
The server provides these OAuth endpoints automatically:
|
||||
|
||||
- `GET /authorize` - Authorization endpoint
|
||||
- `POST /token` - Token exchange endpoint
|
||||
- `GET /.well-known/oauth-authorization-server` - OAuth metadata
|
||||
- `POST /register` - Client registration (if enabled)
|
||||
- `POST /revoke` - Token revocation (if enabled)
|
||||
|
||||
## OAuth Flow
|
||||
|
||||
### Standard Authorization Code Flow
|
||||
|
||||
1. **Get Authorization Code**:
|
||||
```bash
|
||||
curl "http://localhost:8000/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/callback&response_type=code&code_challenge=YOUR_CHALLENGE&code_challenge_method=S256"
|
||||
```
|
||||
|
||||
2. **Exchange Code for Token**:
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=authorization_code&code=AUTH_CODE&client_id=CLIENT_ID&client_secret=CLIENT_SECRET&code_verifier=YOUR_VERIFIER"
|
||||
```
|
||||
|
||||
3. **Use Access Token**:
|
||||
```bash
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Authorization: Bearer ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Using Supabase Auth
|
||||
|
||||
For production, use Supabase for persistent auth storage:
|
||||
|
||||
```bash
|
||||
# Configure environment
|
||||
FASTMCP_AUTH_ENABLED=true
|
||||
FASTMCP_AUTH_PROVIDER=supabase
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_KEY=your-service-key
|
||||
|
||||
# Start server
|
||||
basic-memory mcp --transport streamable-http --host 0.0.0.0
|
||||
```
|
||||
|
||||
### Security Requirements
|
||||
|
||||
1. **HTTPS Required**: OAuth requires HTTPS in production (localhost exception for testing)
|
||||
2. **PKCE Support**: Claude.ai requires PKCE for authorization
|
||||
3. **Token Expiration**: Access tokens expire after 1 hour
|
||||
4. **Scopes**: Supported scopes are `read`, `write`, and `admin`
|
||||
|
||||
## Connecting from Claude.ai
|
||||
|
||||
1. **Deploy with HTTPS**:
|
||||
```bash
|
||||
# Use ngrok for testing
|
||||
ngrok http 8000
|
||||
|
||||
# Or deploy to cloud provider
|
||||
```
|
||||
|
||||
2. **Configure in Claude.ai**:
|
||||
- Go to Settings → Integrations
|
||||
- Click "Add More"
|
||||
- Enter: `https://your-server.com/mcp`
|
||||
- Click "Connect"
|
||||
- Authorize in the popup window
|
||||
|
||||
## Debugging
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **401 Unauthorized**:
|
||||
- Check token is valid and not expired
|
||||
- Verify secret key consistency
|
||||
- Ensure bearer token format: `Authorization: Bearer TOKEN`
|
||||
|
||||
2. **404 on Auth Endpoints**:
|
||||
- Endpoints are at root, not under `/auth`
|
||||
- Use `/authorize` not `/auth/authorize`
|
||||
|
||||
3. **Token Validation Fails**:
|
||||
- Basic provider uses in-memory storage
|
||||
- Tokens don't persist across server restarts
|
||||
- Use same secret key for testing
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check OAuth metadata
|
||||
curl http://localhost:8000/.well-known/oauth-authorization-server
|
||||
|
||||
# Enable debug logging
|
||||
export FASTMCP_LOG_LEVEL=DEBUG
|
||||
|
||||
# Test token directly
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-v
|
||||
```
|
||||
|
||||
## Provider Options
|
||||
|
||||
- **basic**: In-memory storage (development only)
|
||||
- **supabase**: Recommended for production
|
||||
- **github**: GitHub OAuth integration
|
||||
- **google**: Google OAuth integration
|
||||
|
||||
## Example Test Script
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import asyncio
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
async def test_oauth_flow():
|
||||
"""Test the full OAuth flow"""
|
||||
client_id = "test-client"
|
||||
client_secret = "test-secret"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# 1. Get authorization code
|
||||
auth_response = await client.get(
|
||||
"http://localhost:8000/authorize",
|
||||
params={
|
||||
"client_id": client_id,
|
||||
"redirect_uri": "http://localhost:8000/callback",
|
||||
"response_type": "code",
|
||||
"code_challenge": "test-challenge",
|
||||
"code_challenge_method": "S256",
|
||||
"state": "test-state"
|
||||
}
|
||||
)
|
||||
|
||||
# Extract code from redirect URL
|
||||
redirect_url = auth_response.headers.get("Location")
|
||||
parsed = urlparse(redirect_url)
|
||||
code = parse_qs(parsed.query)["code"][0]
|
||||
|
||||
# 2. Exchange for token
|
||||
token_response = await client.post(
|
||||
"http://localhost:8000/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code_verifier": "test-verifier",
|
||||
"redirect_uri": "http://localhost:8000/callback"
|
||||
}
|
||||
)
|
||||
|
||||
tokens = token_response.json()
|
||||
print(f"Access token: {tokens['access_token']}")
|
||||
|
||||
# 3. Test MCP endpoint
|
||||
mcp_response = await client.post(
|
||||
"http://localhost:8000/mcp",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
json={"method": "initialize", "params": {}}
|
||||
)
|
||||
|
||||
print(f"MCP Response: {mcp_response.status_code}")
|
||||
|
||||
asyncio.run(test_oauth_flow())
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `FASTMCP_AUTH_ENABLED` | Enable OAuth authentication | `false` |
|
||||
| `FASTMCP_AUTH_PROVIDER` | OAuth provider type | `basic` |
|
||||
| `FASTMCP_AUTH_SECRET_KEY` | JWT signing key (basic provider) | Random |
|
||||
| `FASTMCP_AUTH_ISSUER_URL` | OAuth issuer URL | `http://localhost:8000` |
|
||||
| `FASTMCP_AUTH_REQUIRED_SCOPES` | Required scopes (comma-separated) | `read,write` |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Supabase OAuth Setup](./Supabase%20OAuth%20Setup.md) - Production auth setup
|
||||
- [External OAuth Providers](./External%20OAuth%20Providers.md) - GitHub, Google integration
|
||||
- [MCP OAuth Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) - Official spec
|
||||
@@ -0,0 +1,311 @@
|
||||
# Supabase OAuth Setup for Basic Memory
|
||||
|
||||
This guide explains how to set up Supabase as the OAuth provider for Basic Memory MCP server in production.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Supabase project (create one at [supabase.com](https://supabase.com))
|
||||
2. Basic Memory MCP server deployed
|
||||
3. Environment variables configuration
|
||||
|
||||
## Overview
|
||||
|
||||
The Supabase OAuth provider offers:
|
||||
- Production-ready authentication with persistent storage
|
||||
- User management through Supabase Auth
|
||||
- JWT token validation
|
||||
- Integration with Supabase's security features
|
||||
- Support for social logins (GitHub, Google, etc.)
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Get Supabase Credentials
|
||||
|
||||
From your Supabase project dashboard:
|
||||
|
||||
1. Go to Settings > API
|
||||
2. Copy these values:
|
||||
- `Project URL` → `SUPABASE_URL`
|
||||
- `anon public` key → `SUPABASE_ANON_KEY`
|
||||
- `service_role` key → `SUPABASE_SERVICE_KEY` (keep this secret!)
|
||||
- JWT secret → `SUPABASE_JWT_SECRET` (under Settings > API > JWT Settings)
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Create a `.env` file:
|
||||
|
||||
```bash
|
||||
# Enable OAuth
|
||||
FASTMCP_AUTH_ENABLED=true
|
||||
FASTMCP_AUTH_PROVIDER=supabase
|
||||
|
||||
# Your MCP server URL
|
||||
FASTMCP_AUTH_ISSUER_URL=https://your-mcp-server.com
|
||||
|
||||
# Supabase configuration
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_KEY=your-service-key
|
||||
SUPABASE_JWT_SECRET=your-jwt-secret
|
||||
|
||||
# Allowed OAuth clients (comma-separated)
|
||||
SUPABASE_ALLOWED_CLIENTS=web-app,mobile-app,cli-tool
|
||||
|
||||
# Required scopes
|
||||
FASTMCP_AUTH_REQUIRED_SCOPES=read,write
|
||||
```
|
||||
|
||||
### 3. Create OAuth Clients Table (Optional)
|
||||
|
||||
For production, create a table to store OAuth clients in Supabase:
|
||||
|
||||
```sql
|
||||
CREATE TABLE oauth_clients (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
client_id TEXT UNIQUE NOT NULL,
|
||||
client_secret TEXT NOT NULL,
|
||||
name TEXT,
|
||||
redirect_uris TEXT[],
|
||||
allowed_scopes TEXT[],
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create an index for faster lookups
|
||||
CREATE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id);
|
||||
|
||||
-- RLS policies
|
||||
ALTER TABLE oauth_clients ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Only service role can manage clients
|
||||
CREATE POLICY "Service role can manage clients" ON oauth_clients
|
||||
FOR ALL USING (auth.jwt()->>'role' = 'service_role');
|
||||
```
|
||||
|
||||
### 4. Set Up Auth Flow
|
||||
|
||||
The Supabase OAuth provider handles the following flow:
|
||||
|
||||
1. **Client Authorization Request**
|
||||
```
|
||||
GET /authorize?client_id=web-app&redirect_uri=https://app.com/callback
|
||||
```
|
||||
|
||||
2. **Redirect to Supabase Auth**
|
||||
- User authenticates with Supabase (email/password, magic link, or social login)
|
||||
- Supabase redirects back to your MCP server
|
||||
|
||||
3. **Token Exchange**
|
||||
```
|
||||
POST /token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code&code=xxx&client_id=web-app
|
||||
```
|
||||
|
||||
4. **Access Protected Resources**
|
||||
```
|
||||
GET /mcp
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### 5. Enable Social Logins (Optional)
|
||||
|
||||
In Supabase dashboard:
|
||||
|
||||
1. Go to Authentication > Providers
|
||||
2. Enable desired providers (GitHub, Google, etc.)
|
||||
3. Configure OAuth apps for each provider
|
||||
4. Users can now log in via social providers
|
||||
|
||||
### 6. User Management
|
||||
|
||||
Supabase provides:
|
||||
- User registration and login
|
||||
- Password reset flows
|
||||
- Email verification
|
||||
- User metadata storage
|
||||
- Admin APIs for user management
|
||||
|
||||
Access user data in your MCP tools:
|
||||
|
||||
```python
|
||||
# In your MCP tool
|
||||
async def get_user_info(ctx: Context):
|
||||
# The token is already validated by the OAuth middleware
|
||||
user_id = ctx.auth.user_id
|
||||
email = ctx.auth.email
|
||||
|
||||
# Use Supabase client to get more user data if needed
|
||||
user = await supabase.auth.admin.get_user_by_id(user_id)
|
||||
return user
|
||||
```
|
||||
|
||||
### 7. Production Deployment
|
||||
|
||||
1. **Environment Security**
|
||||
- Never expose `SUPABASE_SERVICE_KEY`
|
||||
- Use environment variables, not hardcoded values
|
||||
- Rotate keys periodically
|
||||
|
||||
2. **HTTPS Required**
|
||||
- Always use HTTPS in production
|
||||
- Configure proper SSL certificates
|
||||
|
||||
3. **Rate Limiting**
|
||||
- Implement rate limiting for auth endpoints
|
||||
- Use Supabase's built-in rate limiting
|
||||
|
||||
4. **Monitoring**
|
||||
- Monitor auth logs in Supabase dashboard
|
||||
- Set up alerts for suspicious activity
|
||||
|
||||
## Testing
|
||||
|
||||
### Local Development
|
||||
|
||||
For local testing with Supabase:
|
||||
|
||||
```bash
|
||||
# Start MCP server with Supabase auth
|
||||
FASTMCP_AUTH_ENABLED=true \
|
||||
FASTMCP_AUTH_PROVIDER=supabase \
|
||||
SUPABASE_URL=http://localhost:54321 \
|
||||
SUPABASE_ANON_KEY=your-local-anon-key \
|
||||
bm mcp --transport streamable-http
|
||||
```
|
||||
|
||||
### Test Authentication Flow
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import asyncio
|
||||
|
||||
async def test_supabase_auth():
|
||||
# 1. Register/login with Supabase directly
|
||||
supabase_url = "https://your-project.supabase.co"
|
||||
|
||||
# 2. Get MCP authorization URL
|
||||
response = await httpx.get(
|
||||
"http://localhost:8000/authorize",
|
||||
params={
|
||||
"client_id": "web-app",
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"response_type": "code",
|
||||
}
|
||||
)
|
||||
|
||||
# 3. User logs in via Supabase
|
||||
# 4. Exchange code for MCP tokens
|
||||
# 5. Access protected resources
|
||||
|
||||
asyncio.run(test_supabase_auth())
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom User Metadata
|
||||
|
||||
Store additional user data in Supabase:
|
||||
|
||||
```sql
|
||||
-- Add custom fields to auth.users
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}';
|
||||
|
||||
-- Or create a separate profiles table
|
||||
CREATE TABLE profiles (
|
||||
id UUID REFERENCES auth.users PRIMARY KEY,
|
||||
username TEXT UNIQUE,
|
||||
avatar_url TEXT,
|
||||
bio TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### Row Level Security (RLS)
|
||||
|
||||
Protect user data with RLS:
|
||||
|
||||
```sql
|
||||
-- Users can only access their own data
|
||||
CREATE POLICY "Users can view own profile" ON profiles
|
||||
FOR SELECT USING (auth.uid() = id);
|
||||
|
||||
CREATE POLICY "Users can update own profile" ON profiles
|
||||
FOR UPDATE USING (auth.uid() = id);
|
||||
```
|
||||
|
||||
### Custom Claims
|
||||
|
||||
Add custom claims to JWT tokens:
|
||||
|
||||
```sql
|
||||
-- Function to add custom claims
|
||||
CREATE OR REPLACE FUNCTION custom_jwt_claims()
|
||||
RETURNS JSON AS $$
|
||||
BEGIN
|
||||
RETURN json_build_object(
|
||||
'user_role', current_setting('request.jwt.claims')::json->>'user_role',
|
||||
'permissions', current_setting('request.jwt.claims')::json->>'permissions'
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Invalid JWT Secret**
|
||||
- Ensure `SUPABASE_JWT_SECRET` matches your Supabase project
|
||||
- Check Settings > API > JWT Settings in Supabase dashboard
|
||||
|
||||
2. **CORS Errors**
|
||||
- Configure CORS in your MCP server
|
||||
- Add allowed origins in Supabase dashboard
|
||||
|
||||
3. **Token Validation Fails**
|
||||
- Verify tokens are being passed correctly
|
||||
- Check token expiration times
|
||||
- Ensure scopes match requirements
|
||||
|
||||
4. **User Not Found**
|
||||
- Confirm user exists in Supabase Auth
|
||||
- Check if email is verified (if required)
|
||||
- Verify client permissions
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
|
||||
```bash
|
||||
export FASTMCP_LOG_LEVEL=DEBUG
|
||||
export SUPABASE_LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Secure Keys**: Never commit secrets to version control
|
||||
2. **Least Privilege**: Use minimal required scopes
|
||||
3. **Token Rotation**: Implement refresh token rotation
|
||||
4. **Audit Logs**: Monitor authentication events
|
||||
5. **Rate Limiting**: Protect against brute force attacks
|
||||
6. **HTTPS Only**: Always use encrypted connections
|
||||
|
||||
## Migration from Basic Auth
|
||||
|
||||
To migrate from the basic auth provider:
|
||||
|
||||
1. Export existing user data
|
||||
2. Import users into Supabase Auth
|
||||
3. Update client applications to use new auth flow
|
||||
4. Gradually transition users to Supabase login
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Set up email templates in Supabase
|
||||
- Configure password policies
|
||||
- Implement MFA (multi-factor authentication)
|
||||
- Add social login providers
|
||||
- Create admin dashboard for user management
|
||||
+127
-26
@@ -388,6 +388,59 @@ Maintain context for complex projects over time:
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Note Editing (New in v0.13.0)
|
||||
|
||||
**Edit notes incrementally without rewriting entire documents:**
|
||||
|
||||
```
|
||||
💬 "Add a new section about deployment to my API documentation"
|
||||
🤖 [Uses edit_note to append new section]
|
||||
|
||||
💬 "Update the date at the top of my meeting notes"
|
||||
🤖 [Uses edit_note to prepend new timestamp]
|
||||
|
||||
💬 "Replace the implementation section in my design doc"
|
||||
🤖 [Uses edit_note to replace specific section]
|
||||
```
|
||||
|
||||
Available editing operations:
|
||||
- **Append**: Add content to end of notes
|
||||
- **Prepend**: Add content to beginning of notes
|
||||
- **Replace Section**: Replace content under specific headers
|
||||
- **Find & Replace**: Simple text replacements with validation
|
||||
|
||||
### File Management (New in v0.13.0)
|
||||
|
||||
**Move and organize notes with full database consistency:**
|
||||
|
||||
```
|
||||
💬 "Move my old meeting notes to the archive folder"
|
||||
🤖 [Uses move_note with automatic folder creation and database updates]
|
||||
|
||||
💬 "Reorganize my project files into a better structure"
|
||||
🤖 [Moves files while maintaining search indexes and links]
|
||||
```
|
||||
|
||||
Move operations include:
|
||||
- **Database Consistency**: Updates file paths, permalinks, and checksums
|
||||
- **Search Reindexing**: Maintains search functionality after moves
|
||||
- **Folder Creation**: Automatically creates destination directories
|
||||
- **Project Isolation**: Moves are contained within the current project
|
||||
- **Rollback Protection**: Ensures data integrity during failed operations
|
||||
|
||||
### Enhanced Search (New in v0.13.0)
|
||||
|
||||
**Frontmatter tags are now searchable:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Coffee Brewing Methods
|
||||
tags: [coffee, brewing, equipment]
|
||||
---
|
||||
```
|
||||
|
||||
Now searchable by: "coffee", "brewing", "equipment", or "Coffee Brewing Methods"
|
||||
|
||||
### Importing External Knowledge
|
||||
|
||||
Import existing conversations:
|
||||
@@ -398,9 +451,12 @@ basic-memory import claude conversations
|
||||
|
||||
# From ChatGPT
|
||||
basic-memory import chatgpt
|
||||
|
||||
# Target specific projects (v0.13.0)
|
||||
basic-memory --project=work import claude conversations
|
||||
```
|
||||
|
||||
After importing, run `basic-memory sync` to index everything.
|
||||
After importing, changes sync automatically in real-time.
|
||||
|
||||
### Obsidian Integration
|
||||
|
||||
@@ -460,12 +516,47 @@ basic-memory import claude conversations
|
||||
basic-memory import chatgpt
|
||||
```
|
||||
|
||||
## Multiple Projects
|
||||
## Multiple Projects (v0.13.0)
|
||||
|
||||
Basic Memory supports managing multiple separate knowledge bases through projects. This feature allows you to maintain
|
||||
separate knowledge graphs for different purposes (e.g., personal notes, work projects, research topics).
|
||||
Basic Memory v0.13.0 introduces **fluid project management** - the ability to switch between projects instantly during conversations without restart. This allows you to maintain separate knowledge graphs for different purposes while seamlessly switching between them.
|
||||
|
||||
Basic Memory keeps a list of projects in a config file: ` ~/.basic-memory/config.json`
|
||||
### Instant Project Switching (New in v0.13.0)
|
||||
|
||||
**Switch projects during conversations:**
|
||||
|
||||
```
|
||||
💬 "What projects do I have?"
|
||||
🤖 Available projects:
|
||||
• main (current, default)
|
||||
• work-notes
|
||||
• personal-journal
|
||||
• code-snippets
|
||||
|
||||
💬 "Switch to work-notes"
|
||||
🤖 ✓ Switched to work-notes project
|
||||
|
||||
Project Summary:
|
||||
• 47 entities
|
||||
• 125 observations
|
||||
• 23 relations
|
||||
|
||||
💬 "What did I work on yesterday?"
|
||||
🤖 [Shows recent activity from work-notes project]
|
||||
```
|
||||
|
||||
### Project-Specific Operations (New in v0.13.0)
|
||||
|
||||
Some MCP tools support optional project parameters for targeting specific projects:
|
||||
|
||||
```
|
||||
💬 "Create a note about this meeting in my personal-notes project"
|
||||
🤖 [Creates note in personal-notes project]
|
||||
|
||||
💬 "Switch to my work project"
|
||||
🤖 [Switches project context, then all operations work within that project]
|
||||
```
|
||||
|
||||
**Note**: Operations like search, move, and edit work within the currently active project. To work with content in different projects, switch to that project first or use the project parameter where supported.
|
||||
|
||||
### Managing Projects
|
||||
|
||||
@@ -474,16 +565,16 @@ Basic Memory keeps a list of projects in a config file: ` ~/.basic-memory/config
|
||||
basic-memory project list
|
||||
|
||||
# Add a new project
|
||||
basic-memory project add work ~/work-basic-memory
|
||||
basic-memory project create work ~/work-basic-memory
|
||||
|
||||
# Set the default project
|
||||
basic-memory project default work
|
||||
basic-memory project set-default work
|
||||
|
||||
# Remove a project (doesn't delete files)
|
||||
basic-memory project remove personal
|
||||
basic-memory project delete personal
|
||||
|
||||
# Show current project
|
||||
basic-memory project current
|
||||
# Show current project statistics
|
||||
basic-memory project info
|
||||
```
|
||||
|
||||
### Using Projects in Commands
|
||||
@@ -504,23 +595,32 @@ You can also set the `BASIC_MEMORY_PROJECT` environment variable:
|
||||
BASIC_MEMORY_PROJECT=work basic-memory sync
|
||||
```
|
||||
|
||||
### Project Isolation
|
||||
### Unified Database Architecture (New in v0.13.0)
|
||||
|
||||
Each project maintains:
|
||||
Basic Memory v0.13.0 uses a unified database architecture:
|
||||
|
||||
- Its own collection of markdown files in the specified directory
|
||||
- A separate SQLite database for that project
|
||||
- Complete knowledge graph isolation from other projects
|
||||
- **Single Database**: All projects share `~/.basic-memory/memory.db`
|
||||
- **Project Isolation**: Proper data separation with project context
|
||||
- **Better Performance**: Optimized queries and reduced file I/O
|
||||
- **Easier Backup**: Single database file contains all project data
|
||||
- **Session Context**: Maintains active project throughout conversations
|
||||
|
||||
## Workflow Tips
|
||||
|
||||
1. Run sync in watch mode for automatic updates
|
||||
2. Use git for version control of your knowledge base
|
||||
3. Review and edit AI-created content for accuracy
|
||||
4. Periodically organize and refine your knowledge structure
|
||||
5. Build rich connections between related ideas
|
||||
6. Use forward references to plan future documentation
|
||||
7. Start conversations with special prompts to leverage existing knowledge
|
||||
### General Workflow
|
||||
1. **Project Organization**: Use multiple projects to separate different areas (work, personal, research)
|
||||
2. **Session Context**: Switch projects during conversations without restart (v0.13.0)
|
||||
3. **Real-time Sync**: Changes sync automatically - no need to run watch mode
|
||||
4. **Review Content**: Edit AI-created content for accuracy
|
||||
5. **Build Connections**: Create rich relationships between related ideas
|
||||
6. **Use Special Prompts**: Start conversations with context from your knowledge base
|
||||
|
||||
### v0.13.0 Workflow Enhancements
|
||||
7. **Incremental Editing**: Use edit_note for small changes instead of rewriting entire documents
|
||||
8. **File Organization**: Move and reorganize notes as your knowledge base grows
|
||||
9. **Project-Specific Creation**: Create notes in specific projects using project parameters
|
||||
10. **Search Tags**: Use frontmatter tags to improve content discoverability
|
||||
11. **Project Statistics**: Monitor project growth and activity with project info commands
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -528,9 +628,8 @@ Each project maintains:
|
||||
|
||||
If changes aren't showing up:
|
||||
|
||||
1. Verify `basic-memory sync --watch` is running
|
||||
2. Run `basic-memory status` to check system state
|
||||
3. Try a manual sync with `basic-memory sync`
|
||||
1. Run `basic-memory status` to check system state
|
||||
2. Try a manual sync with `basic-memory sync`
|
||||
|
||||
### Missing Content
|
||||
|
||||
@@ -553,4 +652,6 @@ If relations aren't working:
|
||||
- implements [[Knowledge Format]] (How knowledge is structured)
|
||||
- relates_to [[Getting Started with Basic Memory]] (Setup and first steps)
|
||||
- relates_to [[Canvas]] (Creating visual knowledge maps)
|
||||
- relates_to [[CLI Reference]] (Command line tools)
|
||||
- relates_to [[CLI Reference]] (Command line tools)
|
||||
- enhanced_in_v0.13.0 [[OAuth Authentication Guide]] (Production authentication)
|
||||
- enhanced_in_v0.13.0 [[Project Management]] (Multi-project workflows)
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Test Note Creation - Basic Functionality
|
||||
type: note
|
||||
permalink: testing/test-note-creation-basic-functionality
|
||||
tags:
|
||||
- '["testing"'
|
||||
- '"core-functionality"'
|
||||
- '"note-creation"]'
|
||||
---
|
||||
|
||||
---
|
||||
title: Test Note Creation - Basic Functionality
|
||||
tags: [testing, core-functionality, note-creation, edited]
|
||||
test_status: active
|
||||
last_edited: 2025-06-01
|
||||
---
|
||||
|
||||
# Test Note Creation - Basic Functionality
|
||||
|
||||
## Test Status: COMPREHENSIVE TESTING IN PROGRESS
|
||||
Testing basic note creation with various content types and structures.
|
||||
|
||||
## Content Types Tested
|
||||
- Plain text content ✓
|
||||
- Markdown formatting **bold**, *italic*
|
||||
- Lists:
|
||||
- Bullet points
|
||||
- Numbered items
|
||||
- Code blocks: `inline code`
|
||||
|
||||
```python
|
||||
# Block code
|
||||
def test_function():
|
||||
return "Hello, Basic Memory!"
|
||||
```
|
||||
|
||||
## Special Characters
|
||||
- Unicode: café, naïve, résumé
|
||||
- Emojis: 🚀 🔬 📝
|
||||
- Symbols: @#$%^&*()
|
||||
|
||||
## Frontmatter Testing
|
||||
This note should have proper frontmatter parsing.
|
||||
|
||||
## Relations to Test
|
||||
- connects_to [[Another Test Note]]
|
||||
- validates [[Core Functionality Tests]]
|
||||
|
||||
## Observations
|
||||
- [success] Note creation initiated
|
||||
- [test] Content variety included
|
||||
- [validation] Special characters included
|
||||
|
||||
|
||||
## Edit Test Results
|
||||
- [success] Note reading via title lookup ✓
|
||||
- [success] Search functionality returns relevant results ✓
|
||||
- [success] Special characters (unicode, emojis) preserved ✓
|
||||
- [test] Now testing append edit operation ✓
|
||||
|
||||
## Performance Notes
|
||||
- Note creation: Instantaneous
|
||||
- Note reading: Fast response
|
||||
- Search: Good relevance scoring
|
||||
|
||||
## Next Tests
|
||||
- Edit operations (append, prepend, find_replace)
|
||||
- Move operations
|
||||
- Cross-project functionality
|
||||
Binary file not shown.
@@ -1,26 +0,0 @@
|
||||
# Basic Memory Installer
|
||||
|
||||
This installer configures Basic Memory to work with Claude Desktop.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the latest installer from the [releases page](https://github.com/basicmachines-co/basic-memory/releases)
|
||||
2. Unzip the downloaded file
|
||||
3. Since the app is currently unsigned, you'll need to:
|
||||
|
||||
On your Mac, choose Apple menu > System Settings, then click Privacy & Security in the sidebar. (You may need to
|
||||
scroll down.)
|
||||
|
||||
Go to Security, then click Open.
|
||||
|
||||
Click Open Anyway.
|
||||
|
||||
This button is available for about an hour after you try to open the app.
|
||||
|
||||
Enter your login password, then click OK.
|
||||
|
||||
https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac
|
||||
|
||||
5. Restart Claude Desktop
|
||||
|
||||
The warning only appears the first time you open the app. Future updates will include proper code signing.
|
||||
@@ -1,64 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect x="0" y="0" width="512" height="512" rx="64" fill="#111111"/>
|
||||
|
||||
<!-- Define arrowhead marker -->
|
||||
<defs>
|
||||
<marker id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
orient="auto">
|
||||
<path d="M 0 0 L 10 5 L 0 10 Z"
|
||||
fill="#00cc00"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- State 1 (initial) -->
|
||||
<circle cx="156" cy="256" r="30" fill="none" stroke="#00cc00" stroke-width="3"/>
|
||||
|
||||
<!-- State 2 (accept) -->
|
||||
<circle cx="356" cy="176" r="34" fill="none" stroke="#00cc00" stroke-width="3"/>
|
||||
<circle cx="356" cy="176" r="28" fill="none" stroke="#00cc00" stroke-width="3"/>
|
||||
|
||||
<!-- State 3 (accept) -->
|
||||
<circle cx="356" cy="336" r="34" fill="none" stroke="#00cc00" stroke-width="3"/>
|
||||
<circle cx="356" cy="336" r="28" fill="none" stroke="#00cc00" stroke-width="3"/>
|
||||
|
||||
<!-- Initial arrow -->
|
||||
<path d="M 96 256 L 126 256"
|
||||
stroke="#00cc00" stroke-width="3" fill="none"
|
||||
marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- State transitions -->
|
||||
<!-- 1 -> 2 -->
|
||||
<path d="M 180 240
|
||||
Q 260 200, 320 176"
|
||||
stroke="#00cc00" stroke-width="3" fill="none"
|
||||
marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- 1 -> 3 -->
|
||||
<path d="M 180 272
|
||||
Q 260 312, 320 336"
|
||||
stroke="#00cc00" stroke-width="3" fill="none"
|
||||
marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- Self loops -->
|
||||
<path d="M 356 142
|
||||
Q 396 142, 396 176
|
||||
Q 396 210, 356 210
|
||||
Q 316 210, 316 176
|
||||
Q 316 142, 356 142"
|
||||
stroke="#00cc00" stroke-width="2" fill="none"
|
||||
marker-end="url(#arrowhead)"/>
|
||||
|
||||
<path d="M 356 302
|
||||
Q 396 302, 396 336
|
||||
Q 396 370, 356 370
|
||||
Q 316 370, 316 336
|
||||
Q 316 302, 356 302"
|
||||
stroke="#00cc00" stroke-width="2" fill="none"
|
||||
marker-end="url(#arrowhead)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,93 +0,0 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Use tkinter for GUI alerts on macOS
|
||||
if sys.platform == "darwin":
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
|
||||
def ensure_uv_installed():
|
||||
"""Check if uv is installed, install if not."""
|
||||
try:
|
||||
subprocess.run(["uv", "--version"], capture_output=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("Installing uv package manager...")
|
||||
subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-LsSf",
|
||||
"https://astral.sh/uv/install.sh",
|
||||
"|",
|
||||
"sh",
|
||||
],
|
||||
shell=True,
|
||||
)
|
||||
|
||||
|
||||
def get_config_path():
|
||||
"""Get Claude Desktop config path for current platform."""
|
||||
if sys.platform == "darwin":
|
||||
return Path.home() / "Library/Application Support/Claude/claude_desktop_config.json"
|
||||
elif sys.platform == "win32":
|
||||
return Path.home() / "AppData/Roaming/Claude/claude_desktop_config.json"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||
|
||||
|
||||
def update_claude_config():
|
||||
"""Update Claude Desktop config to include basic-memory."""
|
||||
config_path = get_config_path()
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing config or create new
|
||||
if config_path.exists():
|
||||
config = json.loads(config_path.read_text())
|
||||
else:
|
||||
config = {"mcpServers": {}}
|
||||
|
||||
# Add/update basic-memory config
|
||||
config["mcpServers"]["basic-memory"] = {
|
||||
"command": "uvx",
|
||||
"args": ["basic-memory@latest", "mcp"],
|
||||
}
|
||||
|
||||
# Write back config
|
||||
config_path.write_text(json.dumps(config, indent=2))
|
||||
|
||||
|
||||
def print_completion_message():
|
||||
"""Show completion message with helpful tips."""
|
||||
message = """Installation complete! Basic Memory is now available in Claude Desktop.
|
||||
|
||||
Please restart Claude Desktop for changes to take effect.
|
||||
|
||||
Quick Start:
|
||||
1. You can run sync directly using: uvx basic-memory sync
|
||||
2. Optionally, install globally with: uv pip install basic-memory
|
||||
|
||||
Built with ♥️ by Basic Machines."""
|
||||
|
||||
if sys.platform == "darwin":
|
||||
# Show GUI message on macOS
|
||||
root = tk.Tk()
|
||||
root.withdraw() # Hide the main window
|
||||
messagebox.showinfo("Basic Memory", message)
|
||||
root.destroy()
|
||||
else:
|
||||
# Fallback to console output
|
||||
print(message)
|
||||
|
||||
|
||||
def main():
|
||||
print("Welcome to Basic Memory installer")
|
||||
ensure_uv_installed()
|
||||
print("Configuring Claude Desktop...")
|
||||
update_claude_config()
|
||||
print_completion_message()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Convert SVG to PNG at various required sizes
|
||||
rsvg-convert -h 16 -w 16 icon.svg > icon_16x16.png
|
||||
rsvg-convert -h 32 -w 32 icon.svg > icon_32x32.png
|
||||
rsvg-convert -h 128 -w 128 icon.svg > icon_128x128.png
|
||||
rsvg-convert -h 256 -w 256 icon.svg > icon_256x256.png
|
||||
rsvg-convert -h 512 -w 512 icon.svg > icon_512x512.png
|
||||
|
||||
# Create iconset directory
|
||||
mkdir -p Basic.iconset
|
||||
|
||||
# Move files into iconset with Mac-specific names
|
||||
cp icon_16x16.png Basic.iconset/icon_16x16.png
|
||||
cp icon_32x32.png Basic.iconset/icon_16x16@2x.png
|
||||
cp icon_32x32.png Basic.iconset/icon_32x32.png
|
||||
cp icon_128x128.png Basic.iconset/icon_32x32@2x.png
|
||||
cp icon_256x256.png Basic.iconset/icon_128x128.png
|
||||
cp icon_512x512.png Basic.iconset/icon_256x256.png
|
||||
cp icon_512x512.png Basic.iconset/icon_512x512.png
|
||||
|
||||
# Convert iconset to icns
|
||||
iconutil -c icns Basic.iconset
|
||||
|
||||
# Clean up
|
||||
rm -rf Basic.iconset
|
||||
rm icon_*.png
|
||||
@@ -1,40 +0,0 @@
|
||||
from cx_Freeze import setup, Executable
|
||||
import sys
|
||||
|
||||
# Build options for all platforms
|
||||
build_exe_options = {
|
||||
"packages": ["json", "pathlib"],
|
||||
"excludes": ["unittest", "pydoc", "test"],
|
||||
}
|
||||
|
||||
# Platform-specific options
|
||||
if sys.platform == "win32":
|
||||
base = "Win32GUI" # Use GUI base for Windows
|
||||
build_exe_options.update(
|
||||
{
|
||||
"include_msvcr": True,
|
||||
}
|
||||
)
|
||||
target_name = "Basic Memory Installer.exe"
|
||||
else: # darwin
|
||||
base = None # Don't use GUI base for macOS
|
||||
target_name = "Basic Memory Installer"
|
||||
|
||||
executables = [
|
||||
Executable(script="installer.py", target_name=target_name, base=base, icon="Basic.icns")
|
||||
]
|
||||
|
||||
setup(
|
||||
name="basic-memory",
|
||||
version=open("../pyproject.toml").read().split('version = "', 1)[1].split('"', 1)[0],
|
||||
description="Basic Memory - Local-first knowledge management",
|
||||
options={
|
||||
"build_exe": build_exe_options,
|
||||
"bdist_mac": {
|
||||
"bundle_name": "Basic Memory Installer",
|
||||
"iconfile": "Basic.icns",
|
||||
"codesign_identity": "-", # Force ad-hoc signing
|
||||
},
|
||||
},
|
||||
executables=executables,
|
||||
)
|
||||
+40
-17
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "basic-memory"
|
||||
version = "0.12.1"
|
||||
dynamic = ["version"]
|
||||
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12.1"
|
||||
@@ -30,6 +30,10 @@ dependencies = [
|
||||
"alembic>=1.14.1",
|
||||
"qasync>=0.27.1",
|
||||
"pillow>=11.1.0",
|
||||
"pybars3>=0.9.7",
|
||||
"fastmcp>=2.3.4",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dotenv>=1.1.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -43,7 +47,7 @@ basic-memory = "basic_memory.cli.main:app"
|
||||
bm = "basic_memory.cli.main:app"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
@@ -75,6 +79,15 @@ dev-dependencies = [
|
||||
"pyqt6>=6.8.1",
|
||||
]
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "uv-dynamic-versioning"
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
vcs = "git"
|
||||
style = "pep440"
|
||||
bump = true
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src/"]
|
||||
exclude = ["**/__pycache__"]
|
||||
@@ -85,23 +98,33 @@ reportMissingTypeStubs = false
|
||||
pythonVersion = "3.12"
|
||||
|
||||
|
||||
[tool.semantic_release]
|
||||
version_variables = [
|
||||
"src/basic_memory/__init__.py:__version__",
|
||||
]
|
||||
version_toml = [
|
||||
"pyproject.toml:project.version",
|
||||
]
|
||||
major_on_zero = false
|
||||
branch = "main"
|
||||
changelog_file = "CHANGELOG.md"
|
||||
build_command = "pip install uv && uv build"
|
||||
dist_path = "dist/"
|
||||
upload_to_pypi = true
|
||||
commit_message = "chore(release): {version} [skip ci]"
|
||||
|
||||
[tool.coverage.run]
|
||||
concurrency = ["thread", "gevent"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if self.debug:",
|
||||
"if settings.DEBUG",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if 0:",
|
||||
"if __name__ == .__main__.:",
|
||||
"class .*\\bProtocol\\):",
|
||||
"@(abc\\.)?abstractmethod",
|
||||
]
|
||||
|
||||
# Exclude specific modules that are difficult to test comprehensively
|
||||
omit = [
|
||||
"*/external_auth_provider.py", # External HTTP calls to OAuth providers
|
||||
"*/supabase_auth_provider.py", # External HTTP calls to Supabase APIs
|
||||
"*/watch_service.py", # File system watching - complex integration testing
|
||||
"*/background_sync.py", # Background processes
|
||||
"*/cli/main.py", # CLI entry point
|
||||
"*/mcp/tools/project_management.py", # Covered by integration tests
|
||||
]
|
||||
|
||||
[tool.logfire]
|
||||
ignore_no_config = true
|
||||
ignore_no_config = true
|
||||
@@ -1,3 +1,9 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
__version__ = "0.12.1"
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version("basic-memory")
|
||||
except Exception: # pragma: no cover
|
||||
# Fallback if package not installed (e.g., during development)
|
||||
__version__ = "0.0.0" # pragma: no cover
|
||||
|
||||
@@ -13,7 +13,7 @@ from basic_memory.models import Base
|
||||
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
|
||||
os.environ["BASIC_MEMORY_ENV"] = "test"
|
||||
|
||||
from basic_memory.config import config as app_config
|
||||
from basic_memory.config import app_config
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""add projects table
|
||||
|
||||
Revision ID: 5fe1ab1ccebe
|
||||
Revises: cc7172b46608
|
||||
Create Date: 2025-05-14 09:05:18.214357
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5fe1ab1ccebe"
|
||||
down_revision: Union[str, None] = "cc7172b46608"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"project",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("permalink", sa.String(), nullable=False),
|
||||
sa.Column("path", sa.String(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("is_default"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("permalink"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
"ix_project_created_at", ["created_at"], unique=False, if_not_exists=True
|
||||
)
|
||||
batch_op.create_index("ix_project_name", ["name"], unique=True, if_not_exists=True)
|
||||
batch_op.create_index("ix_project_path", ["path"], unique=False, if_not_exists=True)
|
||||
batch_op.create_index(
|
||||
"ix_project_permalink", ["permalink"], unique=True, if_not_exists=True
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_project_updated_at", ["updated_at"], unique=False, if_not_exists=True
|
||||
)
|
||||
|
||||
with op.batch_alter_table("entity", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("project_id", sa.Integer(), nullable=False))
|
||||
batch_op.drop_index(
|
||||
"uix_entity_permalink",
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
||||
)
|
||||
batch_op.drop_index("ix_entity_file_path")
|
||||
batch_op.create_index(batch_op.f("ix_entity_file_path"), ["file_path"], unique=False)
|
||||
batch_op.create_index("ix_entity_project_id", ["project_id"], unique=False)
|
||||
batch_op.create_index(
|
||||
"uix_entity_file_path_project", ["file_path", "project_id"], unique=True
|
||||
)
|
||||
batch_op.create_index(
|
||||
"uix_entity_permalink_project",
|
||||
["permalink", "project_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
||||
)
|
||||
batch_op.create_foreign_key("fk_entity_project_id", "project", ["project_id"], ["id"])
|
||||
|
||||
# drop the search index table. it will be recreated
|
||||
op.drop_table("search_index")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("entity", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
|
||||
batch_op.drop_index(
|
||||
"uix_entity_permalink_project",
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
||||
)
|
||||
batch_op.drop_index("uix_entity_file_path_project")
|
||||
batch_op.drop_index("ix_entity_project_id")
|
||||
batch_op.drop_index(batch_op.f("ix_entity_file_path"))
|
||||
batch_op.create_index("ix_entity_file_path", ["file_path"], unique=1)
|
||||
batch_op.create_index(
|
||||
"uix_entity_permalink",
|
||||
["permalink"],
|
||||
unique=1,
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
||||
)
|
||||
batch_op.drop_column("project_id")
|
||||
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_project_updated_at")
|
||||
batch_op.drop_index("ix_project_permalink")
|
||||
batch_op.drop_index("ix_project_path")
|
||||
batch_op.drop_index("ix_project_name")
|
||||
batch_op.drop_index("ix_project_created_at")
|
||||
|
||||
op.drop_table("project")
|
||||
# ### end Alembic commands ###
|
||||
@@ -56,11 +56,6 @@ def upgrade() -> None:
|
||||
);
|
||||
""")
|
||||
|
||||
# Print instruction to manually reindex after migration
|
||||
print("\n------------------------------------------------------------------")
|
||||
print("IMPORTANT: After migration completes, manually run the reindex command:")
|
||||
print("basic-memory sync")
|
||||
print("------------------------------------------------------------------\n")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
+43
-13
@@ -1,29 +1,50 @@
|
||||
"""FastAPI application for basic-memory knowledge graph API."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.exception_handlers import http_exception_handler
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import __version__ as version
|
||||
from basic_memory import db
|
||||
from basic_memory.api.routers import knowledge, memory, project_info, resource, search
|
||||
from basic_memory.config import config as project_config
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
from basic_memory.api.routers import (
|
||||
directory_router,
|
||||
importer_router,
|
||||
knowledge,
|
||||
management,
|
||||
memory,
|
||||
project,
|
||||
resource,
|
||||
search,
|
||||
prompt_router,
|
||||
)
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.services.initialization import initialize_app, initialize_file_sync
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # pragma: no cover
|
||||
"""Lifecycle manager for the FastAPI app."""
|
||||
# Initialize database and file sync services
|
||||
watch_task = await initialize_app(project_config)
|
||||
# Initialize app and database
|
||||
logger.info("Starting Basic Memory API")
|
||||
await initialize_app(app_config)
|
||||
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
if app_config.sync_changes:
|
||||
# start file sync task in background
|
||||
app.state.sync_task = asyncio.create_task(initialize_file_sync(app_config))
|
||||
else:
|
||||
logger.info("Sync changes disabled. Skipping file sync service.")
|
||||
|
||||
# proceed with startup
|
||||
yield
|
||||
|
||||
logger.info("Shutting down Basic Memory API")
|
||||
if watch_task:
|
||||
watch_task.cancel()
|
||||
if app.state.sync_task:
|
||||
logger.info("Stopping sync...")
|
||||
app.state.sync_task.cancel() # pyright: ignore
|
||||
|
||||
await db.shutdown_db()
|
||||
|
||||
@@ -32,17 +53,26 @@ async def lifespan(app: FastAPI): # pragma: no cover
|
||||
app = FastAPI(
|
||||
title="Basic Memory API",
|
||||
description="Knowledge graph API for basic-memory",
|
||||
version="0.1.0",
|
||||
version=version,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(knowledge.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(memory.router)
|
||||
app.include_router(resource.router)
|
||||
app.include_router(project_info.router)
|
||||
app.include_router(knowledge.router, prefix="/{project}")
|
||||
app.include_router(memory.router, prefix="/{project}")
|
||||
app.include_router(resource.router, prefix="/{project}")
|
||||
app.include_router(search.router, prefix="/{project}")
|
||||
app.include_router(project.project_router, prefix="/{project}")
|
||||
app.include_router(directory_router.router, prefix="/{project}")
|
||||
app.include_router(prompt_router.router, prefix="/{project}")
|
||||
app.include_router(importer_router.router, prefix="/{project}")
|
||||
|
||||
# Project resource router works accross projects
|
||||
app.include_router(project.project_resource_router)
|
||||
app.include_router(management.router)
|
||||
|
||||
# Auth routes are handled by FastMCP automatically when auth is enabled
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""API routers."""
|
||||
|
||||
from . import knowledge_router as knowledge
|
||||
from . import management_router as management
|
||||
from . import memory_router as memory
|
||||
from . import project_router as project
|
||||
from . import resource_router as resource
|
||||
from . import search_router as search
|
||||
from . import project_info_router as project_info
|
||||
from . import prompt_router as prompt
|
||||
|
||||
__all__ = ["knowledge", "memory", "resource", "search", "project_info"]
|
||||
__all__ = ["knowledge", "management", "memory", "project", "resource", "search", "prompt"]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Router for directory tree operations."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from basic_memory.deps import DirectoryServiceDep, ProjectIdDep
|
||||
from basic_memory.schemas.directory import DirectoryNode
|
||||
|
||||
router = APIRouter(prefix="/directory", tags=["directory"])
|
||||
|
||||
|
||||
@router.get("/tree", response_model=DirectoryNode)
|
||||
async def get_directory_tree(
|
||||
directory_service: DirectoryServiceDep,
|
||||
project_id: ProjectIdDep,
|
||||
):
|
||||
"""Get hierarchical directory structure from the knowledge base.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: ID of the current project
|
||||
|
||||
Returns:
|
||||
DirectoryNode representing the root of the hierarchical tree structure
|
||||
"""
|
||||
# Get a hierarchical directory tree for the specific project
|
||||
tree = await directory_service.get_directory_tree()
|
||||
|
||||
# Return the hierarchical tree
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[DirectoryNode])
|
||||
async def list_directory(
|
||||
directory_service: DirectoryServiceDep,
|
||||
project_id: ProjectIdDep,
|
||||
dir_name: str = Query("/", description="Directory path to list"),
|
||||
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
|
||||
file_name_glob: Optional[str] = Query(
|
||||
None, description="Glob pattern for filtering file names"
|
||||
),
|
||||
):
|
||||
"""List directory contents with filtering and depth control.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: ID of the current project
|
||||
dir_name: Directory path to list (default: root "/")
|
||||
depth: Recursion depth (1-10, default: 1 for immediate children only)
|
||||
file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")
|
||||
|
||||
Returns:
|
||||
List of DirectoryNode objects matching the criteria
|
||||
"""
|
||||
# Get directory listing with filtering
|
||||
nodes = await directory_service.list_directory(
|
||||
dir_name=dir_name,
|
||||
depth=depth,
|
||||
file_name_glob=file_name_glob,
|
||||
)
|
||||
|
||||
return nodes
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Import router for Basic Memory API."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, UploadFile, status
|
||||
|
||||
from basic_memory.deps import (
|
||||
ChatGPTImporterDep,
|
||||
ClaudeConversationsImporterDep,
|
||||
ClaudeProjectsImporterDep,
|
||||
MemoryJsonImporterDep,
|
||||
)
|
||||
from basic_memory.importers import Importer
|
||||
from basic_memory.schemas.importer import (
|
||||
ChatImportResult,
|
||||
EntityImportResult,
|
||||
ProjectImportResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/import", tags=["import"])
|
||||
|
||||
|
||||
@router.post("/chatgpt", response_model=ChatImportResult)
|
||||
async def import_chatgpt(
|
||||
importer: ChatGPTImporterDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from ChatGPT JSON export.
|
||||
|
||||
Args:
|
||||
file: The ChatGPT conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
ChatImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, folder)
|
||||
|
||||
|
||||
@router.post("/claude/conversations", response_model=ChatImportResult)
|
||||
async def import_claude_conversations(
|
||||
importer: ClaudeConversationsImporterDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from Claude conversations.json export.
|
||||
|
||||
Args:
|
||||
file: The Claude conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
ChatImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, folder)
|
||||
|
||||
|
||||
@router.post("/claude/projects", response_model=ProjectImportResult)
|
||||
async def import_claude_projects(
|
||||
importer: ClaudeProjectsImporterDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("projects"),
|
||||
) -> ProjectImportResult:
|
||||
"""Import projects from Claude projects.json export.
|
||||
|
||||
Args:
|
||||
file: The Claude projects.json file.
|
||||
base_folder: The base folder to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
ProjectImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, folder)
|
||||
|
||||
|
||||
@router.post("/memory-json", response_model=EntityImportResult)
|
||||
async def import_memory_json(
|
||||
importer: MemoryJsonImporterDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
) -> EntityImportResult:
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
Args:
|
||||
file: The memory.json file.
|
||||
destination_folder: Optional destination folder within the project.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
EntityImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
try:
|
||||
file_data = []
|
||||
file_bytes = await file.read()
|
||||
file_str = file_bytes.decode("utf-8")
|
||||
for line in file_str.splitlines():
|
||||
json_data = json.loads(line)
|
||||
file_data.append(json_data)
|
||||
|
||||
result = await importer.import_data(file_data, folder)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=result.error_message or "Import failed",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Import failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def import_file(importer: Importer, file: UploadFile, destination_folder: str):
|
||||
try:
|
||||
# Process file
|
||||
json_data = json.load(file.file)
|
||||
result = await importer.import_data(json_data, destination_folder)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=result.error_message or "Import failed",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Import failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}",
|
||||
)
|
||||
@@ -10,6 +10,10 @@ from basic_memory.deps import (
|
||||
get_search_service,
|
||||
SearchServiceDep,
|
||||
LinkResolverDep,
|
||||
ProjectPathDep,
|
||||
FileServiceDep,
|
||||
ProjectConfigDep,
|
||||
AppConfigDep,
|
||||
)
|
||||
from basic_memory.schemas import (
|
||||
EntityListResponse,
|
||||
@@ -17,8 +21,8 @@ from basic_memory.schemas import (
|
||||
DeleteEntitiesResponse,
|
||||
DeleteEntitiesRequest,
|
||||
)
|
||||
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
|
||||
from basic_memory.schemas.base import Permalink, Entity
|
||||
from basic_memory.services.exceptions import EntityNotFoundError
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
|
||||
|
||||
@@ -44,43 +48,36 @@ async def create_entity(
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
"API response",
|
||||
endpoint="create_entity",
|
||||
title=result.title,
|
||||
permalink=result.permalink,
|
||||
status_code=201,
|
||||
f"API response: endpoint='create_entity' title={result.title}, permalink={result.permalink}, status_code=201"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/entities/{permalink:path}", response_model=EntityResponse)
|
||||
async def create_or_update_entity(
|
||||
project: ProjectPathDep,
|
||||
permalink: Permalink,
|
||||
data: Entity,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service: SearchServiceDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Create or update an entity. If entity exists, it will be updated, otherwise created."""
|
||||
logger.info(
|
||||
"API request",
|
||||
endpoint="create_or_update_entity",
|
||||
permalink=permalink,
|
||||
entity_type=data.entity_type,
|
||||
title=data.title,
|
||||
f"API request: create_or_update_entity for {project=}, {permalink=}, {data.entity_type=}, {data.title=}"
|
||||
)
|
||||
|
||||
# Validate permalink matches
|
||||
if data.permalink != permalink:
|
||||
logger.warning(
|
||||
"API validation error",
|
||||
endpoint="create_or_update_entity",
|
||||
permalink=permalink,
|
||||
data_permalink=data.permalink,
|
||||
error="Permalink mismatch",
|
||||
f"API validation error: creating/updating entity with permalink mismatch - url={permalink}, data={data.permalink}",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Entity permalink {data.permalink} must match URL path: '{permalink}'",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Entity permalink must match URL path")
|
||||
|
||||
# Try create_or_update operation
|
||||
entity, created = await entity_service.create_or_update_entity(data)
|
||||
@@ -91,38 +88,130 @@ async def create_or_update_entity(
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
"API response",
|
||||
endpoint="create_or_update_entity",
|
||||
title=result.title,
|
||||
permalink=result.permalink,
|
||||
created=created,
|
||||
status_code=response.status_code,
|
||||
f"API response: {result.title=}, {result.permalink=}, {created=}, status_code={response.status_code}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/entities/{identifier:path}", response_model=EntityResponse)
|
||||
async def edit_entity(
|
||||
identifier: str,
|
||||
data: EditEntityRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service: SearchServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Edit an existing entity using various operations like append, prepend, find_replace, or replace_section.
|
||||
|
||||
This endpoint allows for targeted edits without requiring the full entity content.
|
||||
"""
|
||||
logger.info(
|
||||
f"API request: endpoint='edit_entity', identifier='{identifier}', operation='{data.operation}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Edit the entity using the service
|
||||
entity = await entity_service.edit_entity(
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
|
||||
# Reindex the updated entity
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
# Return the updated entity response
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
"API response",
|
||||
endpoint="edit_entity",
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
permalink=result.permalink,
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move_entity(
|
||||
data: MoveEntityRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
project_config: ProjectConfigDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Move an entity to a new file location with project consistency.
|
||||
|
||||
This endpoint moves a note to a different path while maintaining project
|
||||
consistency and optionally updating permalinks based on configuration.
|
||||
"""
|
||||
logger.info(
|
||||
f"API request: endpoint='move_entity', identifier='{data.identifier}', destination='{data.destination_path}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Move the entity using the service
|
||||
moved_entity = await entity_service.move_entity(
|
||||
identifier=data.identifier,
|
||||
destination_path=data.destination_path,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Get the moved entity to reindex it
|
||||
entity = await entity_service.link_resolver.resolve_link(data.destination_path)
|
||||
if entity:
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
logger.info(
|
||||
"API response",
|
||||
endpoint="move_entity",
|
||||
identifier=data.identifier,
|
||||
destination=data.destination_path,
|
||||
status_code=200,
|
||||
)
|
||||
result = EntityResponse.model_validate(moved_entity)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Read endpoints
|
||||
|
||||
|
||||
@router.get("/entities/{permalink:path}", response_model=EntityResponse)
|
||||
@router.get("/entities/{identifier:path}", response_model=EntityResponse)
|
||||
async def get_entity(
|
||||
entity_service: EntityServiceDep,
|
||||
permalink: str,
|
||||
link_resolver: LinkResolverDep,
|
||||
identifier: str,
|
||||
) -> EntityResponse:
|
||||
"""Get a specific entity by ID.
|
||||
"""Get a specific entity by file path or permalink..
|
||||
|
||||
Args:
|
||||
permalink: Entity path ID
|
||||
content: If True, include full file content
|
||||
identifier: Entity file path or permalink
|
||||
:param entity_service: EntityService
|
||||
:param link_resolver: LinkResolver
|
||||
"""
|
||||
logger.info(f"request: get_entity with permalink={permalink}")
|
||||
try:
|
||||
entity = await entity_service.get_by_permalink(permalink)
|
||||
result = EntityResponse.model_validate(entity)
|
||||
return result
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
|
||||
logger.info(f"request: get_entity with identifier={identifier}")
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {identifier} not found")
|
||||
|
||||
result = EntityResponse.model_validate(entity)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/entities", response_model=EntityListResponse)
|
||||
@@ -161,8 +250,8 @@ async def delete_entity(
|
||||
# Delete the entity
|
||||
deleted = await entity_service.delete_entity(entity.permalink or entity.id)
|
||||
|
||||
# Remove from search index
|
||||
background_tasks.add_task(search_service.delete_by_permalink, entity.permalink)
|
||||
# Remove from search index (entity, observations, and relations)
|
||||
background_tasks.add_task(search_service.handle_delete, entity)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
return result
|
||||
@@ -185,4 +274,4 @@ async def delete_entities(
|
||||
background_tasks.add_task(search_service.delete_by_permalink, permalink)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
return result
|
||||
return result
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Management router for basic-memory API."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.deps import SyncServiceDep, ProjectRepositoryDep
|
||||
|
||||
router = APIRouter(prefix="/management", tags=["management"])
|
||||
|
||||
|
||||
class WatchStatusResponse(BaseModel):
|
||||
"""Response model for watch status."""
|
||||
|
||||
running: bool
|
||||
"""Whether the watch service is currently running."""
|
||||
|
||||
|
||||
@router.get("/watch/status", response_model=WatchStatusResponse)
|
||||
async def get_watch_status(request: Request) -> WatchStatusResponse:
|
||||
"""Get the current status of the watch service."""
|
||||
return WatchStatusResponse(
|
||||
running=request.app.state.watch_task is not None and not request.app.state.watch_task.done()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/watch/start", response_model=WatchStatusResponse)
|
||||
async def start_watch_service(
|
||||
request: Request, project_repository: ProjectRepositoryDep, sync_service: SyncServiceDep
|
||||
) -> WatchStatusResponse:
|
||||
"""Start the watch service if it's not already running."""
|
||||
|
||||
# needed because of circular imports from sync -> app
|
||||
from basic_memory.sync import WatchService
|
||||
from basic_memory.sync.background_sync import create_background_sync_task
|
||||
|
||||
if request.app.state.watch_task is not None and not request.app.state.watch_task.done():
|
||||
# Watch service is already running
|
||||
return WatchStatusResponse(running=True)
|
||||
|
||||
# Create and start a new watch service
|
||||
logger.info("Starting watch service via management API")
|
||||
|
||||
# Get services needed for the watch task
|
||||
watch_service = WatchService(
|
||||
app_config=app_config,
|
||||
project_repository=project_repository,
|
||||
)
|
||||
|
||||
# Create and store the task
|
||||
watch_task = create_background_sync_task(sync_service, watch_service)
|
||||
request.app.state.watch_task = watch_task
|
||||
|
||||
return WatchStatusResponse(running=True)
|
||||
|
||||
|
||||
@router.post("/watch/stop", response_model=WatchStatusResponse)
|
||||
async def stop_watch_service(request: Request) -> WatchStatusResponse: # pragma: no cover
|
||||
"""Stop the watch service if it's running."""
|
||||
if request.app.state.watch_task is None or request.app.state.watch_task.done():
|
||||
# Watch service is not running
|
||||
return WatchStatusResponse(running=False)
|
||||
|
||||
# Cancel the running task
|
||||
logger.info("Stopping watch service via management API")
|
||||
request.app.state.watch_task.cancel()
|
||||
|
||||
# Wait for it to be properly cancelled
|
||||
try:
|
||||
await request.app.state.watch_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
request.app.state.watch_task = None
|
||||
return WatchStatusResponse(running=False)
|
||||
@@ -1,78 +1,23 @@
|
||||
"""Routes for memory:// URI operations."""
|
||||
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from dateparser import parse
|
||||
from fastapi import APIRouter, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import ContextServiceDep, EntityRepositoryDep
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.repository.search_repository import SearchIndexRow
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
RelationSummary,
|
||||
EntitySummary,
|
||||
ObservationSummary,
|
||||
MemoryMetadata,
|
||||
normalize_memory_url,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.services.context_service import ContextResultRow
|
||||
from basic_memory.api.routers.utils import to_graph_context
|
||||
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
async def to_graph_context(context, entity_repository: EntityRepository, page: int, page_size: int):
|
||||
# return results
|
||||
async def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return EntitySummary(
|
||||
title=item.title, # pyright: ignore
|
||||
permalink=item.permalink,
|
||||
content=item.content,
|
||||
file_path=item.file_path,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.OBSERVATION:
|
||||
return ObservationSummary(
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
category=item.category, # pyright: ignore
|
||||
content=item.content, # pyright: ignore
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.RELATION:
|
||||
from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
|
||||
to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
|
||||
return RelationSummary(
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
relation_type=item.type,
|
||||
from_entity=from_entity.permalink, # pyright: ignore
|
||||
to_entity=to_entity.permalink if to_entity else None,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case _: # pragma: no cover
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
primary_results = [await to_summary(r) for r in context["primary_results"]]
|
||||
related_results = [await to_summary(r) for r in context["related_results"]]
|
||||
metadata = MemoryMetadata.model_validate(context["metadata"])
|
||||
# Transform to GraphContext
|
||||
return GraphContext(
|
||||
primary_results=primary_results,
|
||||
related_results=related_results,
|
||||
metadata=metadata,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recent", response_model=GraphContext)
|
||||
async def recent(
|
||||
context_service: ContextServiceDep,
|
||||
@@ -119,7 +64,7 @@ async def get_memory_context(
|
||||
entity_repository: EntityRepositoryDep,
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
timeframe: Optional[TimeFrame] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
@@ -133,7 +78,7 @@ async def get_memory_context(
|
||||
memory_url = normalize_memory_url(uri)
|
||||
|
||||
# Parse timeframe
|
||||
since = parse(timeframe)
|
||||
since = parse(timeframe) if timeframe else None
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
"""Router for statistics and system information."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from basic_memory.config import config, config_manager
|
||||
from basic_memory.deps import (
|
||||
ProjectInfoRepositoryDep,
|
||||
)
|
||||
from basic_memory.repository.project_info_repository import ProjectInfoRepository
|
||||
from basic_memory.schemas import (
|
||||
ProjectInfoResponse,
|
||||
ProjectStatistics,
|
||||
ActivityMetrics,
|
||||
SystemStatus,
|
||||
)
|
||||
from basic_memory.sync.watch_service import WATCH_STATUS_JSON
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import text
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["statistics"])
|
||||
|
||||
|
||||
@router.get("/project-info", response_model=ProjectInfoResponse)
|
||||
async def get_project_info(
|
||||
repository: ProjectInfoRepositoryDep,
|
||||
) -> ProjectInfoResponse:
|
||||
"""Get comprehensive information about the current Basic Memory project."""
|
||||
# Get statistics
|
||||
statistics = await get_statistics(repository)
|
||||
|
||||
# Get activity metrics
|
||||
activity = await get_activity_metrics(repository)
|
||||
|
||||
# Get system status
|
||||
system = await get_system_status()
|
||||
|
||||
# Get project configuration information
|
||||
project_name = config.project
|
||||
project_path = str(config.home)
|
||||
available_projects = config_manager.projects
|
||||
default_project = config_manager.default_project
|
||||
|
||||
# Construct the response
|
||||
return ProjectInfoResponse(
|
||||
project_name=project_name,
|
||||
project_path=project_path,
|
||||
available_projects=available_projects,
|
||||
default_project=default_project,
|
||||
statistics=statistics,
|
||||
activity=activity,
|
||||
system=system,
|
||||
)
|
||||
|
||||
|
||||
async def get_statistics(repository: ProjectInfoRepository) -> ProjectStatistics:
|
||||
"""Get statistics about the current project."""
|
||||
# Get basic counts
|
||||
entity_count_result = await repository.execute_query(text("SELECT COUNT(*) FROM entity"))
|
||||
total_entities = entity_count_result.scalar() or 0
|
||||
|
||||
observation_count_result = await repository.execute_query(
|
||||
text("SELECT COUNT(*) FROM observation")
|
||||
)
|
||||
total_observations = observation_count_result.scalar() or 0
|
||||
|
||||
relation_count_result = await repository.execute_query(text("SELECT COUNT(*) FROM relation"))
|
||||
total_relations = relation_count_result.scalar() or 0
|
||||
|
||||
unresolved_count_result = await repository.execute_query(
|
||||
text("SELECT COUNT(*) FROM relation WHERE to_id IS NULL")
|
||||
)
|
||||
total_unresolved = unresolved_count_result.scalar() or 0
|
||||
|
||||
# Get entity counts by type
|
||||
entity_types_result = await repository.execute_query(
|
||||
text("SELECT entity_type, COUNT(*) FROM entity GROUP BY entity_type")
|
||||
)
|
||||
entity_types = {row[0]: row[1] for row in entity_types_result.fetchall()}
|
||||
|
||||
# Get observation counts by category
|
||||
category_result = await repository.execute_query(
|
||||
text("SELECT category, COUNT(*) FROM observation GROUP BY category")
|
||||
)
|
||||
observation_categories = {row[0]: row[1] for row in category_result.fetchall()}
|
||||
|
||||
# Get relation counts by type
|
||||
relation_types_result = await repository.execute_query(
|
||||
text("SELECT relation_type, COUNT(*) FROM relation GROUP BY relation_type")
|
||||
)
|
||||
relation_types = {row[0]: row[1] for row in relation_types_result.fetchall()}
|
||||
|
||||
# Find most connected entities (most outgoing relations)
|
||||
connected_result = await repository.execute_query(
|
||||
text("""
|
||||
SELECT e.id, e.title, e.permalink, COUNT(r.id) AS relation_count
|
||||
FROM entity e
|
||||
JOIN relation r ON e.id = r.from_id
|
||||
GROUP BY e.id
|
||||
ORDER BY relation_count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
)
|
||||
most_connected = [
|
||||
{"id": row[0], "title": row[1], "permalink": row[2], "relation_count": row[3]}
|
||||
for row in connected_result.fetchall()
|
||||
]
|
||||
|
||||
# Count isolated entities (no relations)
|
||||
isolated_result = await repository.execute_query(
|
||||
text("""
|
||||
SELECT COUNT(e.id)
|
||||
FROM entity e
|
||||
LEFT JOIN relation r1 ON e.id = r1.from_id
|
||||
LEFT JOIN relation r2 ON e.id = r2.to_id
|
||||
WHERE r1.id IS NULL AND r2.id IS NULL
|
||||
""")
|
||||
)
|
||||
isolated_count = isolated_result.scalar() or 0
|
||||
|
||||
return ProjectStatistics(
|
||||
total_entities=total_entities,
|
||||
total_observations=total_observations,
|
||||
total_relations=total_relations,
|
||||
total_unresolved_relations=total_unresolved,
|
||||
entity_types=entity_types,
|
||||
observation_categories=observation_categories,
|
||||
relation_types=relation_types,
|
||||
most_connected_entities=most_connected,
|
||||
isolated_entities=isolated_count,
|
||||
)
|
||||
|
||||
|
||||
async def get_activity_metrics(repository: ProjectInfoRepository) -> ActivityMetrics:
|
||||
"""Get activity metrics for the current project."""
|
||||
# Get recently created entities
|
||||
created_result = await repository.execute_query(
|
||||
text("""
|
||||
SELECT id, title, permalink, entity_type, created_at
|
||||
FROM entity
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
)
|
||||
recently_created = [
|
||||
{
|
||||
"id": row[0],
|
||||
"title": row[1],
|
||||
"permalink": row[2],
|
||||
"entity_type": row[3],
|
||||
"created_at": row[4],
|
||||
}
|
||||
for row in created_result.fetchall()
|
||||
]
|
||||
|
||||
# Get recently updated entities
|
||||
updated_result = await repository.execute_query(
|
||||
text("""
|
||||
SELECT id, title, permalink, entity_type, updated_at
|
||||
FROM entity
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
)
|
||||
recently_updated = [
|
||||
{
|
||||
"id": row[0],
|
||||
"title": row[1],
|
||||
"permalink": row[2],
|
||||
"entity_type": row[3],
|
||||
"updated_at": row[4],
|
||||
}
|
||||
for row in updated_result.fetchall()
|
||||
]
|
||||
|
||||
# Get monthly growth over the last 6 months
|
||||
# Calculate the start of 6 months ago
|
||||
now = datetime.now()
|
||||
six_months_ago = datetime(
|
||||
now.year - (1 if now.month <= 6 else 0), ((now.month - 6) % 12) or 12, 1
|
||||
)
|
||||
|
||||
# Query for monthly entity creation
|
||||
entity_growth_result = await repository.execute_query(
|
||||
text(f"""
|
||||
SELECT
|
||||
strftime('%Y-%m', created_at) AS month,
|
||||
COUNT(*) AS count
|
||||
FROM entity
|
||||
WHERE created_at >= '{six_months_ago.isoformat()}'
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
""")
|
||||
)
|
||||
entity_growth = {row[0]: row[1] for row in entity_growth_result.fetchall()}
|
||||
|
||||
# Query for monthly observation creation
|
||||
observation_growth_result = await repository.execute_query(
|
||||
text(f"""
|
||||
SELECT
|
||||
strftime('%Y-%m', created_at) AS month,
|
||||
COUNT(*) AS count
|
||||
FROM observation
|
||||
INNER JOIN entity ON observation.entity_id = entity.id
|
||||
WHERE entity.created_at >= '{six_months_ago.isoformat()}'
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
""")
|
||||
)
|
||||
observation_growth = {row[0]: row[1] for row in observation_growth_result.fetchall()}
|
||||
|
||||
# Query for monthly relation creation
|
||||
relation_growth_result = await repository.execute_query(
|
||||
text(f"""
|
||||
SELECT
|
||||
strftime('%Y-%m', created_at) AS month,
|
||||
COUNT(*) AS count
|
||||
FROM relation
|
||||
INNER JOIN entity ON relation.from_id = entity.id
|
||||
WHERE entity.created_at >= '{six_months_ago.isoformat()}'
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
""")
|
||||
)
|
||||
relation_growth = {row[0]: row[1] for row in relation_growth_result.fetchall()}
|
||||
|
||||
# Combine all monthly growth data
|
||||
monthly_growth = {}
|
||||
for month in set(
|
||||
list(entity_growth.keys()) + list(observation_growth.keys()) + list(relation_growth.keys())
|
||||
):
|
||||
monthly_growth[month] = {
|
||||
"entities": entity_growth.get(month, 0),
|
||||
"observations": observation_growth.get(month, 0),
|
||||
"relations": relation_growth.get(month, 0),
|
||||
"total": (
|
||||
entity_growth.get(month, 0)
|
||||
+ observation_growth.get(month, 0)
|
||||
+ relation_growth.get(month, 0)
|
||||
),
|
||||
}
|
||||
|
||||
return ActivityMetrics(
|
||||
recently_created=recently_created,
|
||||
recently_updated=recently_updated,
|
||||
monthly_growth=monthly_growth,
|
||||
)
|
||||
|
||||
|
||||
async def get_system_status() -> SystemStatus:
|
||||
"""Get system status information."""
|
||||
import basic_memory
|
||||
|
||||
# Get database information
|
||||
db_path = config.database_path
|
||||
db_size = db_path.stat().st_size if db_path.exists() else 0
|
||||
db_size_readable = f"{db_size / (1024 * 1024):.2f} MB"
|
||||
|
||||
# Get watch service status if available
|
||||
watch_status = None
|
||||
watch_status_path = config.home / ".basic-memory" / WATCH_STATUS_JSON
|
||||
if watch_status_path.exists():
|
||||
try:
|
||||
watch_status = json.loads(watch_status_path.read_text(encoding="utf-8"))
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
return SystemStatus(
|
||||
version=basic_memory.__version__,
|
||||
database_path=str(db_path),
|
||||
database_size=db_size_readable,
|
||||
watch_status=watch_status,
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Router for project management."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Path, Body
|
||||
from typing import Optional
|
||||
|
||||
from basic_memory.deps import ProjectServiceDep
|
||||
from basic_memory.schemas import ProjectInfoResponse
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectList,
|
||||
ProjectItem,
|
||||
ProjectInfoRequest,
|
||||
ProjectStatusResponse,
|
||||
)
|
||||
|
||||
# Router for resources in a specific project
|
||||
project_router = APIRouter(prefix="/project", tags=["project"])
|
||||
|
||||
# Router for managing project resources
|
||||
project_resource_router = APIRouter(prefix="/projects", tags=["project_management"])
|
||||
|
||||
|
||||
@project_router.get("/info", response_model=ProjectInfoResponse)
|
||||
async def get_project_info(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectInfoResponse:
|
||||
"""Get comprehensive information about the current Basic Memory project."""
|
||||
return await project_service.get_project_info()
|
||||
|
||||
|
||||
# Update a project
|
||||
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
|
||||
async def update_project(
|
||||
project_service: ProjectServiceDep,
|
||||
project_name: str = Path(..., description="Name of the project to update"),
|
||||
path: Optional[str] = Body(None, description="New path for the project"),
|
||||
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Update a project's information in configuration and database.
|
||||
|
||||
Args:
|
||||
project_name: The name of the project to update
|
||||
path: Optional new path for the project
|
||||
is_active: Optional status update for the project
|
||||
|
||||
Returns:
|
||||
Response confirming the project was updated
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
# Get original project info for the response
|
||||
old_project = ProjectItem(
|
||||
name=project_name,
|
||||
path=project_service.projects.get(project_name, ""),
|
||||
)
|
||||
|
||||
await project_service.update_project(project_name, updated_path=path, is_active=is_active)
|
||||
|
||||
# Get updated project info
|
||||
updated_path = path if path else project_service.projects.get(project_name, "")
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{project_name}' updated successfully",
|
||||
status="success",
|
||||
default=(project_name == project_service.default_project),
|
||||
old_project=old_project,
|
||||
new_project=ProjectItem(name=project_name, path=updated_path),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# List all available projects
|
||||
@project_resource_router.get("/projects", response_model=ProjectList)
|
||||
async def list_projects(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectList:
|
||||
"""List all configured projects.
|
||||
|
||||
Returns:
|
||||
A list of all projects with metadata
|
||||
"""
|
||||
projects = await project_service.list_projects()
|
||||
default_project = project_service.default_project
|
||||
|
||||
project_items = [
|
||||
ProjectItem(
|
||||
name=project.name,
|
||||
path=project.path,
|
||||
is_default=project.is_default or False,
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
|
||||
return ProjectList(
|
||||
projects=project_items,
|
||||
default_project=default_project,
|
||||
)
|
||||
|
||||
|
||||
# Add a new project
|
||||
@project_resource_router.post("/projects", response_model=ProjectStatusResponse)
|
||||
async def add_project(
|
||||
project_data: ProjectInfoRequest,
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Add a new project to configuration and database.
|
||||
|
||||
Args:
|
||||
project_data: The project name and path, with option to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was added
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
await project_service.add_project(project_data.name, project_data.path)
|
||||
|
||||
if project_data.set_default: # pragma: no cover
|
||||
await project_service.set_default_project(project_data.name)
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{project_data.name}' added successfully",
|
||||
status="success",
|
||||
default=project_data.set_default,
|
||||
new_project=ProjectItem(
|
||||
name=project_data.name, path=project_data.path, is_default=project_data.set_default
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# Remove a project
|
||||
@project_resource_router.delete("/{name}", response_model=ProjectStatusResponse)
|
||||
async def remove_project(
|
||||
project_service: ProjectServiceDep,
|
||||
name: str = Path(..., description="Name of the project to remove"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Remove a project from configuration and database.
|
||||
|
||||
Args:
|
||||
name: The name of the project to remove
|
||||
|
||||
Returns:
|
||||
Response confirming the project was removed
|
||||
"""
|
||||
try:
|
||||
old_project = await project_service.get_project(name)
|
||||
if not old_project: # pragma: no cover
|
||||
raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover
|
||||
|
||||
await project_service.remove_project(name)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' removed successfully",
|
||||
status="success",
|
||||
default=False,
|
||||
old_project=ProjectItem(name=old_project.name, path=old_project.path),
|
||||
new_project=None,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# Set a project as default
|
||||
@project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse)
|
||||
async def set_default_project(
|
||||
project_service: ProjectServiceDep,
|
||||
name: str = Path(..., description="Name of the project to set as default"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Set a project as the default project.
|
||||
|
||||
Args:
|
||||
name: The name of the project to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was set as default
|
||||
"""
|
||||
try:
|
||||
# Get the old default project
|
||||
default_name = project_service.default_project
|
||||
default_project = await project_service.get_project(default_name)
|
||||
if not default_project: # pragma: no cover
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
||||
)
|
||||
|
||||
# get the new project
|
||||
new_default_project = await project_service.get_project(name)
|
||||
if not new_default_project: # pragma: no cover
|
||||
raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover
|
||||
|
||||
await project_service.set_default_project(name)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' set as default successfully",
|
||||
status="success",
|
||||
default=True,
|
||||
old_project=ProjectItem(name=default_name, path=default_project.path),
|
||||
new_project=ProjectItem(
|
||||
name=name,
|
||||
path=new_default_project.path,
|
||||
is_default=True,
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# Synchronize projects between config and database
|
||||
@project_resource_router.post("/sync", response_model=ProjectStatusResponse)
|
||||
async def synchronize_projects(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Synchronize projects between configuration file and database.
|
||||
|
||||
Ensures that all projects in the configuration file exist in the database
|
||||
and vice versa.
|
||||
|
||||
Returns:
|
||||
Response confirming synchronization was completed
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
await project_service.synchronize_projects()
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message="Projects synchronized successfully between configuration and database",
|
||||
status="success",
|
||||
default=False,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Router for prompt-related operations.
|
||||
|
||||
This router is responsible for rendering various prompts using Handlebars templates.
|
||||
It centralizes all prompt formatting logic that was previously in the MCP prompts.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from dateparser import parse
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.api.routers.utils import to_graph_context, to_search_results
|
||||
from basic_memory.api.template_loader import template_loader
|
||||
from basic_memory.deps import (
|
||||
ContextServiceDep,
|
||||
EntityRepositoryDep,
|
||||
SearchServiceDep,
|
||||
EntityServiceDep,
|
||||
)
|
||||
from basic_memory.schemas.prompt import (
|
||||
ContinueConversationRequest,
|
||||
SearchPromptRequest,
|
||||
PromptResponse,
|
||||
PromptMetadata,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery
|
||||
|
||||
router = APIRouter(prefix="/prompt", tags=["prompt"])
|
||||
|
||||
|
||||
@router.post("/continue-conversation", response_model=PromptResponse)
|
||||
async def continue_conversation(
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
context_service: ContextServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
request: ContinueConversationRequest,
|
||||
) -> PromptResponse:
|
||||
"""Generate a prompt for continuing a conversation.
|
||||
|
||||
This endpoint takes a topic and/or timeframe and generates a prompt with
|
||||
relevant context from the knowledge base.
|
||||
|
||||
Args:
|
||||
request: The request parameters
|
||||
|
||||
Returns:
|
||||
Formatted continuation prompt with context
|
||||
"""
|
||||
logger.info(
|
||||
f"Generating continue conversation prompt, topic: {request.topic}, timeframe: {request.timeframe}"
|
||||
)
|
||||
|
||||
since = parse(request.timeframe) if request.timeframe else None
|
||||
|
||||
# Initialize search results
|
||||
search_results = []
|
||||
|
||||
# Get data needed for template
|
||||
if request.topic:
|
||||
query = SearchQuery(text=request.topic, after_date=request.timeframe)
|
||||
results = await search_service.search(query, limit=request.search_items_limit)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
|
||||
# Build context from results
|
||||
all_hierarchical_results = []
|
||||
for result in search_results:
|
||||
if hasattr(result, "permalink") and result.permalink:
|
||||
# Get hierarchical context using the new dataclass-based approach
|
||||
context_result = await context_service.build_context(
|
||||
result.permalink,
|
||||
depth=request.depth,
|
||||
since=since,
|
||||
max_related=request.related_items_limit,
|
||||
include_observations=True, # Include observations for entities
|
||||
)
|
||||
|
||||
# Process results into the schema format
|
||||
graph_context = await to_graph_context(
|
||||
context_result, entity_repository=entity_repository
|
||||
)
|
||||
|
||||
# Add results to our collection (limit to top results for each permalink)
|
||||
if graph_context.results:
|
||||
all_hierarchical_results.extend(graph_context.results[:3])
|
||||
|
||||
# Limit to a reasonable number of total results
|
||||
all_hierarchical_results = all_hierarchical_results[:10]
|
||||
|
||||
template_context = {
|
||||
"topic": request.topic,
|
||||
"timeframe": request.timeframe,
|
||||
"hierarchical_results": all_hierarchical_results,
|
||||
"has_results": len(all_hierarchical_results) > 0,
|
||||
}
|
||||
else:
|
||||
# If no topic, get recent activity
|
||||
context_result = await context_service.build_context(
|
||||
types=[SearchItemType.ENTITY],
|
||||
depth=request.depth,
|
||||
since=since,
|
||||
max_related=request.related_items_limit,
|
||||
include_observations=True,
|
||||
)
|
||||
recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
|
||||
|
||||
hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
|
||||
|
||||
template_context = {
|
||||
"topic": f"Recent Activity from ({request.timeframe})",
|
||||
"timeframe": request.timeframe,
|
||||
"hierarchical_results": hierarchical_results,
|
||||
"has_results": len(hierarchical_results) > 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Render template
|
||||
rendered_prompt = await template_loader.render(
|
||||
"prompts/continue_conversation.hbs", template_context
|
||||
)
|
||||
|
||||
# Calculate metadata
|
||||
# Count items of different types
|
||||
observation_count = 0
|
||||
relation_count = 0
|
||||
entity_count = 0
|
||||
|
||||
# Get the hierarchical results from the template context
|
||||
hierarchical_results_for_count = template_context.get("hierarchical_results", [])
|
||||
|
||||
# For topic-based search
|
||||
if request.topic:
|
||||
for item in hierarchical_results_for_count:
|
||||
if hasattr(item, "observations"):
|
||||
observation_count += len(item.observations) if item.observations else 0
|
||||
|
||||
if hasattr(item, "related_results"):
|
||||
for related in item.related_results or []:
|
||||
if hasattr(related, "type"):
|
||||
if related.type == "relation":
|
||||
relation_count += 1
|
||||
elif related.type == "entity": # pragma: no cover
|
||||
entity_count += 1 # pragma: no cover
|
||||
# For recent activity
|
||||
else:
|
||||
for item in hierarchical_results_for_count:
|
||||
if hasattr(item, "observations"):
|
||||
observation_count += len(item.observations) if item.observations else 0
|
||||
|
||||
if hasattr(item, "related_results"):
|
||||
for related in item.related_results or []:
|
||||
if hasattr(related, "type"):
|
||||
if related.type == "relation":
|
||||
relation_count += 1
|
||||
elif related.type == "entity": # pragma: no cover
|
||||
entity_count += 1 # pragma: no cover
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"query": request.topic,
|
||||
"timeframe": request.timeframe,
|
||||
"search_count": len(search_results)
|
||||
if request.topic
|
||||
else 0, # Original search results count
|
||||
"context_count": len(hierarchical_results_for_count),
|
||||
"observation_count": observation_count,
|
||||
"relation_count": relation_count,
|
||||
"total_items": (
|
||||
len(hierarchical_results_for_count)
|
||||
+ observation_count
|
||||
+ relation_count
|
||||
+ entity_count
|
||||
),
|
||||
"search_limit": request.search_items_limit,
|
||||
"context_depth": request.depth,
|
||||
"related_limit": request.related_items_limit,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
prompt_metadata = PromptMetadata(**metadata)
|
||||
|
||||
return PromptResponse(
|
||||
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering continue conversation template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error rendering prompt template: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search", response_model=PromptResponse)
|
||||
async def search_prompt(
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
request: SearchPromptRequest,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> PromptResponse:
|
||||
"""Generate a prompt for search results.
|
||||
|
||||
This endpoint takes a search query and formats the results into a helpful
|
||||
prompt with context and suggestions.
|
||||
|
||||
Args:
|
||||
request: The search parameters
|
||||
page: The page number for pagination
|
||||
page_size: The number of results per page, defaults to 10
|
||||
|
||||
Returns:
|
||||
Formatted search results prompt with context
|
||||
"""
|
||||
logger.info(f"Generating search prompt, query: {request.query}, timeframe: {request.timeframe}")
|
||||
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
query = SearchQuery(text=request.query, after_date=request.timeframe)
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
|
||||
template_context = {
|
||||
"query": request.query,
|
||||
"timeframe": request.timeframe,
|
||||
"results": search_results,
|
||||
"has_results": len(search_results) > 0,
|
||||
"result_count": len(search_results),
|
||||
}
|
||||
|
||||
try:
|
||||
# Render template
|
||||
rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"query": request.query,
|
||||
"timeframe": request.timeframe,
|
||||
"search_count": len(search_results),
|
||||
"context_count": len(search_results),
|
||||
"observation_count": 0, # Search results don't include observations
|
||||
"relation_count": 0, # Search results don't include relations
|
||||
"total_items": len(search_results),
|
||||
"search_limit": limit,
|
||||
"context_depth": 0, # No context depth for basic search
|
||||
"related_limit": 0, # No related items for basic search
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
prompt_metadata = PromptMetadata(**metadata)
|
||||
|
||||
return PromptResponse(
|
||||
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering search template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error rendering prompt template: {str(e)}",
|
||||
)
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchResponse
|
||||
from basic_memory.api.routers.utils import to_search_results
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.deps import SearchServiceDep, EntityServiceDep
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
@@ -20,26 +21,7 @@ async def search(
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
|
||||
search_results = []
|
||||
for r in results:
|
||||
entities = await entity_service.get_entities_by_id([r.entity_id, r.from_id, r.to_id]) # pyright: ignore
|
||||
search_results.append(
|
||||
SearchResult(
|
||||
title=r.title, # pyright: ignore
|
||||
type=r.type, # pyright: ignore
|
||||
permalink=r.permalink,
|
||||
score=r.score, # pyright: ignore
|
||||
entity=entities[0].permalink if entities else None,
|
||||
content=r.content,
|
||||
file_path=r.file_path,
|
||||
metadata=r.metadata,
|
||||
category=r.category,
|
||||
from_entity=entities[0].permalink if entities else None,
|
||||
to_entity=entities[1].permalink if len(entities) > 1 else None,
|
||||
relation_type=r.relation_type,
|
||||
)
|
||||
)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
return SearchResponse(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.repository.search_repository import SearchIndexRow
|
||||
from basic_memory.schemas.memory import (
|
||||
EntitySummary,
|
||||
ObservationSummary,
|
||||
RelationSummary,
|
||||
MemoryMetadata,
|
||||
GraphContext,
|
||||
ContextResult,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType, SearchResult
|
||||
from basic_memory.services import EntityService
|
||||
from basic_memory.services.context_service import (
|
||||
ContextResultRow,
|
||||
ContextResult as ServiceContextResult,
|
||||
)
|
||||
|
||||
|
||||
async def to_graph_context(
|
||||
context_result: ServiceContextResult,
|
||||
entity_repository: EntityRepository,
|
||||
page: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
):
|
||||
# Helper function to convert items to summaries
|
||||
async def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return EntitySummary(
|
||||
title=item.title, # pyright: ignore
|
||||
permalink=item.permalink,
|
||||
content=item.content,
|
||||
file_path=item.file_path,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.OBSERVATION:
|
||||
return ObservationSummary(
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
category=item.category, # pyright: ignore
|
||||
content=item.content, # pyright: ignore
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.RELATION:
|
||||
from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
|
||||
to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
|
||||
return RelationSummary(
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
relation_type=item.relation_type, # pyright: ignore
|
||||
from_entity=from_entity.title, # pyright: ignore
|
||||
to_entity=to_entity.title if to_entity else None,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case _: # pragma: no cover
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
# Process the hierarchical results
|
||||
hierarchical_results = []
|
||||
for context_item in context_result.results:
|
||||
# Process primary result
|
||||
primary_result = await to_summary(context_item.primary_result)
|
||||
|
||||
# Process observations
|
||||
observations = []
|
||||
for obs in context_item.observations:
|
||||
observations.append(await to_summary(obs))
|
||||
|
||||
# Process related results
|
||||
related = []
|
||||
for rel in context_item.related_results:
|
||||
related.append(await to_summary(rel))
|
||||
|
||||
# Add to hierarchical results
|
||||
hierarchical_results.append(
|
||||
ContextResult(
|
||||
primary_result=primary_result,
|
||||
observations=observations,
|
||||
related_results=related,
|
||||
)
|
||||
)
|
||||
|
||||
# Create schema metadata from service metadata
|
||||
metadata = MemoryMetadata(
|
||||
uri=context_result.metadata.uri,
|
||||
types=context_result.metadata.types,
|
||||
depth=context_result.metadata.depth,
|
||||
timeframe=context_result.metadata.timeframe,
|
||||
generated_at=context_result.metadata.generated_at,
|
||||
primary_count=context_result.metadata.primary_count,
|
||||
related_count=context_result.metadata.related_count,
|
||||
total_results=context_result.metadata.primary_count + context_result.metadata.related_count,
|
||||
total_relations=context_result.metadata.total_relations,
|
||||
total_observations=context_result.metadata.total_observations,
|
||||
)
|
||||
|
||||
# Return new GraphContext with just hierarchical results
|
||||
return GraphContext(
|
||||
results=hierarchical_results,
|
||||
metadata=metadata,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
async def to_search_results(entity_service: EntityService, results: List[SearchIndexRow]):
|
||||
search_results = []
|
||||
for r in results:
|
||||
entities = await entity_service.get_entities_by_id([r.entity_id, r.from_id, r.to_id]) # pyright: ignore
|
||||
search_results.append(
|
||||
SearchResult(
|
||||
title=r.title, # pyright: ignore
|
||||
type=r.type, # pyright: ignore
|
||||
permalink=r.permalink,
|
||||
score=r.score, # pyright: ignore
|
||||
entity=entities[0].permalink if entities else None,
|
||||
content=r.content,
|
||||
file_path=r.file_path,
|
||||
metadata=r.metadata,
|
||||
category=r.category,
|
||||
from_entity=entities[0].permalink if entities else None,
|
||||
to_entity=entities[1].permalink if len(entities) > 1 else None,
|
||||
relation_type=r.relation_type,
|
||||
)
|
||||
)
|
||||
return search_results
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Template loading and rendering utilities for the Basic Memory API.
|
||||
|
||||
This module handles the loading and rendering of Handlebars templates from the
|
||||
templates directory, providing a consistent interface for all prompt-related
|
||||
formatting needs.
|
||||
"""
|
||||
|
||||
import textwrap
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from pathlib import Path
|
||||
import json
|
||||
import datetime
|
||||
|
||||
import pybars
|
||||
from loguru import logger
|
||||
|
||||
# Get the base path of the templates directory
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
|
||||
|
||||
# Custom helpers for Handlebars
|
||||
def _date_helper(this, *args):
|
||||
"""Format a date using the given format string."""
|
||||
if len(args) < 1: # pragma: no cover
|
||||
return ""
|
||||
|
||||
timestamp = args[0]
|
||||
format_str = args[1] if len(args) > 1 else "%Y-%m-%d %H:%M"
|
||||
|
||||
if hasattr(timestamp, "strftime"):
|
||||
result = timestamp.strftime(format_str)
|
||||
elif isinstance(timestamp, str):
|
||||
try:
|
||||
dt = datetime.datetime.fromisoformat(timestamp)
|
||||
result = dt.strftime(format_str)
|
||||
except ValueError:
|
||||
result = timestamp
|
||||
else:
|
||||
result = str(timestamp) # pragma: no cover
|
||||
|
||||
return pybars.strlist([result])
|
||||
|
||||
|
||||
def _default_helper(this, *args):
|
||||
"""Return a default value if the given value is None or empty."""
|
||||
if len(args) < 2: # pragma: no cover
|
||||
return ""
|
||||
|
||||
value = args[0]
|
||||
default_value = args[1]
|
||||
|
||||
result = default_value if value is None or value == "" else value
|
||||
# Use strlist for consistent handling of HTML escaping
|
||||
return pybars.strlist([str(result)])
|
||||
|
||||
|
||||
def _capitalize_helper(this, *args):
|
||||
"""Capitalize the first letter of a string."""
|
||||
if len(args) < 1: # pragma: no cover
|
||||
return ""
|
||||
|
||||
text = args[0]
|
||||
if not text or not isinstance(text, str): # pragma: no cover
|
||||
result = ""
|
||||
else:
|
||||
result = text.capitalize()
|
||||
|
||||
return pybars.strlist([result])
|
||||
|
||||
|
||||
def _round_helper(this, *args):
|
||||
"""Round a number to the specified number of decimal places."""
|
||||
if len(args) < 1:
|
||||
return ""
|
||||
|
||||
value = args[0]
|
||||
decimal_places = args[1] if len(args) > 1 else 2
|
||||
|
||||
try:
|
||||
result = str(round(float(value), int(decimal_places)))
|
||||
except (ValueError, TypeError):
|
||||
result = str(value)
|
||||
|
||||
return pybars.strlist([result])
|
||||
|
||||
|
||||
def _size_helper(this, *args):
|
||||
"""Return the size/length of a collection."""
|
||||
if len(args) < 1:
|
||||
return 0
|
||||
|
||||
value = args[0]
|
||||
if value is None:
|
||||
result = "0"
|
||||
elif isinstance(value, (list, tuple, dict, str)):
|
||||
result = str(len(value)) # pragma: no cover
|
||||
else: # pragma: no cover
|
||||
result = "0"
|
||||
|
||||
return pybars.strlist([result])
|
||||
|
||||
|
||||
def _json_helper(this, *args):
|
||||
"""Convert a value to a JSON string."""
|
||||
if len(args) < 1: # pragma: no cover
|
||||
return "{}"
|
||||
|
||||
value = args[0]
|
||||
# For pybars, we need to return a SafeString to prevent HTML escaping
|
||||
result = json.dumps(value) # pragma: no cover
|
||||
# Safe string implementation to prevent HTML escaping
|
||||
return pybars.strlist([result])
|
||||
|
||||
|
||||
def _math_helper(this, *args):
|
||||
"""Perform basic math operations."""
|
||||
if len(args) < 3:
|
||||
return pybars.strlist(["Math error: Insufficient arguments"])
|
||||
|
||||
lhs = args[0]
|
||||
operator = args[1]
|
||||
rhs = args[2]
|
||||
|
||||
try:
|
||||
lhs = float(lhs)
|
||||
rhs = float(rhs)
|
||||
if operator == "+":
|
||||
result = str(lhs + rhs)
|
||||
elif operator == "-":
|
||||
result = str(lhs - rhs)
|
||||
elif operator == "*":
|
||||
result = str(lhs * rhs)
|
||||
elif operator == "/":
|
||||
result = str(lhs / rhs)
|
||||
else:
|
||||
result = f"Unsupported operator: {operator}"
|
||||
except (ValueError, TypeError) as e:
|
||||
result = f"Math error: {e}"
|
||||
|
||||
return pybars.strlist([result])
|
||||
|
||||
|
||||
def _lt_helper(this, *args):
|
||||
"""Check if left hand side is less than right hand side."""
|
||||
if len(args) < 2:
|
||||
return False
|
||||
|
||||
lhs = args[0]
|
||||
rhs = args[1]
|
||||
|
||||
try:
|
||||
return float(lhs) < float(rhs)
|
||||
except (ValueError, TypeError):
|
||||
# Fall back to string comparison for non-numeric values
|
||||
return str(lhs) < str(rhs)
|
||||
|
||||
|
||||
def _if_cond_helper(this, options, condition):
|
||||
"""Block helper for custom if conditionals."""
|
||||
if condition:
|
||||
return options["fn"](this)
|
||||
elif "inverse" in options:
|
||||
return options["inverse"](this)
|
||||
return "" # pragma: no cover
|
||||
|
||||
|
||||
def _dedent_helper(this, options):
|
||||
"""Dedent a block of text to remove common leading whitespace.
|
||||
|
||||
Usage:
|
||||
{{#dedent}}
|
||||
This text will have its
|
||||
common leading whitespace removed
|
||||
while preserving relative indentation.
|
||||
{{/dedent}}
|
||||
"""
|
||||
if "fn" not in options: # pragma: no cover
|
||||
return ""
|
||||
|
||||
# Get the content from the block
|
||||
content = options["fn"](this)
|
||||
|
||||
# Convert to string if it's a strlist
|
||||
if (
|
||||
isinstance(content, list)
|
||||
or hasattr(content, "__iter__")
|
||||
and not isinstance(content, (str, bytes))
|
||||
):
|
||||
content_str = "".join(str(item) for item in content) # pragma: no cover
|
||||
else:
|
||||
content_str = str(content) # pragma: no cover
|
||||
|
||||
# Add trailing and leading newlines to ensure proper dedenting
|
||||
# This is critical for textwrap.dedent to work correctly with mixed content
|
||||
content_str = "\n" + content_str + "\n"
|
||||
|
||||
# Use textwrap to dedent the content and remove the extra newlines we added
|
||||
dedented = textwrap.dedent(content_str)[1:-1]
|
||||
|
||||
# Return as a SafeString to prevent HTML escaping
|
||||
return pybars.strlist([dedented]) # pragma: no cover
|
||||
|
||||
|
||||
class TemplateLoader:
|
||||
"""Loader for Handlebars templates.
|
||||
|
||||
This class is responsible for loading templates from disk and rendering
|
||||
them with the provided context data.
|
||||
"""
|
||||
|
||||
def __init__(self, template_dir: Optional[str] = None):
|
||||
"""Initialize the template loader.
|
||||
|
||||
Args:
|
||||
template_dir: Optional custom template directory path
|
||||
"""
|
||||
self.template_dir = Path(template_dir) if template_dir else TEMPLATES_DIR
|
||||
self.template_cache: Dict[str, Callable] = {}
|
||||
self.compiler = pybars.Compiler()
|
||||
|
||||
# Set up standard helpers
|
||||
self.helpers = {
|
||||
"date": _date_helper,
|
||||
"default": _default_helper,
|
||||
"capitalize": _capitalize_helper,
|
||||
"round": _round_helper,
|
||||
"size": _size_helper,
|
||||
"json": _json_helper,
|
||||
"math": _math_helper,
|
||||
"lt": _lt_helper,
|
||||
"if_cond": _if_cond_helper,
|
||||
"dedent": _dedent_helper,
|
||||
}
|
||||
|
||||
logger.debug(f"Initialized template loader with directory: {self.template_dir}")
|
||||
|
||||
def get_template(self, template_path: str) -> Callable:
|
||||
"""Get a template by path, using cache if available.
|
||||
|
||||
Args:
|
||||
template_path: The path to the template, relative to the templates directory
|
||||
|
||||
Returns:
|
||||
The compiled Handlebars template
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the template doesn't exist
|
||||
"""
|
||||
if template_path in self.template_cache:
|
||||
return self.template_cache[template_path]
|
||||
|
||||
# Convert from Liquid-style path to Handlebars extension
|
||||
if template_path.endswith(".liquid"):
|
||||
template_path = template_path.replace(".liquid", ".hbs")
|
||||
elif not template_path.endswith(".hbs"):
|
||||
template_path = f"{template_path}.hbs"
|
||||
|
||||
full_path = self.template_dir / template_path
|
||||
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"Template not found: {full_path}")
|
||||
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
template_str = f.read()
|
||||
|
||||
template = self.compiler.compile(template_str)
|
||||
self.template_cache[template_path] = template
|
||||
|
||||
logger.debug(f"Loaded template: {template_path}")
|
||||
return template
|
||||
|
||||
async def render(self, template_path: str, context: Dict[str, Any]) -> str:
|
||||
"""Render a template with the given context.
|
||||
|
||||
Args:
|
||||
template_path: The path to the template, relative to the templates directory
|
||||
context: The context data to pass to the template
|
||||
|
||||
Returns:
|
||||
The rendered template as a string
|
||||
"""
|
||||
template = self.get_template(template_path)
|
||||
return template(context, helpers=self.helpers)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the template cache."""
|
||||
self.template_cache.clear()
|
||||
logger.debug("Template cache cleared")
|
||||
|
||||
|
||||
# Global template loader instance
|
||||
template_loader = TemplateLoader()
|
||||
+22
-23
@@ -2,6 +2,9 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.mcp.project_session import session
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
"""Show version and exit."""
|
||||
@@ -39,31 +42,27 @@ def app_callback(
|
||||
) -> None:
|
||||
"""Basic Memory - Local-first personal knowledge management."""
|
||||
|
||||
# We use the project option to set the BASIC_MEMORY_PROJECT environment variable
|
||||
# The config module will pick this up when loading
|
||||
if project: # pragma: no cover
|
||||
import os
|
||||
import importlib
|
||||
from basic_memory import config as config_module
|
||||
|
||||
# Set the environment variable
|
||||
os.environ["BASIC_MEMORY_PROJECT"] = project
|
||||
|
||||
# Reload the config module to pick up the new project
|
||||
importlib.reload(config_module)
|
||||
|
||||
# Update the local reference
|
||||
global config
|
||||
from basic_memory.config import config as new_config
|
||||
|
||||
config = new_config
|
||||
|
||||
# Run migrations for every command unless --version was specified
|
||||
# Run initialization for every command unless --version was specified
|
||||
if not version and ctx.invoked_subcommand is not None:
|
||||
from basic_memory.config import config
|
||||
from basic_memory.services.initialization import ensure_initialize_database
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.services.initialization import ensure_initialization
|
||||
|
||||
ensure_initialize_database(config)
|
||||
ensure_initialization(app_config)
|
||||
|
||||
# Initialize MCP session with the specified project or default
|
||||
if project: # pragma: no cover
|
||||
# Use the project specified via --project flag
|
||||
current_project_config = get_project_config(project)
|
||||
session.set_current_project(current_project_config.name)
|
||||
|
||||
# Update the global config to use this project
|
||||
from basic_memory.config import update_current_project
|
||||
|
||||
update_current_project(project)
|
||||
else:
|
||||
# Use the default project
|
||||
current_project = app_config.default_project
|
||||
session.set_current_project(current_project)
|
||||
|
||||
|
||||
# Register sub-command groups
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""CLI commands for basic-memory."""
|
||||
|
||||
from . import status, sync, db, import_memory_json, mcp, import_claude_conversations
|
||||
from . import auth, status, sync, db, import_memory_json, mcp, import_claude_conversations
|
||||
from . import import_claude_projects, import_chatgpt, tool, project
|
||||
|
||||
__all__ = [
|
||||
"auth",
|
||||
"status",
|
||||
"sync",
|
||||
"db",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""OAuth management commands."""
|
||||
|
||||
import typer
|
||||
from typing import Optional
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.mcp.auth_provider import BasicMemoryOAuthProvider
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
|
||||
|
||||
auth_app = typer.Typer(help="OAuth client management commands")
|
||||
app.add_typer(auth_app, name="auth")
|
||||
|
||||
|
||||
@auth_app.command()
|
||||
def register_client(
|
||||
client_id: Optional[str] = typer.Option(
|
||||
None, help="Client ID (auto-generated if not provided)"
|
||||
),
|
||||
client_secret: Optional[str] = typer.Option(
|
||||
None, help="Client secret (auto-generated if not provided)"
|
||||
),
|
||||
issuer_url: str = typer.Option("http://localhost:8000", help="OAuth issuer URL"),
|
||||
):
|
||||
"""Register a new OAuth client for Basic Memory MCP server."""
|
||||
|
||||
# Create provider instance
|
||||
provider = BasicMemoryOAuthProvider(issuer_url=issuer_url)
|
||||
|
||||
# Create client info with required redirect_uris
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id=client_id or "", # Provider will generate if empty
|
||||
client_secret=client_secret or "", # Provider will generate if empty
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:8000/callback")], # Default redirect URI
|
||||
client_name="Basic Memory OAuth Client",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
)
|
||||
|
||||
# Register the client
|
||||
import asyncio
|
||||
|
||||
asyncio.run(provider.register_client(client_info))
|
||||
|
||||
typer.echo("Client registered successfully!")
|
||||
typer.echo(f"Client ID: {client_info.client_id}")
|
||||
typer.echo(f"Client Secret: {client_info.client_secret}")
|
||||
typer.echo("\nSave these credentials securely - the client secret cannot be retrieved later.")
|
||||
|
||||
|
||||
@auth_app.command()
|
||||
def test_auth(
|
||||
issuer_url: str = typer.Option("http://localhost:8000", help="OAuth issuer URL"),
|
||||
):
|
||||
"""Test OAuth authentication flow.
|
||||
|
||||
IMPORTANT: Use the same FASTMCP_AUTH_SECRET_KEY environment variable
|
||||
as your MCP server for tokens to validate correctly.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from mcp.server.auth.provider import AuthorizationParams
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
async def test_flow():
|
||||
# Create provider with same secret key as server
|
||||
provider = BasicMemoryOAuthProvider(issuer_url=issuer_url)
|
||||
|
||||
# Register a test client
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id=secrets.token_urlsafe(16),
|
||||
client_secret=secrets.token_urlsafe(32),
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:8000/callback")],
|
||||
client_name="Test OAuth Client",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
)
|
||||
await provider.register_client(client_info)
|
||||
typer.echo(f"Registered test client: {client_info.client_id}")
|
||||
|
||||
# Get the client
|
||||
client = await provider.get_client(client_info.client_id)
|
||||
if not client:
|
||||
typer.echo("Error: Client not found after registration", err=True)
|
||||
return
|
||||
|
||||
# Create authorization request
|
||||
auth_params = AuthorizationParams(
|
||||
state="test-state",
|
||||
scopes=["read", "write"],
|
||||
code_challenge="test-challenge",
|
||||
redirect_uri=AnyHttpUrl("http://localhost:8000/callback"),
|
||||
redirect_uri_provided_explicitly=True,
|
||||
)
|
||||
|
||||
# Get authorization URL
|
||||
auth_url = await provider.authorize(client, auth_params)
|
||||
typer.echo(f"Authorization URL: {auth_url}")
|
||||
|
||||
# Extract auth code from URL
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
parsed = urlparse(auth_url)
|
||||
params = parse_qs(parsed.query)
|
||||
auth_code = params.get("code", [None])[0]
|
||||
|
||||
if not auth_code:
|
||||
typer.echo("Error: No authorization code in URL", err=True)
|
||||
return
|
||||
|
||||
# Load the authorization code
|
||||
code_obj = await provider.load_authorization_code(client, auth_code)
|
||||
if not code_obj:
|
||||
typer.echo("Error: Invalid authorization code", err=True)
|
||||
return
|
||||
|
||||
# Exchange for tokens
|
||||
token = await provider.exchange_authorization_code(client, code_obj)
|
||||
typer.echo(f"Access token: {token.access_token}")
|
||||
typer.echo(f"Refresh token: {token.refresh_token}")
|
||||
typer.echo(f"Expires in: {token.expires_in} seconds")
|
||||
|
||||
# Validate access token
|
||||
access_token_obj = await provider.load_access_token(token.access_token)
|
||||
if access_token_obj:
|
||||
typer.echo("Access token validated successfully!")
|
||||
typer.echo(f"Client ID: {access_token_obj.client_id}")
|
||||
typer.echo(f"Scopes: {access_token_obj.scopes}")
|
||||
else:
|
||||
typer.echo("Error: Invalid access token", err=True)
|
||||
|
||||
asyncio.run(test_flow())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
auth_app()
|
||||
@@ -7,7 +7,7 @@ from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.config import app_config
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -18,7 +18,7 @@ def reset(
|
||||
if typer.confirm("This will delete all data in your db. Are you sure?"):
|
||||
logger.info("Resetting database...")
|
||||
# Get database path
|
||||
db_path = config.database_path
|
||||
db_path = app_config.app_database_path
|
||||
|
||||
# Delete the database file if it exists
|
||||
if db_path.exists():
|
||||
@@ -26,7 +26,7 @@ def reset(
|
||||
logger.info(f"Database file deleted: {db_path}")
|
||||
|
||||
# Create a new empty database
|
||||
asyncio.run(db.run_migrations(config))
|
||||
asyncio.run(db.run_migrations(app_config))
|
||||
logger.info("Database reset complete")
|
||||
|
||||
if reindex:
|
||||
|
||||
@@ -2,203 +2,21 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated, Set, Optional
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.importers import ChatGPTImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def clean_filename(text: str) -> str:
|
||||
"""Convert text to safe filename."""
|
||||
clean = "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
|
||||
return clean
|
||||
|
||||
|
||||
def format_timestamp(ts: float) -> str:
|
||||
"""Format Unix timestamp for display."""
|
||||
dt = datetime.fromtimestamp(ts)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def get_message_content(message: Dict[str, Any]) -> str:
|
||||
"""Extract clean message content."""
|
||||
if not message or "content" not in message:
|
||||
return "" # pragma: no cover
|
||||
|
||||
content = message["content"]
|
||||
if content.get("content_type") == "text":
|
||||
return "\n".join(content.get("parts", []))
|
||||
elif content.get("content_type") == "code":
|
||||
return f"```{content.get('language', '')}\n{content.get('text', '')}\n```"
|
||||
return "" # pragma: no cover
|
||||
|
||||
|
||||
def traverse_messages(
|
||||
mapping: Dict[str, Any], root_id: Optional[str], seen: Set[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Traverse message tree and return messages in order."""
|
||||
messages = []
|
||||
node = mapping.get(root_id) if root_id else None
|
||||
|
||||
while node:
|
||||
if node["id"] not in seen and node.get("message"):
|
||||
seen.add(node["id"])
|
||||
messages.append(node["message"])
|
||||
|
||||
# Follow children
|
||||
children = node.get("children", [])
|
||||
for child_id in children:
|
||||
child_msgs = traverse_messages(mapping, child_id, seen)
|
||||
messages.extend(child_msgs)
|
||||
|
||||
break # Don't follow siblings
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def format_chat_markdown(
|
||||
title: str,
|
||||
mapping: Dict[str, Any],
|
||||
root_id: Optional[str],
|
||||
created_at: float,
|
||||
modified_at: float,
|
||||
) -> str:
|
||||
"""Format chat as clean markdown."""
|
||||
|
||||
# Start with title
|
||||
lines = [f"# {title}\n"]
|
||||
|
||||
# Traverse message tree
|
||||
seen_msgs = set()
|
||||
messages = traverse_messages(mapping, root_id, seen_msgs)
|
||||
|
||||
# Format each message
|
||||
for msg in messages:
|
||||
# Skip hidden messages
|
||||
if msg.get("metadata", {}).get("is_visually_hidden_from_conversation"):
|
||||
continue
|
||||
|
||||
# Get author and timestamp
|
||||
author = msg["author"]["role"].title()
|
||||
ts = format_timestamp(msg["create_time"]) if msg.get("create_time") else ""
|
||||
|
||||
# Add message header
|
||||
lines.append(f"### {author} ({ts})")
|
||||
|
||||
# Add message content
|
||||
content = get_message_content(msg)
|
||||
if content:
|
||||
lines.append(content)
|
||||
|
||||
# Add spacing
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_chat_content(folder: str, conversation: Dict[str, Any]) -> EntityMarkdown:
|
||||
"""Convert chat conversation to Basic Memory entity."""
|
||||
|
||||
# Extract timestamps
|
||||
created_at = conversation["create_time"]
|
||||
modified_at = conversation["update_time"]
|
||||
|
||||
root_id = None
|
||||
# Find root message
|
||||
for node_id, node in conversation["mapping"].items():
|
||||
if node.get("parent") is None:
|
||||
root_id = node_id
|
||||
break
|
||||
|
||||
# Generate permalink
|
||||
date_prefix = datetime.fromtimestamp(created_at).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(conversation["title"])
|
||||
|
||||
# Format content
|
||||
content = format_chat_markdown(
|
||||
title=conversation["title"],
|
||||
mapping=conversation["mapping"],
|
||||
root_id=root_id,
|
||||
created_at=created_at,
|
||||
modified_at=modified_at,
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "conversation",
|
||||
"title": conversation["title"],
|
||||
"created": format_timestamp(created_at),
|
||||
"modified": format_timestamp(modified_at),
|
||||
"permalink": f"{folder}/{date_prefix}-{clean_title}",
|
||||
}
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
async def process_chatgpt_json(
|
||||
json_path: Path, folder: str, markdown_processor: MarkdownProcessor
|
||||
) -> Dict[str, int]:
|
||||
"""Import conversations from ChatGPT JSON format."""
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
console=console,
|
||||
) as progress:
|
||||
read_task = progress.add_task("Reading chat data...", total=None)
|
||||
|
||||
# Read conversations
|
||||
conversations = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
progress.update(read_task, total=len(conversations))
|
||||
|
||||
# Process each conversation
|
||||
messages_imported = 0
|
||||
chats_imported = 0
|
||||
|
||||
for chat in conversations:
|
||||
# Convert to entity
|
||||
entity = format_chat_content(folder, chat)
|
||||
|
||||
# Write file
|
||||
file_path = config.home / f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
# logger.info(f"Writing file: {file_path.absolute()}")
|
||||
await markdown_processor.write_file(file_path, entity)
|
||||
|
||||
# Count messages
|
||||
msg_count = sum(
|
||||
1
|
||||
for node in chat["mapping"].values()
|
||||
if node.get("message")
|
||||
and not node.get("message", {})
|
||||
.get("metadata", {})
|
||||
.get("is_visually_hidden_from_conversation")
|
||||
)
|
||||
|
||||
chats_imported += 1
|
||||
messages_imported += msg_count
|
||||
progress.update(read_task, advance=1)
|
||||
|
||||
return {"conversations": chats_imported, "messages": messages_imported}
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
entity_parser = EntityParser(config.home)
|
||||
@@ -225,30 +43,36 @@ def import_chatgpt(
|
||||
"""
|
||||
|
||||
try:
|
||||
if conversations_json:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
if not conversations_json.exists(): # pragma: no cover
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_chatgpt_json(conversations_json, folder, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
)
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
|
||||
# Create importer and run import
|
||||
importer = ChatGPTImporter(config.home, markdown_processor)
|
||||
with conversations_json.open("r", encoding="utf-8") as file:
|
||||
json_data = json.load(file)
|
||||
result = asyncio.run(importer.import_data(json_data, folder))
|
||||
|
||||
if not result.success: # pragma: no cover
|
||||
typer.echo(f"Error during import: {result.error_message}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {result.conversations} conversations\n"
|
||||
f"Containing {result.messages} messages",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
|
||||
@@ -2,156 +2,21 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import claude_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def clean_filename(text: str) -> str:
|
||||
"""Convert text to safe filename."""
|
||||
# Remove invalid characters and convert spaces
|
||||
clean = "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
|
||||
return clean
|
||||
|
||||
|
||||
def format_timestamp(ts: str) -> str:
|
||||
"""Format ISO timestamp for display."""
|
||||
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def format_chat_markdown(
|
||||
name: str, messages: List[Dict[str, Any]], created_at: str, modified_at: str, permalink: str
|
||||
) -> str:
|
||||
"""Format chat as clean markdown."""
|
||||
|
||||
# Start with frontmatter and title
|
||||
lines = [
|
||||
f"# {name}\n",
|
||||
]
|
||||
|
||||
# Add messages
|
||||
for msg in messages:
|
||||
# Format timestamp
|
||||
ts = format_timestamp(msg["created_at"])
|
||||
|
||||
# Add message header
|
||||
lines.append(f"### {msg['sender'].title()} ({ts})")
|
||||
|
||||
# Handle message content
|
||||
content = msg.get("text", "")
|
||||
if msg.get("content"):
|
||||
content = " ".join(c.get("text", "") for c in msg["content"])
|
||||
lines.append(content)
|
||||
|
||||
# Handle attachments
|
||||
attachments = msg.get("attachments", [])
|
||||
for attachment in attachments:
|
||||
if "file_name" in attachment:
|
||||
lines.append(f"\n**Attachment: {attachment['file_name']}**")
|
||||
if "extracted_content" in attachment:
|
||||
lines.append("```")
|
||||
lines.append(attachment["extracted_content"])
|
||||
lines.append("```")
|
||||
|
||||
# Add spacing between messages
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_chat_content(
|
||||
base_path: Path, name: str, messages: List[Dict[str, Any]], created_at: str, modified_at: str
|
||||
) -> EntityMarkdown:
|
||||
"""Convert chat messages to Basic Memory entity format."""
|
||||
|
||||
# Generate permalink
|
||||
date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(name)
|
||||
permalink = f"{base_path}/{date_prefix}-{clean_title}"
|
||||
|
||||
# Format content
|
||||
content = format_chat_markdown(
|
||||
name=name,
|
||||
messages=messages,
|
||||
created_at=created_at,
|
||||
modified_at=modified_at,
|
||||
permalink=permalink,
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "conversation",
|
||||
"title": name,
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": permalink,
|
||||
}
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
async def process_conversations_json(
|
||||
json_path: Path, base_path: Path, markdown_processor: MarkdownProcessor
|
||||
) -> Dict[str, int]:
|
||||
"""Import chat data from conversations2.json format."""
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
console=console,
|
||||
) as progress:
|
||||
read_task = progress.add_task("Reading chat data...", total=None)
|
||||
|
||||
# Read chat data - handle array of arrays format
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
conversations = [chat for chat in data]
|
||||
progress.update(read_task, total=len(conversations))
|
||||
|
||||
# Process each conversation
|
||||
messages_imported = 0
|
||||
chats_imported = 0
|
||||
|
||||
for chat in conversations:
|
||||
# Convert to entity
|
||||
entity = format_chat_content(
|
||||
base_path=base_path,
|
||||
name=chat["name"],
|
||||
messages=chat["chat_messages"],
|
||||
created_at=chat["created_at"],
|
||||
modified_at=chat["updated_at"],
|
||||
)
|
||||
|
||||
# Write file
|
||||
file_path = Path(f"{entity.frontmatter.metadata['permalink']}.md")
|
||||
await markdown_processor.write_file(file_path, entity)
|
||||
|
||||
chats_imported += 1
|
||||
messages_imported += len(chat["chat_messages"])
|
||||
progress.update(read_task, advance=1)
|
||||
|
||||
return {"conversations": chats_imported, "messages": messages_imported}
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
entity_parser = EntityParser(config.home)
|
||||
@@ -185,19 +50,28 @@ def import_claude(
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Create the importer
|
||||
importer = ClaudeConversationsImporter(config.home, markdown_processor)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_conversations_json(conversations_json, base_path, markdown_processor)
|
||||
)
|
||||
|
||||
# Run the import
|
||||
with conversations_json.open("r", encoding="utf-8") as file:
|
||||
json_data = json.load(file)
|
||||
result = asyncio.run(importer.import_data(json_data, folder))
|
||||
|
||||
if not result.success: # pragma: no cover
|
||||
typer.echo(f"Error during import: {result.error_message}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
f"Imported {result.conversations} conversations\n"
|
||||
f"Containing {result.messages} messages",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,138 +3,20 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Annotated, Optional
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import claude_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def clean_filename(text: str) -> str:
|
||||
"""Convert text to safe filename."""
|
||||
clean = "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
|
||||
return clean
|
||||
|
||||
|
||||
def format_project_markdown(project: Dict[str, Any], doc: Dict[str, Any]) -> EntityMarkdown:
|
||||
"""Format a project document as a Basic Memory entity."""
|
||||
|
||||
# Extract timestamps
|
||||
created_at = doc.get("created_at") or project["created_at"]
|
||||
modified_at = project["updated_at"]
|
||||
|
||||
# Generate clean names for organization
|
||||
project_dir = clean_filename(project["name"])
|
||||
doc_file = clean_filename(doc["filename"])
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "project_doc",
|
||||
"title": doc["filename"],
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": f"{project_dir}/docs/{doc_file}",
|
||||
"project_name": project["name"],
|
||||
"project_uuid": project["uuid"],
|
||||
"doc_uuid": doc["uuid"],
|
||||
}
|
||||
),
|
||||
content=doc["content"],
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
def format_prompt_markdown(project: Dict[str, Any]) -> Optional[EntityMarkdown]:
|
||||
"""Format project prompt template as a Basic Memory entity."""
|
||||
|
||||
if not project.get("prompt_template"):
|
||||
return None
|
||||
|
||||
# Extract timestamps
|
||||
created_at = project["created_at"]
|
||||
modified_at = project["updated_at"]
|
||||
|
||||
# Generate clean project directory name
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "prompt_template",
|
||||
"title": f"Prompt Template: {project['name']}",
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": f"{project_dir}/prompt-template",
|
||||
"project_name": project["name"],
|
||||
"project_uuid": project["uuid"],
|
||||
}
|
||||
),
|
||||
content=f"# Prompt Template: {project['name']}\n\n{project['prompt_template']}",
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
async def process_projects_json(
|
||||
json_path: Path, base_path: Path, markdown_processor: MarkdownProcessor
|
||||
) -> Dict[str, int]:
|
||||
"""Import project data from Claude.ai projects.json format."""
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
console=console,
|
||||
) as progress:
|
||||
read_task = progress.add_task("Reading project data...", total=None)
|
||||
|
||||
# Read project data
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
progress.update(read_task, total=len(data))
|
||||
|
||||
# Track import counts
|
||||
docs_imported = 0
|
||||
prompts_imported = 0
|
||||
|
||||
# Process each project
|
||||
for project in data:
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Create project directories
|
||||
docs_dir = base_path / project_dir / "docs"
|
||||
docs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Import prompt template if it exists
|
||||
if prompt_entity := format_prompt_markdown(project):
|
||||
file_path = base_path / f"{prompt_entity.frontmatter.metadata['permalink']}.md"
|
||||
await markdown_processor.write_file(file_path, prompt_entity)
|
||||
prompts_imported += 1
|
||||
|
||||
# Import project documents
|
||||
for doc in project.get("docs", []):
|
||||
entity = format_project_markdown(project, doc)
|
||||
file_path = base_path / f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await markdown_processor.write_file(file_path, entity)
|
||||
docs_imported += 1
|
||||
|
||||
progress.update(read_task, advance=1)
|
||||
|
||||
return {"documents": docs_imported, "prompts": prompts_imported}
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
entity_parser = EntityParser(config.home)
|
||||
@@ -160,30 +42,38 @@ def import_projects(
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
try:
|
||||
if projects_json:
|
||||
if not projects_json.exists():
|
||||
typer.echo(f"Error: File not found: {projects_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
if not projects_json.exists():
|
||||
typer.echo(f"Error: File not found: {projects_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / base_folder if base_folder else config.home
|
||||
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_projects_json(projects_json, base_path, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['documents']} project documents\n"
|
||||
f"Imported {results['prompts']} prompt templates",
|
||||
expand=False,
|
||||
)
|
||||
# Create the importer
|
||||
importer = ClaudeProjectsImporter(config.home, markdown_processor)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / base_folder if base_folder else config.home
|
||||
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
|
||||
|
||||
# Run the import
|
||||
with projects_json.open("r", encoding="utf-8") as file:
|
||||
json_data = json.load(file)
|
||||
result = asyncio.run(importer.import_data(json_data, base_folder))
|
||||
|
||||
if not result.success: # pragma: no cover
|
||||
typer.echo(f"Error during import: {result.error_message}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {result.documents} project documents\n"
|
||||
f"Imported {result.prompts} prompt templates",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
|
||||
@@ -3,94 +3,20 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
||||
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter, Observation, Relation
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
async def process_memory_json(
|
||||
json_path: Path, base_path: Path, markdown_processor: MarkdownProcessor
|
||||
):
|
||||
"""Import entities from memory.json using markdown processor."""
|
||||
|
||||
# First pass - collect all relations by source entity
|
||||
entity_relations: Dict[str, List[Relation]] = {}
|
||||
entities: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
console=console,
|
||||
) as progress:
|
||||
read_task = progress.add_task("Reading memory.json...", total=None)
|
||||
|
||||
# First pass - collect entities and relations
|
||||
with open(json_path) as f:
|
||||
lines = f.readlines()
|
||||
progress.update(read_task, total=len(lines))
|
||||
|
||||
for line in lines:
|
||||
data = json.loads(line)
|
||||
if data["type"] == "entity":
|
||||
entities[data["name"]] = data
|
||||
elif data["type"] == "relation":
|
||||
# Store relation with its source entity
|
||||
source = data.get("from") or data.get("from_id")
|
||||
if source not in entity_relations:
|
||||
entity_relations[source] = []
|
||||
entity_relations[source].append(
|
||||
Relation(
|
||||
type=data.get("relationType") or data.get("relation_type"),
|
||||
target=data.get("to") or data.get("to_id"),
|
||||
)
|
||||
)
|
||||
progress.update(read_task, advance=1)
|
||||
|
||||
# Second pass - create and write entities
|
||||
write_task = progress.add_task("Creating entities...", total=len(entities))
|
||||
|
||||
entities_created = 0
|
||||
for name, entity_data in entities.items():
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": entity_data["entityType"],
|
||||
"title": name,
|
||||
"permalink": f"{entity_data['entityType']}/{name}",
|
||||
}
|
||||
),
|
||||
content=f"# {name}\n",
|
||||
observations=[Observation(content=obs) for obs in entity_data["observations"]],
|
||||
relations=entity_relations.get(
|
||||
name, []
|
||||
), # Add any relations where this entity is the source
|
||||
)
|
||||
|
||||
# Let markdown processor handle writing
|
||||
file_path = base_path / f"{entity_data['entityType']}/{name}.md"
|
||||
await markdown_processor.write_file(file_path, entity)
|
||||
entities_created += 1
|
||||
progress.update(write_task, advance=1)
|
||||
|
||||
return {
|
||||
"entities": entities_created,
|
||||
"relations": sum(len(rels) for rels in entity_relations.values()),
|
||||
}
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
entity_parser = EntityParser(config.home)
|
||||
@@ -102,6 +28,9 @@ def memory_json(
|
||||
json_path: Annotated[Path, typer.Argument(..., help="Path to memory.json file")] = Path(
|
||||
"memory.json"
|
||||
),
|
||||
destination_folder: Annotated[
|
||||
str, typer.Option(help="Optional destination folder within the project")
|
||||
] = "",
|
||||
):
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
@@ -121,17 +50,31 @@ def memory_json(
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Create the importer
|
||||
importer = MemoryJsonImporter(config.home, markdown_processor)
|
||||
|
||||
# Process the file
|
||||
base_path = config.home
|
||||
base_path = config.home if not destination_folder else config.home / destination_folder
|
||||
console.print(f"\nImporting from {json_path}...writing to {base_path}")
|
||||
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
|
||||
|
||||
# Run the import for json log format
|
||||
file_data = []
|
||||
with json_path.open("r", encoding="utf-8") as file:
|
||||
for line in file:
|
||||
json_data = json.loads(line)
|
||||
file_data.append(json_data)
|
||||
result = asyncio.run(importer.import_data(file_data, destination_folder))
|
||||
|
||||
if not result.success: # pragma: no cover
|
||||
typer.echo(f"Error during import: {result.error_message}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Created {results['entities']} entities\n"
|
||||
f"Added {results['relations']} relations",
|
||||
f"Created {result.entities} entities\n"
|
||||
f"Added {result.relations} relations",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""MCP server command."""
|
||||
"""MCP server command with streamable HTTP transport."""
|
||||
|
||||
import asyncio
|
||||
import typer
|
||||
|
||||
import basic_memory
|
||||
from basic_memory.cli.app import app
|
||||
|
||||
# Import mcp instance
|
||||
@@ -9,27 +11,78 @@ from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
|
||||
# Import mcp tools to register them
|
||||
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
|
||||
|
||||
# Import prompts to register them
|
||||
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@app.command()
|
||||
def mcp(): # pragma: no cover
|
||||
"""Run the MCP server"""
|
||||
from basic_memory.config import config
|
||||
import asyncio
|
||||
from basic_memory.services.initialization import initialize_database
|
||||
def mcp(
|
||||
transport: str = typer.Option("stdio", help="Transport type: stdio, streamable-http, or sse"),
|
||||
host: str = typer.Option(
|
||||
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
|
||||
),
|
||||
port: int = typer.Option(8000, help="Port for HTTP transports"),
|
||||
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
|
||||
): # pragma: no cover
|
||||
"""Run the MCP server with configurable transport options.
|
||||
|
||||
# First, run just the database migrations synchronously
|
||||
asyncio.run(initialize_database(config))
|
||||
This command starts an MCP server using one of three transport options:
|
||||
|
||||
# Load config to check if sync is enabled
|
||||
from basic_memory.config import config_manager
|
||||
- stdio: Standard I/O (good for local usage)
|
||||
- streamable-http: Recommended for web deployments (default)
|
||||
- sse: Server-Sent Events (for compatibility with existing clients)
|
||||
"""
|
||||
|
||||
basic_memory_config = config_manager.load_config()
|
||||
# Check if OAuth is enabled
|
||||
import os
|
||||
|
||||
if basic_memory_config.sync_changes:
|
||||
# For now, we'll just log that sync will be handled by the MCP server
|
||||
from loguru import logger
|
||||
auth_enabled = os.getenv("FASTMCP_AUTH_ENABLED", "false").lower() == "true"
|
||||
if auth_enabled:
|
||||
logger.info("OAuth authentication is ENABLED")
|
||||
logger.info(f"Issuer URL: {os.getenv('FASTMCP_AUTH_ISSUER_URL', 'http://localhost:8000')}")
|
||||
if os.getenv("FASTMCP_AUTH_REQUIRED_SCOPES"):
|
||||
logger.info(f"Required scopes: {os.getenv('FASTMCP_AUTH_REQUIRED_SCOPES')}")
|
||||
else:
|
||||
logger.info("OAuth authentication is DISABLED")
|
||||
|
||||
logger.info("File sync will be handled by the MCP server")
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.services.initialization import initialize_file_sync
|
||||
|
||||
# Start the MCP server
|
||||
mcp_server.run()
|
||||
# Start the MCP server with the specified transport
|
||||
|
||||
# Use unified thread-based sync approach for both transports
|
||||
import threading
|
||||
|
||||
def run_file_sync():
|
||||
"""Run file sync in a separate thread with its own event loop."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(initialize_file_sync(app_config))
|
||||
except Exception as e:
|
||||
logger.error(f"File sync error: {e}", err=True)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
if app_config.sync_changes:
|
||||
# Start the sync thread
|
||||
sync_thread = threading.Thread(target=run_file_sync, daemon=True)
|
||||
sync_thread.start()
|
||||
logger.info("Started file sync in background")
|
||||
|
||||
# Now run the MCP server (blocks)
|
||||
logger.info(f"Starting MCP server with {transport.upper()} transport")
|
||||
|
||||
if transport == "stdio":
|
||||
mcp_server.run(
|
||||
transport=transport,
|
||||
)
|
||||
elif transport == "streamable-http" or transport == "sse":
|
||||
mcp_server.run(
|
||||
transport=transport,
|
||||
host=host,
|
||||
port=port,
|
||||
path=path,
|
||||
)
|
||||
|
||||
@@ -9,13 +9,21 @@ from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager, config
|
||||
from basic_memory.mcp.tools.project_info import project_info
|
||||
from basic_memory.config import config
|
||||
from basic_memory.mcp.project_session import session
|
||||
from basic_memory.mcp.resources.project_info import project_info
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.tree import Tree
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.project_info import ProjectList
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.project_info import ProjectStatusResponse
|
||||
from basic_memory.mcp.tools.utils import call_delete
|
||||
from basic_memory.mcp.tools.utils import call_put
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -28,112 +36,135 @@ def format_path(path: str) -> str:
|
||||
"""Format a path for display, using ~ for home directory."""
|
||||
home = str(Path.home())
|
||||
if path.startswith(home):
|
||||
return path.replace(home, "~", 1)
|
||||
return path.replace(home, "~", 1) # pragma: no cover
|
||||
return path
|
||||
|
||||
|
||||
@project_app.command("list")
|
||||
def list_projects() -> None:
|
||||
"""List all configured projects."""
|
||||
config_manager = ConfigManager()
|
||||
projects = config_manager.projects
|
||||
# Use API to list projects
|
||||
|
||||
table = Table(title="Basic Memory Projects")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Path", style="green")
|
||||
table.add_column("Default", style="yellow")
|
||||
table.add_column("Active", style="magenta")
|
||||
project_url = config.project_url
|
||||
|
||||
default_project = config_manager.default_project
|
||||
active_project = config.project
|
||||
try:
|
||||
response = asyncio.run(call_get(client, f"{project_url}/project/projects"))
|
||||
result = ProjectList.model_validate(response.json())
|
||||
|
||||
for name, path in projects.items():
|
||||
is_default = "✓" if name == default_project else ""
|
||||
is_active = "✓" if name == active_project else ""
|
||||
table.add_row(name, format_path(path), is_default, is_active)
|
||||
table = Table(title="Basic Memory Projects")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Path", style="green")
|
||||
table.add_column("Default", style="yellow")
|
||||
table.add_column("Active", style="magenta")
|
||||
|
||||
console.print(table)
|
||||
for project in result.projects:
|
||||
is_default = "✓" if project.is_default else ""
|
||||
is_active = "✓" if session.get_current_project() == project.name else ""
|
||||
table.add_row(project.name, format_path(project.path), is_default, is_active)
|
||||
|
||||
console.print(table)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error listing projects: {str(e)}[/red]")
|
||||
console.print("[yellow]Note: Make sure the Basic Memory server is running.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("add")
|
||||
def add_project(
|
||||
name: str = typer.Argument(..., help="Name of the project"),
|
||||
path: str = typer.Argument(..., help="Path to the project directory"),
|
||||
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
|
||||
) -> None:
|
||||
"""Add a new project."""
|
||||
config_manager = ConfigManager()
|
||||
# Resolve to absolute path
|
||||
resolved_path = os.path.abspath(os.path.expanduser(path))
|
||||
|
||||
try:
|
||||
# Resolve to absolute path
|
||||
resolved_path = os.path.abspath(os.path.expanduser(path))
|
||||
config_manager.add_project(name, resolved_path)
|
||||
console.print(f"[green]Project '{name}' added at {format_path(resolved_path)}[/green]")
|
||||
project_url = config.project_url
|
||||
data = {"name": name, "path": resolved_path, "set_default": set_default}
|
||||
|
||||
# Display usage hint
|
||||
console.print("\nTo use this project:")
|
||||
console.print(f" basic-memory --project={name} <command>")
|
||||
console.print(" # or")
|
||||
console.print(f" basic-memory project default {name}")
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
response = asyncio.run(call_post(client, f"{project_url}/project/projects", json=data))
|
||||
result = ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error adding project: {str(e)}[/red]")
|
||||
console.print("[yellow]Note: Make sure the Basic Memory server is running.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Display usage hint
|
||||
console.print("\nTo use this project:")
|
||||
console.print(f" basic-memory --project={name} <command>")
|
||||
console.print(" # or")
|
||||
console.print(f" basic-memory project default {name}")
|
||||
|
||||
|
||||
@project_app.command("remove")
|
||||
def remove_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to remove"),
|
||||
) -> None:
|
||||
"""Remove a project from configuration."""
|
||||
config_manager = ConfigManager()
|
||||
|
||||
try:
|
||||
config_manager.remove_project(name)
|
||||
console.print(f"[green]Project '{name}' removed from configuration[/green]")
|
||||
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
|
||||
except ValueError as e: # pragma: no cover
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
project_url = config.project_url
|
||||
|
||||
response = asyncio.run(call_delete(client, f"{project_url}/project/projects/{name}"))
|
||||
result = ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error removing project: {str(e)}[/red]")
|
||||
console.print("[yellow]Note: Make sure the Basic Memory server is running.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Show this message regardless of method used
|
||||
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
|
||||
|
||||
|
||||
@project_app.command("default")
|
||||
def set_default_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to set as default"),
|
||||
) -> None:
|
||||
"""Set the default project and activate it for the current session."""
|
||||
config_manager = ConfigManager()
|
||||
|
||||
try:
|
||||
# Set the default project
|
||||
config_manager.set_default_project(name)
|
||||
project_url = config.project_url
|
||||
|
||||
# Also activate it for the current session by setting the environment variable
|
||||
os.environ["BASIC_MEMORY_PROJECT"] = name
|
||||
response = asyncio.run(call_put(client, f"{project_url}/project/projects/{name}/default"))
|
||||
result = ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
# Reload configuration to apply the change
|
||||
from importlib import reload
|
||||
from basic_memory import config as config_module
|
||||
|
||||
reload(config_module)
|
||||
console.print(f"[green]Project '{name}' set as default and activated[/green]")
|
||||
except ValueError as e: # pragma: no cover
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error setting default project: {str(e)}[/red]")
|
||||
console.print("[yellow]Note: Make sure the Basic Memory server is running.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Always activate it for the current session
|
||||
os.environ["BASIC_MEMORY_PROJECT"] = name
|
||||
|
||||
@project_app.command("current")
|
||||
def show_current_project() -> None:
|
||||
"""Show the current project."""
|
||||
config_manager = ConfigManager()
|
||||
current = os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project)
|
||||
# Reload configuration to apply the change
|
||||
from importlib import reload
|
||||
from basic_memory import config as config_module
|
||||
|
||||
reload(config_module)
|
||||
|
||||
console.print("[green]Project activated for current session[/green]")
|
||||
|
||||
|
||||
@project_app.command("sync")
|
||||
def synchronize_projects() -> None:
|
||||
"""Synchronize projects between configuration file and database."""
|
||||
# Call the API to synchronize projects
|
||||
|
||||
project_url = config.project_url
|
||||
|
||||
try:
|
||||
path = config_manager.get_project_path(current)
|
||||
console.print(f"Current project: [cyan]{current}[/cyan]")
|
||||
console.print(f"Path: [green]{format_path(str(path))}[/green]")
|
||||
console.print(f"Database: [blue]{format_path(str(config.database_path))}[/blue]")
|
||||
except ValueError: # pragma: no cover
|
||||
console.print(f"[yellow]Warning: Project '{current}' not found in configuration[/yellow]")
|
||||
console.print(f"Using default project: [cyan]{config_manager.default_project}[/cyan]")
|
||||
response = asyncio.run(call_post(client, f"{project_url}/project/sync"))
|
||||
result = ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e: # pragma: no cover
|
||||
console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
|
||||
console.print("[yellow]Note: Make sure the Basic Memory server is running.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("info")
|
||||
@@ -266,9 +297,10 @@ def display_project_info(
|
||||
projects_table.add_column("Path", style="cyan")
|
||||
projects_table.add_column("Default", style="green")
|
||||
|
||||
for name, path in info.available_projects.items():
|
||||
for name, proj_info in info.available_projects.items():
|
||||
is_default = name == info.default_project
|
||||
projects_table.add_row(name, path, "✓" if is_default else "")
|
||||
project_path = proj_info["path"]
|
||||
projects_table.add_row(name, project_path, "✓" if is_default else "")
|
||||
|
||||
console.print(projects_table)
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@ from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.tree import Tree
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.sync import get_sync_service
|
||||
from basic_memory.config import config
|
||||
from basic_memory.sync import SyncService
|
||||
from basic_memory.config import config, app_config
|
||||
from basic_memory.repository import ProjectRepository
|
||||
from basic_memory.sync.sync_service import SyncReport
|
||||
|
||||
# Create rich console
|
||||
@@ -86,9 +87,9 @@ def build_directory_summary(counts: Dict[str, int]) -> str:
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def display_changes(title: str, changes: SyncReport, verbose: bool = False):
|
||||
def display_changes(project_name: str, title: str, changes: SyncReport, verbose: bool = False):
|
||||
"""Display changes using Rich for better visualization."""
|
||||
tree = Tree(title)
|
||||
tree = Tree(f"{project_name}: {title}")
|
||||
|
||||
if changes.total == 0:
|
||||
tree.add("No changes")
|
||||
@@ -121,11 +122,21 @@ def display_changes(title: str, changes: SyncReport, verbose: bool = False):
|
||||
console.print(Panel(tree, expand=False))
|
||||
|
||||
|
||||
async def run_status(sync_service: SyncService, verbose: bool = False):
|
||||
async def run_status(verbose: bool = False):
|
||||
"""Check sync status of files vs database."""
|
||||
# Check knowledge/ directory
|
||||
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
||||
)
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
project = await project_repository.get_by_name(config.project)
|
||||
if not project: # pragma: no cover
|
||||
raise Exception(f"Project '{config.project}' not found")
|
||||
|
||||
sync_service = await get_sync_service(project)
|
||||
knowledge_changes = await sync_service.scan(config.home)
|
||||
display_changes("Status", knowledge_changes, verbose)
|
||||
display_changes(project.name, "Status", knowledge_changes, verbose)
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -134,9 +145,8 @@ def status(
|
||||
):
|
||||
"""Show sync status between files and database."""
|
||||
try:
|
||||
sync_service = asyncio.run(get_sync_service())
|
||||
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
|
||||
asyncio.run(run_status(verbose)) # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking status: {e}")
|
||||
logger.error(f"Error checking status: {e}")
|
||||
typer.echo(f"Error checking status: {e}", err=True)
|
||||
raise typer.Exit(code=1) # pragma: no cover
|
||||
|
||||
@@ -16,10 +16,12 @@ from basic_memory.cli.app import app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.repository import (
|
||||
EntityRepository,
|
||||
ObservationRepository,
|
||||
RelationRepository,
|
||||
ProjectRepository,
|
||||
)
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
from basic_memory.services import EntityService, FileService
|
||||
@@ -27,7 +29,7 @@ from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.sync import SyncService
|
||||
from basic_memory.sync.sync_service import SyncReport
|
||||
from basic_memory.sync.watch_service import WatchService
|
||||
from basic_memory.config import app_config
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -38,21 +40,22 @@ class ValidationIssue:
|
||||
error: str
|
||||
|
||||
|
||||
async def get_sync_service(): # pragma: no cover
|
||||
async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
|
||||
"""Get sync service instance with all dependencies."""
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
||||
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
||||
)
|
||||
|
||||
entity_parser = EntityParser(config.home)
|
||||
project_path = Path(project.path)
|
||||
entity_parser = EntityParser(project_path)
|
||||
markdown_processor = MarkdownProcessor(entity_parser)
|
||||
file_service = FileService(config.home, markdown_processor)
|
||||
file_service = FileService(project_path, markdown_processor)
|
||||
|
||||
# Initialize repositories
|
||||
entity_repository = EntityRepository(session_maker)
|
||||
observation_repository = ObservationRepository(session_maker)
|
||||
relation_repository = RelationRepository(session_maker)
|
||||
search_repository = SearchRepository(session_maker)
|
||||
entity_repository = EntityRepository(session_maker, project_id=project.id)
|
||||
observation_repository = ObservationRepository(session_maker, project_id=project.id)
|
||||
relation_repository = RelationRepository(session_maker, project_id=project.id)
|
||||
search_repository = SearchRepository(session_maker, project_id=project.id)
|
||||
|
||||
# Initialize services
|
||||
search_service = SearchService(search_repository, entity_repository, file_service)
|
||||
@@ -70,7 +73,7 @@ async def get_sync_service(): # pragma: no cover
|
||||
|
||||
# Create sync service
|
||||
sync_service = SyncService(
|
||||
config=config,
|
||||
app_config=app_config,
|
||||
entity_service=entity_service,
|
||||
entity_parser=entity_parser,
|
||||
entity_repository=entity_repository,
|
||||
@@ -153,8 +156,16 @@ def display_detailed_sync_results(knowledge: SyncReport):
|
||||
console.print(knowledge_tree)
|
||||
|
||||
|
||||
async def run_sync(verbose: bool = False, watch: bool = False, console_status: bool = False):
|
||||
async def run_sync(verbose: bool = False):
|
||||
"""Run sync operation."""
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
||||
)
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
project = await project_repository.get_by_name(config.project)
|
||||
if not project: # pragma: no cover
|
||||
raise Exception(f"Project '{config.project}' not found")
|
||||
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
@@ -162,50 +173,33 @@ async def run_sync(verbose: bool = False, watch: bool = False, console_status: b
|
||||
logger.info(
|
||||
"Sync command started",
|
||||
project=config.project,
|
||||
watch_mode=watch,
|
||||
verbose=verbose,
|
||||
directory=str(config.home),
|
||||
)
|
||||
|
||||
sync_service = await get_sync_service()
|
||||
sync_service = await get_sync_service(project)
|
||||
|
||||
# Start watching if requested
|
||||
if watch:
|
||||
logger.info("Starting watch service after initial sync")
|
||||
watch_service = WatchService(
|
||||
sync_service=sync_service,
|
||||
file_service=sync_service.entity_service.file_service,
|
||||
config=config,
|
||||
)
|
||||
logger.info("Running one-time sync")
|
||||
knowledge_changes = await sync_service.sync(config.home)
|
||||
|
||||
# full sync - no progress bars in watch mode
|
||||
await sync_service.sync(config.home)
|
||||
# Log results
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
logger.info(
|
||||
"Sync command completed",
|
||||
project=config.project,
|
||||
total_changes=knowledge_changes.total,
|
||||
new_files=len(knowledge_changes.new),
|
||||
modified_files=len(knowledge_changes.modified),
|
||||
deleted_files=len(knowledge_changes.deleted),
|
||||
moved_files=len(knowledge_changes.moves),
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
# watch changes
|
||||
await watch_service.run() # pragma: no cover
|
||||
# Display results
|
||||
if verbose:
|
||||
display_detailed_sync_results(knowledge_changes)
|
||||
else:
|
||||
# one time sync
|
||||
logger.info("Running one-time sync")
|
||||
knowledge_changes = await sync_service.sync(config.home)
|
||||
|
||||
# Log results
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
logger.info(
|
||||
"Sync command completed",
|
||||
project=config.project,
|
||||
total_changes=knowledge_changes.total,
|
||||
new_files=len(knowledge_changes.new),
|
||||
modified_files=len(knowledge_changes.modified),
|
||||
deleted_files=len(knowledge_changes.deleted),
|
||||
moved_files=len(knowledge_changes.moves),
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
# Display results
|
||||
if verbose:
|
||||
display_detailed_sync_results(knowledge_changes)
|
||||
else:
|
||||
display_sync_summary(knowledge_changes) # pragma: no cover
|
||||
display_sync_summary(knowledge_changes) # pragma: no cover
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -216,22 +210,15 @@ def sync(
|
||||
"-v",
|
||||
help="Show detailed sync information.",
|
||||
),
|
||||
watch: bool = typer.Option(
|
||||
False,
|
||||
"--watch",
|
||||
"-w",
|
||||
help="Start watching for changes after sync.",
|
||||
),
|
||||
) -> None:
|
||||
"""Sync knowledge files with the database."""
|
||||
try:
|
||||
# Show which project we're syncing
|
||||
if not watch: # Don't show in watch mode as it would break the UI
|
||||
typer.echo(f"Syncing project: {config.project}")
|
||||
typer.echo(f"Project path: {config.home}")
|
||||
typer.echo(f"Syncing project: {config.project}")
|
||||
typer.echo(f"Project path: {config.home}")
|
||||
|
||||
# Run sync
|
||||
asyncio.run(run_sync(verbose=verbose, watch=watch))
|
||||
asyncio.run(run_sync(verbose=verbose))
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
@@ -240,7 +227,6 @@ def sync(
|
||||
f"project={config.project},"
|
||||
f"error={str(e)},"
|
||||
f"error_type={type(e).__name__},"
|
||||
f"watch_mode={watch},"
|
||||
f"directory={str(config.home)}",
|
||||
)
|
||||
typer.echo(f"Error during sync: {e}", err=True)
|
||||
|
||||
@@ -4,6 +4,7 @@ from basic_memory.cli.app import app # pragma: no cover
|
||||
|
||||
# Register commands
|
||||
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
auth,
|
||||
db,
|
||||
import_chatgpt,
|
||||
import_claude_conversations,
|
||||
@@ -15,12 +16,7 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
sync,
|
||||
tool,
|
||||
)
|
||||
from basic_memory.config import config
|
||||
from basic_memory.services.initialization import ensure_initialization
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# Run initialization if we are starting as a module
|
||||
ensure_initialization(config)
|
||||
|
||||
# start the app
|
||||
app()
|
||||
|
||||
+144
-88
@@ -2,76 +2,48 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
from typing import Any, Dict, Literal, Optional, List
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
import basic_memory
|
||||
from basic_memory.utils import setup_logging
|
||||
from basic_memory.utils import setup_logging, generate_permalink
|
||||
|
||||
|
||||
DATABASE_NAME = "memory.db"
|
||||
APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
|
||||
DATA_DIR_NAME = ".basic-memory"
|
||||
CONFIG_FILE_NAME = "config.json"
|
||||
WATCH_STATUS_JSON = "watch-status.json"
|
||||
|
||||
Environment = Literal["test", "dev", "user"]
|
||||
|
||||
|
||||
class ProjectConfig(BaseSettings):
|
||||
@dataclass
|
||||
class ProjectConfig:
|
||||
"""Configuration for a specific basic-memory project."""
|
||||
|
||||
env: Environment = Field(default="dev", description="Environment name")
|
||||
|
||||
# Default to ~/basic-memory but allow override with env var: BASIC_MEMORY_HOME
|
||||
home: Path = Field(
|
||||
default_factory=lambda: Path.home() / "basic-memory",
|
||||
description="Base path for basic-memory files",
|
||||
)
|
||||
|
||||
# Name of the project
|
||||
project: str = Field(default="default", description="Project name")
|
||||
|
||||
# Watch service configuration
|
||||
sync_delay: int = Field(
|
||||
default=1000, description="Milliseconds to wait after changes before syncing", gt=0
|
||||
)
|
||||
|
||||
# update permalinks on move
|
||||
update_permalinks_on_move: bool = Field(
|
||||
default=False,
|
||||
description="Whether to update permalinks when files are moved or renamed. default (False)",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="BASIC_MEMORY_",
|
||||
extra="ignore",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
name: str
|
||||
home: Path
|
||||
|
||||
@property
|
||||
def database_path(self) -> Path:
|
||||
"""Get SQLite database path."""
|
||||
database_path = self.home / DATA_DIR_NAME / DATABASE_NAME
|
||||
if not database_path.exists():
|
||||
database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
database_path.touch()
|
||||
return database_path
|
||||
def project(self):
|
||||
return self.name
|
||||
|
||||
@field_validator("home")
|
||||
@classmethod
|
||||
def ensure_path_exists(cls, v: Path) -> Path: # pragma: no cover
|
||||
"""Ensure project path exists."""
|
||||
if not v.exists():
|
||||
v.mkdir(parents=True)
|
||||
return v
|
||||
@property
|
||||
def project_url(self) -> str: # pragma: no cover
|
||||
return f"/{generate_permalink(self.name)}"
|
||||
|
||||
|
||||
class BasicMemoryConfig(BaseSettings):
|
||||
"""Pydantic model for Basic Memory global configuration."""
|
||||
|
||||
env: Environment = Field(default="dev", description="Environment name")
|
||||
|
||||
projects: Dict[str, str] = Field(
|
||||
default_factory=lambda: {"main": str(Path.home() / "basic-memory")},
|
||||
description="Mapping of project names to their filesystem paths",
|
||||
@@ -81,8 +53,15 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Name of the default project to use",
|
||||
)
|
||||
|
||||
# overridden by ~/.basic-memory/config.json
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Watch service configuration
|
||||
sync_delay: int = Field(
|
||||
default=1000, description="Milliseconds to wait after changes before syncing", gt=0
|
||||
)
|
||||
|
||||
# update permalinks on move
|
||||
update_permalinks_on_move: bool = Field(
|
||||
default=False,
|
||||
description="Whether to update permalinks when files are moved or renamed. default (False)",
|
||||
@@ -96,25 +75,84 @@ class BasicMemoryConfig(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="BASIC_MEMORY_",
|
||||
extra="ignore",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
|
||||
def get_project_path(self, project_name: Optional[str] = None) -> Path: # pragma: no cover
|
||||
"""Get the path for a specific project or the default project."""
|
||||
name = project_name or self.default_project
|
||||
|
||||
if name not in self.projects:
|
||||
raise ValueError(f"Project '{name}' not found in configuration")
|
||||
|
||||
return Path(self.projects[name])
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Ensure configuration is valid after initialization."""
|
||||
# Ensure main project exists
|
||||
if "main" not in self.projects:
|
||||
if "main" not in self.projects: # pragma: no cover
|
||||
self.projects["main"] = str(Path.home() / "basic-memory")
|
||||
|
||||
# Ensure default project is valid
|
||||
if self.default_project not in self.projects:
|
||||
if self.default_project not in self.projects: # pragma: no cover
|
||||
self.default_project = "main"
|
||||
|
||||
@property
|
||||
def app_database_path(self) -> Path:
|
||||
"""Get the path to the app-level database.
|
||||
|
||||
This is the single database that will store all knowledge data
|
||||
across all projects.
|
||||
"""
|
||||
database_path = Path.home() / DATA_DIR_NAME / APP_DATABASE_NAME
|
||||
if not database_path.exists(): # pragma: no cover
|
||||
database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
database_path.touch()
|
||||
return database_path
|
||||
|
||||
@property
|
||||
def database_path(self) -> Path:
|
||||
"""Get SQLite database path.
|
||||
|
||||
Rreturns the app-level database path
|
||||
for backward compatibility in the codebase.
|
||||
"""
|
||||
|
||||
# Load the app-level database path from the global config
|
||||
config = config_manager.load_config() # pragma: no cover
|
||||
return config.app_database_path # pragma: no cover
|
||||
|
||||
@property
|
||||
def project_list(self) -> List[ProjectConfig]: # pragma: no cover
|
||||
"""Get all configured projects as ProjectConfig objects."""
|
||||
return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()]
|
||||
|
||||
@field_validator("projects")
|
||||
@classmethod
|
||||
def ensure_project_paths_exists(cls, v: Dict[str, str]) -> Dict[str, str]: # pragma: no cover
|
||||
"""Ensure project path exists."""
|
||||
for name, path_value in v.items():
|
||||
path = Path(path_value)
|
||||
if not Path(path).exists():
|
||||
try:
|
||||
path.mkdir(parents=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create project path: {e}")
|
||||
raise e
|
||||
return v
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Manages Basic Memory configuration."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the configuration manager."""
|
||||
self.config_dir = Path.home() / DATA_DIR_NAME
|
||||
home = os.getenv("HOME", Path.home())
|
||||
if isinstance(home, str):
|
||||
home = Path(home)
|
||||
|
||||
self.config_dir = home / DATA_DIR_NAME
|
||||
self.config_file = self.config_dir / CONFIG_FILE_NAME
|
||||
|
||||
# Ensure config directory exists
|
||||
@@ -129,7 +167,7 @@ class ConfigManager:
|
||||
try:
|
||||
data = json.loads(self.config_file.read_text(encoding="utf-8"))
|
||||
return BasicMemoryConfig(**data)
|
||||
except Exception as e:
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Failed to load config: {e}")
|
||||
config = BasicMemoryConfig()
|
||||
self.save_config(config)
|
||||
@@ -141,7 +179,7 @@ class ConfigManager:
|
||||
|
||||
def save_config(self, config: BasicMemoryConfig) -> None:
|
||||
"""Save configuration to file."""
|
||||
try:
|
||||
try:
|
||||
self.config_file.write_text(json.dumps(config.model_dump(), indent=2))
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Failed to save config: {e}")
|
||||
@@ -156,37 +194,25 @@ class ConfigManager:
|
||||
"""Get the default project name."""
|
||||
return self.config.default_project
|
||||
|
||||
def get_project_path(self, project_name: Optional[str] = None) -> Path:
|
||||
"""Get the path for a specific project or the default project."""
|
||||
name = project_name or self.config.default_project
|
||||
|
||||
# Check if specified in environment variable
|
||||
if not project_name and "BASIC_MEMORY_PROJECT" in os.environ:
|
||||
name = os.environ["BASIC_MEMORY_PROJECT"]
|
||||
|
||||
if name not in self.config.projects:
|
||||
raise ValueError(f"Project '{name}' not found in configuration")
|
||||
|
||||
return Path(self.config.projects[name])
|
||||
|
||||
def add_project(self, name: str, path: str) -> None:
|
||||
def add_project(self, name: str, path: str) -> ProjectConfig:
|
||||
"""Add a new project to the configuration."""
|
||||
if name in self.config.projects:
|
||||
if name in self.config.projects: # pragma: no cover
|
||||
raise ValueError(f"Project '{name}' already exists")
|
||||
|
||||
# Ensure the path exists
|
||||
project_path = Path(path)
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
|
||||
|
||||
self.config.projects[name] = str(project_path)
|
||||
self.save_config(self.config)
|
||||
return ProjectConfig(name=name, home=project_path)
|
||||
|
||||
def remove_project(self, name: str) -> None:
|
||||
"""Remove a project from the configuration."""
|
||||
if name not in self.config.projects:
|
||||
if name not in self.config.projects: # pragma: no cover
|
||||
raise ValueError(f"Project '{name}' not found")
|
||||
|
||||
if name == self.config.default_project:
|
||||
if name == self.config.default_project: # pragma: no cover
|
||||
raise ValueError(f"Cannot remove the default project '{name}'")
|
||||
|
||||
del self.config.projects[name]
|
||||
@@ -202,33 +228,59 @@ class ConfigManager:
|
||||
|
||||
|
||||
def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
||||
"""Get a project configuration for the specified project."""
|
||||
config_manager = ConfigManager()
|
||||
"""
|
||||
Get the project configuration for the current session.
|
||||
If project_name is provided, it will be used instead of the default project.
|
||||
"""
|
||||
|
||||
# Get project name from environment variable or use provided name or default
|
||||
actual_project_name = os.environ.get(
|
||||
"BASIC_MEMORY_PROJECT", project_name or config_manager.default_project
|
||||
)
|
||||
actual_project_name = None
|
||||
|
||||
update_permalinks_on_move = config_manager.load_config().update_permalinks_on_move
|
||||
try:
|
||||
project_path = config_manager.get_project_path(actual_project_name)
|
||||
return ProjectConfig(
|
||||
home=project_path,
|
||||
project=actual_project_name,
|
||||
update_permalinks_on_move=update_permalinks_on_move,
|
||||
# load the config from file
|
||||
global app_config
|
||||
app_config = config_manager.load_config()
|
||||
|
||||
# Get project name from environment variable
|
||||
os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
|
||||
if os_project_name: # pragma: no cover
|
||||
logger.warning(
|
||||
f"BASIC_MEMORY_PROJECT is not supported anymore. Use the --project flag or set the default project in the config instead. Setting default project to {os_project_name}"
|
||||
)
|
||||
except ValueError: # pragma: no cover
|
||||
logger.warning(f"Project '{actual_project_name}' not found, using default")
|
||||
project_path = config_manager.get_project_path(config_manager.default_project)
|
||||
return ProjectConfig(home=project_path, project=config_manager.default_project)
|
||||
actual_project_name = project_name
|
||||
# if the project_name is passed in, use it
|
||||
elif not project_name:
|
||||
# use default
|
||||
actual_project_name = app_config.default_project
|
||||
else: # pragma: no cover
|
||||
actual_project_name = project_name
|
||||
|
||||
# the config contains a dict[str,str] of project names and absolute paths
|
||||
assert actual_project_name is not None, "actual_project_name cannot be None"
|
||||
|
||||
project_path = app_config.projects.get(actual_project_name)
|
||||
if not project_path: # pragma: no cover
|
||||
raise ValueError(f"Project '{actual_project_name}' not found")
|
||||
|
||||
return ProjectConfig(name=actual_project_name, home=Path(project_path))
|
||||
|
||||
|
||||
# Create config manager
|
||||
config_manager = ConfigManager()
|
||||
|
||||
# Load project config for current context
|
||||
config = get_project_config()
|
||||
# Export the app-level config
|
||||
app_config: BasicMemoryConfig = config_manager.config
|
||||
|
||||
# Load project config for the default project (backward compatibility)
|
||||
config: ProjectConfig = get_project_config()
|
||||
|
||||
|
||||
def update_current_project(project_name: str) -> None:
|
||||
"""Update the global config to use a different project.
|
||||
|
||||
This is used by the CLI when --project flag is specified.
|
||||
"""
|
||||
global config
|
||||
config = get_project_config(project_name) # pragma: no cover
|
||||
|
||||
|
||||
# setup logging to a single log file in user home directory
|
||||
user_home = Path.home()
|
||||
@@ -236,6 +288,7 @@ log_dir = user_home / DATA_DIR_NAME
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Process info for logging
|
||||
def get_process_name(): # pragma: no cover
|
||||
"""
|
||||
get the type of process for logging
|
||||
@@ -258,6 +311,9 @@ process_name = get_process_name()
|
||||
_LOGGING_SETUP = False
|
||||
|
||||
|
||||
# Logging
|
||||
|
||||
|
||||
def setup_basic_memory_logging(): # pragma: no cover
|
||||
"""Set up logging for basic-memory, ensuring it only happens once."""
|
||||
global _LOGGING_SETUP
|
||||
@@ -267,7 +323,7 @@ def setup_basic_memory_logging(): # pragma: no cover
|
||||
return
|
||||
|
||||
setup_logging(
|
||||
env=config.env,
|
||||
env=config_manager.config.env,
|
||||
home_dir=user_home, # Use user home for logs
|
||||
log_level=config_manager.load_config().log_level,
|
||||
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
|
||||
|
||||
@@ -4,8 +4,7 @@ from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
|
||||
from basic_memory.config import ProjectConfig
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
@@ -147,7 +146,7 @@ async def engine_session_factory(
|
||||
|
||||
|
||||
async def run_migrations(
|
||||
app_config: ProjectConfig, database_type=DatabaseType.FILESYSTEM
|
||||
app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM
|
||||
): # pragma: no cover
|
||||
"""Run any pending alembic migrations."""
|
||||
logger.info("Running database migrations...")
|
||||
@@ -172,7 +171,10 @@ async def run_migrations(
|
||||
logger.info("Migrations completed successfully")
|
||||
|
||||
_, session_maker = await get_or_create_db(app_config.database_path, database_type)
|
||||
await SearchRepository(session_maker).init_search_index()
|
||||
|
||||
# initialize the search Index schema
|
||||
# the project_id is not used for init_search_index, so we pass a dummy value
|
||||
await SearchRepository(session_maker, 1).init_search_index()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error running migrations: {e}")
|
||||
raise
|
||||
|
||||
+227
-30
@@ -1,50 +1,87 @@
|
||||
"""Dependency injection functions for basic-memory services."""
|
||||
|
||||
from typing import Annotated
|
||||
from loguru import logger
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, HTTPException, Path, status
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
AsyncEngine,
|
||||
async_sessionmaker,
|
||||
)
|
||||
import pathlib
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import ProjectConfig, config
|
||||
from basic_memory.config import ProjectConfig, BasicMemoryConfig
|
||||
from basic_memory.importers import (
|
||||
ChatGPTImporter,
|
||||
ClaudeConversationsImporter,
|
||||
ClaudeProjectsImporter,
|
||||
MemoryJsonImporter,
|
||||
)
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.repository.observation_repository import ObservationRepository
|
||||
from basic_memory.repository.project_info_repository import ProjectInfoRepository
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from basic_memory.repository.relation_repository import RelationRepository
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
from basic_memory.services import (
|
||||
EntityService,
|
||||
)
|
||||
from basic_memory.services import EntityService, ProjectService
|
||||
from basic_memory.services.context_service import ContextService
|
||||
from basic_memory.services.directory_service import DirectoryService
|
||||
from basic_memory.services.file_service import FileService
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.sync import SyncService
|
||||
from basic_memory.config import app_config
|
||||
|
||||
|
||||
def get_app_config() -> BasicMemoryConfig: # pragma: no cover
|
||||
return app_config
|
||||
|
||||
|
||||
AppConfigDep = Annotated[BasicMemoryConfig, Depends(get_app_config)] # pragma: no cover
|
||||
|
||||
|
||||
## project
|
||||
|
||||
|
||||
def get_project_config() -> ProjectConfig: # pragma: no cover
|
||||
return config
|
||||
async def get_project_config(
|
||||
project: "ProjectPathDep", project_repository: "ProjectRepositoryDep"
|
||||
) -> ProjectConfig: # pragma: no cover
|
||||
"""Get the current project referenced from request state.
|
||||
|
||||
Args:
|
||||
request: The current request object
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The resolved project config
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is not found
|
||||
"""
|
||||
|
||||
project_obj = await project_repository.get_by_permalink(str(project))
|
||||
if project_obj:
|
||||
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
|
||||
|
||||
# Not found
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
|
||||
)
|
||||
|
||||
|
||||
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover
|
||||
|
||||
|
||||
## sqlalchemy
|
||||
|
||||
|
||||
async def get_engine_factory(
|
||||
project_config: ProjectConfigDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
||||
"""Get engine and session maker."""
|
||||
engine, session_maker = await db.get_or_create_db(project_config.database_path)
|
||||
engine, session_maker = await db.get_or_create_db(app_config.database_path)
|
||||
return engine, session_maker
|
||||
|
||||
|
||||
@@ -65,11 +102,70 @@ SessionMakerDep = Annotated[async_sessionmaker, Depends(get_session_maker)]
|
||||
## repositories
|
||||
|
||||
|
||||
async def get_project_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
) -> ProjectRepository:
|
||||
"""Get the project repository."""
|
||||
return ProjectRepository(session_maker)
|
||||
|
||||
|
||||
ProjectRepositoryDep = Annotated[ProjectRepository, Depends(get_project_repository)]
|
||||
ProjectPathDep = Annotated[str, Path()] # Use Path dependency to extract from URL
|
||||
|
||||
|
||||
async def get_project_id(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project: ProjectPathDep,
|
||||
) -> int:
|
||||
"""Get the current project ID from request state.
|
||||
|
||||
When using sub-applications with /{project} mounting, the project value
|
||||
is stored in request.state by middleware.
|
||||
|
||||
Args:
|
||||
request: The current request object
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The resolved project ID
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is not found
|
||||
"""
|
||||
|
||||
# Try by permalink first (most common case with URL paths)
|
||||
project_obj = await project_repository.get_by_permalink(str(project))
|
||||
if project_obj:
|
||||
return project_obj.id
|
||||
|
||||
# Try by name if permalink lookup fails
|
||||
project_obj = await project_repository.get_by_name(str(project)) # pragma: no cover
|
||||
if project_obj: # pragma: no cover
|
||||
return project_obj.id
|
||||
|
||||
# Not found
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
The project_id dependency is used in the following:
|
||||
- EntityRepository
|
||||
- ObservationRepository
|
||||
- RelationRepository
|
||||
- SearchRepository
|
||||
- ProjectInfoRepository
|
||||
"""
|
||||
ProjectIdDep = Annotated[int, Depends(get_project_id)]
|
||||
|
||||
|
||||
async def get_entity_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
) -> EntityRepository:
|
||||
"""Create an EntityRepository instance."""
|
||||
return EntityRepository(session_maker)
|
||||
"""Create an EntityRepository instance for the current project."""
|
||||
return EntityRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
|
||||
@@ -77,9 +173,10 @@ EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)
|
||||
|
||||
async def get_observation_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
) -> ObservationRepository:
|
||||
"""Create an ObservationRepository instance."""
|
||||
return ObservationRepository(session_maker)
|
||||
"""Create an ObservationRepository instance for the current project."""
|
||||
return ObservationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
|
||||
@@ -87,9 +184,10 @@ ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observat
|
||||
|
||||
async def get_relation_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
) -> RelationRepository:
|
||||
"""Create a RelationRepository instance."""
|
||||
return RelationRepository(session_maker)
|
||||
"""Create a RelationRepository instance for the current project."""
|
||||
return RelationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
|
||||
@@ -97,22 +195,17 @@ RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repos
|
||||
|
||||
async def get_search_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a SearchRepository instance."""
|
||||
return SearchRepository(session_maker)
|
||||
"""Create a SearchRepository instance for the current project."""
|
||||
return SearchRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
|
||||
|
||||
|
||||
def get_project_info_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
):
|
||||
"""Dependency for StatsRepository."""
|
||||
return ProjectInfoRepository(session_maker)
|
||||
|
||||
|
||||
ProjectInfoRepositoryDep = Annotated[ProjectInfoRepository, Depends(get_project_info_repository)]
|
||||
# ProjectInfoRepository is deprecated and will be removed in a future version.
|
||||
# Use ProjectRepository instead, which has the same functionality plus more project-specific operations.
|
||||
|
||||
## services
|
||||
|
||||
@@ -134,7 +227,12 @@ MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_process
|
||||
async def get_file_service(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
) -> FileService:
|
||||
return FileService(project_config.home, markdown_processor)
|
||||
logger.debug(
|
||||
f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}"
|
||||
)
|
||||
file_service = FileService(project_config.home, markdown_processor)
|
||||
logger.debug(f"Created FileService for project: {file_service} ")
|
||||
return file_service
|
||||
|
||||
|
||||
FileServiceDep = Annotated[FileService, Depends(get_file_service)]
|
||||
@@ -184,9 +282,108 @@ LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)]
|
||||
|
||||
|
||||
async def get_context_service(
|
||||
search_repository: SearchRepositoryDep, entity_repository: EntityRepositoryDep
|
||||
search_repository: SearchRepositoryDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
observation_repository: ObservationRepositoryDep,
|
||||
) -> ContextService:
|
||||
return ContextService(search_repository, entity_repository)
|
||||
return ContextService(
|
||||
search_repository=search_repository,
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
)
|
||||
|
||||
|
||||
ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
|
||||
|
||||
|
||||
async def get_sync_service(
|
||||
entity_service: EntityServiceDep,
|
||||
entity_parser: EntityParserDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
relation_repository: RelationRepositoryDep,
|
||||
search_service: SearchServiceDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> SyncService: # pragma: no cover
|
||||
"""
|
||||
|
||||
:rtype: object
|
||||
"""
|
||||
return SyncService(
|
||||
app_config=app_config,
|
||||
entity_service=entity_service,
|
||||
entity_parser=entity_parser,
|
||||
entity_repository=entity_repository,
|
||||
relation_repository=relation_repository,
|
||||
search_service=search_service,
|
||||
file_service=file_service,
|
||||
)
|
||||
|
||||
|
||||
SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)]
|
||||
|
||||
|
||||
async def get_project_service(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectService:
|
||||
"""Create ProjectService with repository."""
|
||||
return ProjectService(repository=project_repository)
|
||||
|
||||
|
||||
ProjectServiceDep = Annotated[ProjectService, Depends(get_project_service)]
|
||||
|
||||
|
||||
async def get_directory_service(
|
||||
entity_repository: EntityRepositoryDep,
|
||||
) -> DirectoryService:
|
||||
"""Create DirectoryService with dependencies."""
|
||||
return DirectoryService(
|
||||
entity_repository=entity_repository,
|
||||
)
|
||||
|
||||
|
||||
DirectoryServiceDep = Annotated[DirectoryService, Depends(get_directory_service)]
|
||||
|
||||
|
||||
# Import
|
||||
|
||||
|
||||
async def get_chatgpt_importer(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
|
||||
|
||||
|
||||
async def get_claude_conversations_importer(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
ClaudeConversationsImporterDep = Annotated[
|
||||
ClaudeConversationsImporter, Depends(get_claude_conversations_importer)
|
||||
]
|
||||
|
||||
|
||||
async def get_claude_projects_importer(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
|
||||
|
||||
|
||||
async def get_memory_json_importer(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Import services for Basic Memory."""
|
||||
|
||||
from basic_memory.importers.base import Importer
|
||||
from basic_memory.importers.chatgpt_importer import ChatGPTImporter
|
||||
from basic_memory.importers.claude_conversations_importer import (
|
||||
ClaudeConversationsImporter,
|
||||
)
|
||||
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
|
||||
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
|
||||
from basic_memory.schemas.importer import (
|
||||
ChatImportResult,
|
||||
EntityImportResult,
|
||||
ImportResult,
|
||||
ProjectImportResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Importer",
|
||||
"ChatGPTImporter",
|
||||
"ClaudeConversationsImporter",
|
||||
"ClaudeProjectsImporter",
|
||||
"MemoryJsonImporter",
|
||||
"ImportResult",
|
||||
"ChatImportResult",
|
||||
"EntityImportResult",
|
||||
"ProjectImportResult",
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Base import service for Basic Memory."""
|
||||
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, TypeVar
|
||||
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown
|
||||
from basic_memory.schemas.importer import ImportResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T", bound=ImportResult)
|
||||
|
||||
|
||||
class Importer[T: ImportResult]:
|
||||
"""Base class for all import services."""
|
||||
|
||||
def __init__(self, base_path: Path, markdown_processor: MarkdownProcessor):
|
||||
"""Initialize the import service.
|
||||
|
||||
Args:
|
||||
markdown_processor: MarkdownProcessor instance for writing markdown files.
|
||||
"""
|
||||
self.base_path = base_path.resolve() # Get absolute path
|
||||
self.markdown_processor = markdown_processor
|
||||
|
||||
@abstractmethod
|
||||
async def import_data(self, source_data, destination_folder: str, **kwargs: Any) -> T:
|
||||
"""Import data from source file to destination folder.
|
||||
|
||||
Args:
|
||||
source_path: Path to the source file.
|
||||
destination_folder: Destination folder within the project.
|
||||
**kwargs: Additional keyword arguments for specific import types.
|
||||
|
||||
Returns:
|
||||
ImportResult containing statistics and status of the import.
|
||||
"""
|
||||
pass # pragma: no cover
|
||||
|
||||
async def write_entity(self, entity: EntityMarkdown, file_path: Path) -> None:
|
||||
"""Write entity to file using markdown processor.
|
||||
|
||||
Args:
|
||||
entity: EntityMarkdown instance to write.
|
||||
file_path: Path to write the entity to.
|
||||
"""
|
||||
await self.markdown_processor.write_file(file_path, entity)
|
||||
|
||||
def ensure_folder_exists(self, folder: str) -> Path:
|
||||
"""Ensure folder exists, create if it doesn't.
|
||||
|
||||
Args:
|
||||
base_path: Base path of the project.
|
||||
folder: Folder name or path within the project.
|
||||
|
||||
Returns:
|
||||
Path to the folder.
|
||||
"""
|
||||
folder_path = self.base_path / folder
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
return folder_path
|
||||
|
||||
@abstractmethod
|
||||
def handle_error(
|
||||
self, message: str, error: Optional[Exception] = None
|
||||
) -> T: # pragma: no cover
|
||||
"""Handle errors during import.
|
||||
|
||||
Args:
|
||||
message: Error message.
|
||||
error: Optional exception that caused the error.
|
||||
|
||||
Returns:
|
||||
ImportResult with error information.
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,222 @@
|
||||
"""ChatGPT import service for Basic Memory."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
|
||||
from basic_memory.importers.base import Importer
|
||||
from basic_memory.schemas.importer import ChatImportResult
|
||||
from basic_memory.importers.utils import clean_filename, format_timestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
"""Service for importing ChatGPT conversations."""
|
||||
|
||||
async def import_data(
|
||||
self, source_data, destination_folder: str, **kwargs: Any
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from ChatGPT JSON export.
|
||||
|
||||
Args:
|
||||
source_path: Path to the ChatGPT conversations.json file.
|
||||
destination_folder: Destination folder within the project.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
ChatImportResult containing statistics and status of the import.
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
# Ensure the destination folder exists
|
||||
self.ensure_folder_exists(destination_folder)
|
||||
conversations = source_data
|
||||
|
||||
# Process each conversation
|
||||
messages_imported = 0
|
||||
chats_imported = 0
|
||||
|
||||
for chat in conversations:
|
||||
# Convert to entity
|
||||
entity = self._format_chat_content(destination_folder, chat)
|
||||
|
||||
# Write file
|
||||
file_path = self.base_path / f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
|
||||
# Count messages
|
||||
msg_count = sum(
|
||||
1
|
||||
for node in chat["mapping"].values()
|
||||
if node.get("message")
|
||||
and not node.get("message", {})
|
||||
.get("metadata", {})
|
||||
.get("is_visually_hidden_from_conversation")
|
||||
)
|
||||
|
||||
chats_imported += 1
|
||||
messages_imported += msg_count
|
||||
|
||||
return ChatImportResult(
|
||||
import_count={"conversations": chats_imported, "messages": messages_imported},
|
||||
success=True,
|
||||
conversations=chats_imported,
|
||||
messages=messages_imported,
|
||||
)
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception("Failed to import ChatGPT conversations")
|
||||
return self.handle_error("Failed to import ChatGPT conversations", e) # pyright: ignore [reportReturnType]
|
||||
|
||||
def _format_chat_content(
|
||||
self, folder: str, conversation: Dict[str, Any]
|
||||
) -> EntityMarkdown: # pragma: no cover
|
||||
"""Convert chat conversation to Basic Memory entity.
|
||||
|
||||
Args:
|
||||
folder: Destination folder name.
|
||||
conversation: ChatGPT conversation data.
|
||||
|
||||
Returns:
|
||||
EntityMarkdown instance representing the conversation.
|
||||
"""
|
||||
# Extract timestamps
|
||||
created_at = conversation["create_time"]
|
||||
modified_at = conversation["update_time"]
|
||||
|
||||
root_id = None
|
||||
# Find root message
|
||||
for node_id, node in conversation["mapping"].items():
|
||||
if node.get("parent") is None:
|
||||
root_id = node_id
|
||||
break
|
||||
|
||||
# Generate permalink
|
||||
date_prefix = datetime.fromtimestamp(created_at).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(conversation["title"])
|
||||
|
||||
# Format content
|
||||
content = self._format_chat_markdown(
|
||||
title=conversation["title"],
|
||||
mapping=conversation["mapping"],
|
||||
root_id=root_id,
|
||||
created_at=created_at,
|
||||
modified_at=modified_at,
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "conversation",
|
||||
"title": conversation["title"],
|
||||
"created": format_timestamp(created_at),
|
||||
"modified": format_timestamp(modified_at),
|
||||
"permalink": f"{folder}/{date_prefix}-{clean_title}",
|
||||
}
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
def _format_chat_markdown(
|
||||
self,
|
||||
title: str,
|
||||
mapping: Dict[str, Any],
|
||||
root_id: Optional[str],
|
||||
created_at: float,
|
||||
modified_at: float,
|
||||
) -> str: # pragma: no cover
|
||||
"""Format chat as clean markdown.
|
||||
|
||||
Args:
|
||||
title: Chat title.
|
||||
mapping: Message mapping.
|
||||
root_id: Root message ID.
|
||||
created_at: Creation timestamp.
|
||||
modified_at: Modification timestamp.
|
||||
|
||||
Returns:
|
||||
Formatted markdown content.
|
||||
"""
|
||||
# Start with title
|
||||
lines = [f"# {title}\n"]
|
||||
|
||||
# Traverse message tree
|
||||
seen_msgs: Set[str] = set()
|
||||
messages = self._traverse_messages(mapping, root_id, seen_msgs)
|
||||
|
||||
# Format each message
|
||||
for msg in messages:
|
||||
# Skip hidden messages
|
||||
if msg.get("metadata", {}).get("is_visually_hidden_from_conversation"):
|
||||
continue
|
||||
|
||||
# Get author and timestamp
|
||||
author = msg["author"]["role"].title()
|
||||
ts = format_timestamp(msg["create_time"]) if msg.get("create_time") else ""
|
||||
|
||||
# Add message header
|
||||
lines.append(f"### {author} ({ts})")
|
||||
|
||||
# Add message content
|
||||
content = self._get_message_content(msg)
|
||||
if content:
|
||||
lines.append(content)
|
||||
|
||||
# Add spacing
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_message_content(self, message: Dict[str, Any]) -> str: # pragma: no cover
|
||||
"""Extract clean message content.
|
||||
|
||||
Args:
|
||||
message: Message data.
|
||||
|
||||
Returns:
|
||||
Cleaned message content.
|
||||
"""
|
||||
if not message or "content" not in message:
|
||||
return ""
|
||||
|
||||
content = message["content"]
|
||||
if content.get("content_type") == "text":
|
||||
return "\n".join(content.get("parts", []))
|
||||
elif content.get("content_type") == "code":
|
||||
return f"```{content.get('language', '')}\n{content.get('text', '')}\n```"
|
||||
return ""
|
||||
|
||||
def _traverse_messages(
|
||||
self, mapping: Dict[str, Any], root_id: Optional[str], seen: Set[str]
|
||||
) -> List[Dict[str, Any]]: # pragma: no cover
|
||||
"""Traverse message tree and return messages in order.
|
||||
|
||||
Args:
|
||||
mapping: Message mapping.
|
||||
root_id: Root message ID.
|
||||
seen: Set of seen message IDs.
|
||||
|
||||
Returns:
|
||||
List of message data.
|
||||
"""
|
||||
messages = []
|
||||
node = mapping.get(root_id) if root_id else None
|
||||
|
||||
while node:
|
||||
if node["id"] not in seen and node.get("message"):
|
||||
seen.add(node["id"])
|
||||
messages.append(node["message"])
|
||||
|
||||
# Follow children
|
||||
children = node.get("children", [])
|
||||
for child_id in children:
|
||||
child_msgs = self._traverse_messages(mapping, child_id, seen)
|
||||
messages.extend(child_msgs)
|
||||
|
||||
break # Don't follow siblings
|
||||
|
||||
return messages
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Claude conversations import service for Basic Memory."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
|
||||
from basic_memory.importers.base import Importer
|
||||
from basic_memory.schemas.importer import ChatImportResult
|
||||
from basic_memory.importers.utils import clean_filename, format_timestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
"""Service for importing Claude conversations."""
|
||||
|
||||
async def import_data(
|
||||
self, source_data, destination_folder: str, **kwargs: Any
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from Claude JSON export.
|
||||
|
||||
Args:
|
||||
source_data: Path to the Claude conversations.json file.
|
||||
destination_folder: Destination folder within the project.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
ChatImportResult containing statistics and status of the import.
|
||||
"""
|
||||
try:
|
||||
# Ensure the destination folder exists
|
||||
folder_path = self.ensure_folder_exists(destination_folder)
|
||||
|
||||
conversations = source_data
|
||||
|
||||
# Process each conversation
|
||||
messages_imported = 0
|
||||
chats_imported = 0
|
||||
|
||||
for chat in conversations:
|
||||
# Convert to entity
|
||||
entity = self._format_chat_content(
|
||||
base_path=folder_path,
|
||||
name=chat["name"],
|
||||
messages=chat["chat_messages"],
|
||||
created_at=chat["created_at"],
|
||||
modified_at=chat["updated_at"],
|
||||
)
|
||||
|
||||
# Write file
|
||||
file_path = self.base_path / Path(f"{entity.frontmatter.metadata['permalink']}.md")
|
||||
await self.write_entity(entity, file_path)
|
||||
|
||||
chats_imported += 1
|
||||
messages_imported += len(chat["chat_messages"])
|
||||
|
||||
return ChatImportResult(
|
||||
import_count={"conversations": chats_imported, "messages": messages_imported},
|
||||
success=True,
|
||||
conversations=chats_imported,
|
||||
messages=messages_imported,
|
||||
)
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception("Failed to import Claude conversations")
|
||||
return self.handle_error("Failed to import Claude conversations", e) # pyright: ignore [reportReturnType]
|
||||
|
||||
def _format_chat_content(
|
||||
self,
|
||||
base_path: Path,
|
||||
name: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
created_at: str,
|
||||
modified_at: str,
|
||||
) -> EntityMarkdown:
|
||||
"""Convert chat messages to Basic Memory entity format.
|
||||
|
||||
Args:
|
||||
base_path: Base path for the entity.
|
||||
name: Chat name.
|
||||
messages: List of chat messages.
|
||||
created_at: Creation timestamp.
|
||||
modified_at: Modification timestamp.
|
||||
|
||||
Returns:
|
||||
EntityMarkdown instance representing the conversation.
|
||||
"""
|
||||
# Generate permalink
|
||||
date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(name)
|
||||
permalink = f"{base_path.name}/{date_prefix}-{clean_title}"
|
||||
|
||||
# Format content
|
||||
content = self._format_chat_markdown(
|
||||
name=name,
|
||||
messages=messages,
|
||||
created_at=created_at,
|
||||
modified_at=modified_at,
|
||||
permalink=permalink,
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "conversation",
|
||||
"title": name,
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": permalink,
|
||||
}
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
def _format_chat_markdown(
|
||||
self,
|
||||
name: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
created_at: str,
|
||||
modified_at: str,
|
||||
permalink: str,
|
||||
) -> str:
|
||||
"""Format chat as clean markdown.
|
||||
|
||||
Args:
|
||||
name: Chat name.
|
||||
messages: List of chat messages.
|
||||
created_at: Creation timestamp.
|
||||
modified_at: Modification timestamp.
|
||||
permalink: Permalink for the entity.
|
||||
|
||||
Returns:
|
||||
Formatted markdown content.
|
||||
"""
|
||||
# Start with frontmatter and title
|
||||
lines = [
|
||||
f"# {name}\n",
|
||||
]
|
||||
|
||||
# Add messages
|
||||
for msg in messages:
|
||||
# Format timestamp
|
||||
ts = format_timestamp(msg["created_at"])
|
||||
|
||||
# Add message header
|
||||
lines.append(f"### {msg['sender'].title()} ({ts})")
|
||||
|
||||
# Handle message content
|
||||
content = msg.get("text", "")
|
||||
if msg.get("content"):
|
||||
content = " ".join(c.get("text", "") for c in msg["content"])
|
||||
lines.append(content)
|
||||
|
||||
# Handle attachments
|
||||
attachments = msg.get("attachments", [])
|
||||
for attachment in attachments:
|
||||
if "file_name" in attachment:
|
||||
lines.append(f"\n**Attachment: {attachment['file_name']}**")
|
||||
if "extracted_content" in attachment:
|
||||
lines.append("```")
|
||||
lines.append(attachment["extracted_content"])
|
||||
lines.append("```")
|
||||
|
||||
# Add spacing between messages
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Claude projects import service for Basic Memory."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
|
||||
from basic_memory.importers.base import Importer
|
||||
from basic_memory.schemas.importer import ProjectImportResult
|
||||
from basic_memory.importers.utils import clean_filename
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
"""Service for importing Claude projects."""
|
||||
|
||||
async def import_data(
|
||||
self, source_data, destination_folder: str, **kwargs: Any
|
||||
) -> ProjectImportResult:
|
||||
"""Import projects from Claude JSON export.
|
||||
|
||||
Args:
|
||||
source_path: Path to the Claude projects.json file.
|
||||
destination_folder: Base folder for projects within the project.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
ProjectImportResult containing statistics and status of the import.
|
||||
"""
|
||||
try:
|
||||
# Ensure the base folder exists
|
||||
base_path = self.base_path
|
||||
if destination_folder:
|
||||
base_path = self.ensure_folder_exists(destination_folder)
|
||||
|
||||
projects = source_data
|
||||
|
||||
# Process each project
|
||||
docs_imported = 0
|
||||
prompts_imported = 0
|
||||
|
||||
for project in projects:
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Create project directories
|
||||
docs_dir = base_path / project_dir / "docs"
|
||||
docs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Import prompt template if it exists
|
||||
if prompt_entity := self._format_prompt_markdown(project):
|
||||
file_path = base_path / f"{prompt_entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(prompt_entity, file_path)
|
||||
prompts_imported += 1
|
||||
|
||||
# Import project documents
|
||||
for doc in project.get("docs", []):
|
||||
entity = self._format_project_markdown(project, doc)
|
||||
file_path = base_path / f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
docs_imported += 1
|
||||
|
||||
return ProjectImportResult(
|
||||
import_count={"documents": docs_imported, "prompts": prompts_imported},
|
||||
success=True,
|
||||
documents=docs_imported,
|
||||
prompts=prompts_imported,
|
||||
)
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception("Failed to import Claude projects")
|
||||
return self.handle_error("Failed to import Claude projects", e) # pyright: ignore [reportReturnType]
|
||||
|
||||
def _format_project_markdown(
|
||||
self, project: Dict[str, Any], doc: Dict[str, Any]
|
||||
) -> EntityMarkdown:
|
||||
"""Format a project document as a Basic Memory entity.
|
||||
|
||||
Args:
|
||||
project: Project data.
|
||||
doc: Document data.
|
||||
|
||||
Returns:
|
||||
EntityMarkdown instance representing the document.
|
||||
"""
|
||||
# Extract timestamps
|
||||
created_at = doc.get("created_at") or project["created_at"]
|
||||
modified_at = project["updated_at"]
|
||||
|
||||
# Generate clean names for organization
|
||||
project_dir = clean_filename(project["name"])
|
||||
doc_file = clean_filename(doc["filename"])
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "project_doc",
|
||||
"title": doc["filename"],
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": f"{project_dir}/docs/{doc_file}",
|
||||
"project_name": project["name"],
|
||||
"project_uuid": project["uuid"],
|
||||
"doc_uuid": doc["uuid"],
|
||||
}
|
||||
),
|
||||
content=doc["content"],
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
def _format_prompt_markdown(self, project: Dict[str, Any]) -> Optional[EntityMarkdown]:
|
||||
"""Format project prompt template as a Basic Memory entity.
|
||||
|
||||
Args:
|
||||
project: Project data.
|
||||
|
||||
Returns:
|
||||
EntityMarkdown instance representing the prompt template, or None if
|
||||
no prompt template exists.
|
||||
"""
|
||||
if not project.get("prompt_template"):
|
||||
return None
|
||||
|
||||
# Extract timestamps
|
||||
created_at = project["created_at"]
|
||||
modified_at = project["updated_at"]
|
||||
|
||||
# Generate clean project directory name
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "prompt_template",
|
||||
"title": f"Prompt Template: {project['name']}",
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": f"{project_dir}/prompt-template",
|
||||
"project_name": project["name"],
|
||||
"project_uuid": project["uuid"],
|
||||
}
|
||||
),
|
||||
content=f"# Prompt Template: {project['name']}\n\n{project['prompt_template']}",
|
||||
)
|
||||
|
||||
return entity
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Memory JSON import service for Basic Memory."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from basic_memory.config import config
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown, Observation, Relation
|
||||
from basic_memory.importers.base import Importer
|
||||
from basic_memory.schemas.importer import EntityImportResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
"""Service for importing memory.json format data."""
|
||||
|
||||
async def import_data(
|
||||
self, source_data, destination_folder: str = "", **kwargs: Any
|
||||
) -> EntityImportResult:
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
Args:
|
||||
source_data: Path to the memory.json file.
|
||||
destination_folder: Optional destination folder within the project.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
EntityImportResult containing statistics and status of the import.
|
||||
"""
|
||||
try:
|
||||
# First pass - collect all relations by source entity
|
||||
entity_relations: Dict[str, List[Relation]] = {}
|
||||
entities: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Ensure the base path exists
|
||||
base_path = config.home # pragma: no cover
|
||||
if destination_folder: # pragma: no cover
|
||||
base_path = self.ensure_folder_exists(destination_folder)
|
||||
|
||||
# First pass - collect entities and relations
|
||||
for line in source_data:
|
||||
data = line
|
||||
if data["type"] == "entity":
|
||||
entities[data["name"]] = data
|
||||
elif data["type"] == "relation":
|
||||
# Store relation with its source entity
|
||||
source = data.get("from") or data.get("from_id")
|
||||
if source not in entity_relations:
|
||||
entity_relations[source] = []
|
||||
entity_relations[source].append(
|
||||
Relation(
|
||||
type=data.get("relationType") or data.get("relation_type"),
|
||||
target=data.get("to") or data.get("to_id"),
|
||||
)
|
||||
)
|
||||
|
||||
# Second pass - create and write entities
|
||||
entities_created = 0
|
||||
for name, entity_data in entities.items():
|
||||
# Ensure entity type directory exists
|
||||
entity_type_dir = base_path / entity_data["entityType"]
|
||||
entity_type_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": entity_data["entityType"],
|
||||
"title": name,
|
||||
"permalink": f"{entity_data['entityType']}/{name}",
|
||||
}
|
||||
),
|
||||
content=f"# {name}\n",
|
||||
observations=[Observation(content=obs) for obs in entity_data["observations"]],
|
||||
relations=entity_relations.get(name, []),
|
||||
)
|
||||
|
||||
# Write entity file
|
||||
file_path = base_path / f"{entity_data['entityType']}/{name}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
entities_created += 1
|
||||
|
||||
relations_count = sum(len(rels) for rels in entity_relations.values())
|
||||
|
||||
return EntityImportResult(
|
||||
import_count={"entities": entities_created, "relations": relations_count},
|
||||
success=True,
|
||||
entities=entities_created,
|
||||
relations=relations_count,
|
||||
)
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception("Failed to import memory.json")
|
||||
return self.handle_error("Failed to import memory.json", e) # pyright: ignore [reportReturnType]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Utility functions for import services."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def clean_filename(name: str) -> str: # pragma: no cover
|
||||
"""Clean a string to be used as a filename.
|
||||
|
||||
Args:
|
||||
name: The string to clean.
|
||||
|
||||
Returns:
|
||||
A cleaned string suitable for use as a filename.
|
||||
"""
|
||||
# Replace common punctuation and whitespace with underscores
|
||||
name = re.sub(r"[\s\-,.:/\\\[\]\(\)]+", "_", name)
|
||||
# Remove any non-alphanumeric or underscore characters
|
||||
name = re.sub(r"[^\w]+", "", name)
|
||||
# Ensure the name isn't too long
|
||||
if len(name) > 100: # pragma: no cover
|
||||
name = name[:100]
|
||||
# Ensure the name isn't empty
|
||||
if not name: # pragma: no cover
|
||||
name = "untitled"
|
||||
return name
|
||||
|
||||
|
||||
def format_timestamp(timestamp: Any) -> str: # pragma: no cover
|
||||
"""Format a timestamp for use in a filename or title.
|
||||
|
||||
Args:
|
||||
timestamp: A timestamp in various formats.
|
||||
|
||||
Returns:
|
||||
A formatted string representation of the timestamp.
|
||||
"""
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
# Try ISO format
|
||||
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
try:
|
||||
# Try unix timestamp as string
|
||||
timestamp = datetime.fromtimestamp(float(timestamp))
|
||||
except ValueError:
|
||||
# Return as is if we can't parse it
|
||||
return timestamp
|
||||
elif isinstance(timestamp, (int, float)):
|
||||
# Unix timestamp
|
||||
timestamp = datetime.fromtimestamp(timestamp)
|
||||
|
||||
if isinstance(timestamp, datetime):
|
||||
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Return as is if we can't format it
|
||||
return str(timestamp) # pragma: no cover
|
||||
@@ -92,7 +92,6 @@ class EntityParser:
|
||||
async def parse_file(self, path: Path | str) -> EntityMarkdown:
|
||||
"""Parse markdown file into EntityMarkdown."""
|
||||
|
||||
# TODO move to api endpoint to check if absolute path was requested
|
||||
# Check if the path is already absolute
|
||||
if (
|
||||
isinstance(path, Path)
|
||||
@@ -101,12 +100,16 @@ class EntityParser:
|
||||
):
|
||||
absolute_path = Path(path)
|
||||
else:
|
||||
absolute_path = self.base_path / path
|
||||
absolute_path = self.get_file_path(path)
|
||||
|
||||
# Parse frontmatter and content using python-frontmatter
|
||||
file_content = absolute_path.read_text()
|
||||
file_content = absolute_path.read_text(encoding="utf-8")
|
||||
return await self.parse_file_content(absolute_path, file_content)
|
||||
|
||||
def get_file_path(self, path):
|
||||
"""Get absolute path for a file using the base path for the project."""
|
||||
return self.base_path / path
|
||||
|
||||
async def parse_file_content(self, absolute_path, file_content):
|
||||
post = frontmatter.loads(file_content)
|
||||
# Extract file stat info
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""OAuth authentication provider for Basic Memory MCP server."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional
|
||||
|
||||
import jwt
|
||||
from mcp.server.auth.provider import (
|
||||
OAuthAuthorizationServerProvider,
|
||||
AuthorizationParams,
|
||||
AuthorizationCode,
|
||||
RefreshToken,
|
||||
AccessToken,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class BasicMemoryAuthorizationCode(AuthorizationCode):
|
||||
"""Extended authorization code with additional metadata."""
|
||||
|
||||
issuer_state: Optional[str] = None
|
||||
|
||||
|
||||
class BasicMemoryRefreshToken(RefreshToken):
|
||||
"""Extended refresh token with additional metadata."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BasicMemoryAccessToken(AccessToken):
|
||||
"""Extended access token with additional metadata."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BasicMemoryOAuthProvider(
|
||||
OAuthAuthorizationServerProvider[
|
||||
BasicMemoryAuthorizationCode, BasicMemoryRefreshToken, BasicMemoryAccessToken
|
||||
]
|
||||
):
|
||||
"""OAuth provider for Basic Memory MCP server.
|
||||
|
||||
This is a simple in-memory implementation that can be extended
|
||||
to integrate with external OAuth providers or use persistent storage.
|
||||
"""
|
||||
|
||||
def __init__(self, issuer_url: str = "http://localhost:8000", secret_key: Optional[str] = None):
|
||||
self.issuer_url = issuer_url
|
||||
# Use environment variable for secret key if available, otherwise generate
|
||||
import os
|
||||
|
||||
self.secret_key = (
|
||||
secret_key or os.getenv("FASTMCP_AUTH_SECRET_KEY") or secrets.token_urlsafe(32)
|
||||
)
|
||||
|
||||
# In-memory storage - in production, use a proper database
|
||||
self.clients: Dict[str, OAuthClientInformationFull] = {}
|
||||
self.authorization_codes: Dict[str, BasicMemoryAuthorizationCode] = {}
|
||||
self.refresh_tokens: Dict[str, BasicMemoryRefreshToken] = {}
|
||||
self.access_tokens: Dict[str, BasicMemoryAccessToken] = {}
|
||||
|
||||
async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
|
||||
"""Get a client by ID."""
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Register a new OAuth client."""
|
||||
# Generate client ID if not provided
|
||||
if not client_info.client_id:
|
||||
client_info.client_id = secrets.token_urlsafe(16)
|
||||
|
||||
# Generate client secret if not provided
|
||||
if not client_info.client_secret:
|
||||
client_info.client_secret = secrets.token_urlsafe(32)
|
||||
|
||||
self.clients[client_info.client_id] = client_info
|
||||
logger.info(f"Registered OAuth client: {client_info.client_id}")
|
||||
|
||||
async def authorize(
|
||||
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
||||
) -> str:
|
||||
"""Create an authorization URL for the OAuth flow.
|
||||
|
||||
For basic-memory, we'll implement a simple authorization flow.
|
||||
In production, this might redirect to an external provider.
|
||||
"""
|
||||
# Generate authorization code
|
||||
auth_code = secrets.token_urlsafe(32)
|
||||
|
||||
# Store authorization code with metadata
|
||||
self.authorization_codes[auth_code] = BasicMemoryAuthorizationCode(
|
||||
code=auth_code,
|
||||
scopes=params.scopes or [],
|
||||
expires_at=(datetime.utcnow() + timedelta(minutes=10)).timestamp(),
|
||||
client_id=client.client_id,
|
||||
code_challenge=params.code_challenge,
|
||||
redirect_uri=params.redirect_uri,
|
||||
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
||||
issuer_state=params.state,
|
||||
)
|
||||
|
||||
# In a real implementation, we'd redirect to an authorization page
|
||||
# For now, we'll just return the redirect URL with the code
|
||||
redirect_uri = str(params.redirect_uri)
|
||||
separator = "&" if "?" in redirect_uri else "?"
|
||||
|
||||
auth_url = f"{redirect_uri}{separator}code={auth_code}"
|
||||
if params.state:
|
||||
auth_url += f"&state={params.state}"
|
||||
|
||||
return auth_url
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> Optional[BasicMemoryAuthorizationCode]:
|
||||
"""Load an authorization code."""
|
||||
code = self.authorization_codes.get(authorization_code)
|
||||
|
||||
if code and code.client_id == client.client_id:
|
||||
# Check if expired
|
||||
if datetime.utcnow().timestamp() > code.expires_at:
|
||||
del self.authorization_codes[authorization_code]
|
||||
return None
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: BasicMemoryAuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Exchange an authorization code for tokens."""
|
||||
# Generate tokens
|
||||
access_token = self._generate_access_token(client.client_id, authorization_code.scopes)
|
||||
refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Store tokens
|
||||
expires_at = (datetime.utcnow() + timedelta(hours=1)).timestamp()
|
||||
|
||||
self.access_tokens[access_token] = BasicMemoryAccessToken(
|
||||
token=access_token,
|
||||
client_id=client.client_id,
|
||||
scopes=authorization_code.scopes,
|
||||
expires_at=int(expires_at),
|
||||
)
|
||||
|
||||
self.refresh_tokens[refresh_token] = BasicMemoryRefreshToken(
|
||||
token=refresh_token,
|
||||
client_id=client.client_id,
|
||||
scopes=authorization_code.scopes,
|
||||
)
|
||||
|
||||
# Remove used authorization code
|
||||
del self.authorization_codes[authorization_code.code]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=3600, # 1 hour
|
||||
refresh_token=refresh_token,
|
||||
scope=" ".join(authorization_code.scopes) if authorization_code.scopes else None,
|
||||
)
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> Optional[BasicMemoryRefreshToken]:
|
||||
"""Load a refresh token."""
|
||||
token = self.refresh_tokens.get(refresh_token)
|
||||
|
||||
if token and token.client_id == client.client_id:
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: BasicMemoryRefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Exchange a refresh token for new tokens."""
|
||||
# Use requested scopes or original scopes
|
||||
token_scopes = scopes if scopes else refresh_token.scopes
|
||||
|
||||
# Generate new tokens
|
||||
new_access_token = self._generate_access_token(client.client_id, token_scopes)
|
||||
new_refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Store new tokens
|
||||
expires_at = (datetime.utcnow() + timedelta(hours=1)).timestamp()
|
||||
|
||||
self.access_tokens[new_access_token] = BasicMemoryAccessToken(
|
||||
token=new_access_token,
|
||||
client_id=client.client_id,
|
||||
scopes=token_scopes,
|
||||
expires_at=int(expires_at),
|
||||
)
|
||||
|
||||
self.refresh_tokens[new_refresh_token] = BasicMemoryRefreshToken(
|
||||
token=new_refresh_token,
|
||||
client_id=client.client_id,
|
||||
scopes=token_scopes,
|
||||
)
|
||||
|
||||
# Remove old tokens
|
||||
del self.refresh_tokens[refresh_token.token]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=new_access_token,
|
||||
token_type="bearer",
|
||||
expires_in=3600, # 1 hour
|
||||
refresh_token=new_refresh_token,
|
||||
scope=" ".join(token_scopes) if token_scopes else None,
|
||||
)
|
||||
|
||||
async def load_access_token(self, token: str) -> Optional[BasicMemoryAccessToken]:
|
||||
"""Load and validate an access token."""
|
||||
logger.debug("Loading access token, checking in-memory store first")
|
||||
access_token = self.access_tokens.get(token)
|
||||
|
||||
if access_token:
|
||||
# Check if expired
|
||||
if access_token.expires_at and datetime.utcnow().timestamp() > access_token.expires_at:
|
||||
logger.debug("Token found in memory but expired, removing")
|
||||
del self.access_tokens[token]
|
||||
return None
|
||||
logger.debug("Token found in memory and valid")
|
||||
return access_token
|
||||
|
||||
# Try to decode as JWT
|
||||
logger.debug("Token not in memory, attempting JWT decode with secret key")
|
||||
try:
|
||||
# Decode with audience verification - PyJWT expects the audience to match
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self.secret_key,
|
||||
algorithms=["HS256"],
|
||||
audience="basic-memory", # Expecting this audience
|
||||
issuer=self.issuer_url, # And this issuer
|
||||
)
|
||||
logger.debug(f"JWT decoded successfully: {payload}")
|
||||
return BasicMemoryAccessToken(
|
||||
token=token,
|
||||
client_id=payload.get("sub", ""),
|
||||
scopes=payload.get("scopes", []),
|
||||
expires_at=payload.get("exp"),
|
||||
)
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.error(f"JWT decode failed: {e}")
|
||||
return None
|
||||
|
||||
async def revoke_token(self, token: BasicMemoryAccessToken | BasicMemoryRefreshToken) -> None:
|
||||
"""Revoke an access or refresh token."""
|
||||
if isinstance(token, BasicMemoryAccessToken):
|
||||
self.access_tokens.pop(token.token, None)
|
||||
else:
|
||||
self.refresh_tokens.pop(token.token, None)
|
||||
|
||||
def _generate_access_token(self, client_id: str, scopes: list[str]) -> str:
|
||||
"""Generate a JWT access token."""
|
||||
payload = {
|
||||
"iss": self.issuer_url,
|
||||
"sub": client_id,
|
||||
"aud": "basic-memory",
|
||||
"exp": datetime.utcnow() + timedelta(hours=1),
|
||||
"iat": datetime.utcnow(),
|
||||
"scopes": scopes,
|
||||
}
|
||||
|
||||
return jwt.encode(payload, self.secret_key, algorithm="HS256")
|
||||
@@ -0,0 +1,321 @@
|
||||
"""External OAuth provider integration for Basic Memory MCP server."""
|
||||
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from mcp.server.auth.provider import (
|
||||
OAuthAuthorizationServerProvider,
|
||||
AuthorizationParams,
|
||||
AuthorizationCode,
|
||||
RefreshToken,
|
||||
AccessToken,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalAuthorizationCode(AuthorizationCode):
|
||||
"""Authorization code with external provider metadata."""
|
||||
|
||||
external_code: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalRefreshToken(RefreshToken):
|
||||
"""Refresh token with external provider metadata."""
|
||||
|
||||
external_token: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalAccessToken(AccessToken):
|
||||
"""Access token with external provider metadata."""
|
||||
|
||||
external_token: Optional[str] = None
|
||||
|
||||
|
||||
class ExternalOAuthProvider(
|
||||
OAuthAuthorizationServerProvider[
|
||||
ExternalAuthorizationCode, ExternalRefreshToken, ExternalAccessToken
|
||||
]
|
||||
):
|
||||
"""OAuth provider that delegates to external OAuth providers.
|
||||
|
||||
This provider can integrate with services like:
|
||||
- GitHub OAuth
|
||||
- Google OAuth
|
||||
- Auth0
|
||||
- Okta
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
issuer_url: str,
|
||||
external_provider: str,
|
||||
external_client_id: str,
|
||||
external_client_secret: str,
|
||||
external_authorize_url: str,
|
||||
external_token_url: str,
|
||||
external_userinfo_url: Optional[str] = None,
|
||||
):
|
||||
self.issuer_url = issuer_url
|
||||
self.external_provider = external_provider
|
||||
self.external_client_id = external_client_id
|
||||
self.external_client_secret = external_client_secret
|
||||
self.external_authorize_url = external_authorize_url
|
||||
self.external_token_url = external_token_url
|
||||
self.external_userinfo_url = external_userinfo_url
|
||||
|
||||
# In-memory storage - in production, use a database
|
||||
self.clients: Dict[str, OAuthClientInformationFull] = {}
|
||||
self.codes: Dict[str, ExternalAuthorizationCode] = {}
|
||||
self.tokens: Dict[str, Any] = {}
|
||||
|
||||
self.http_client = httpx.AsyncClient()
|
||||
|
||||
async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
|
||||
"""Get a client by ID."""
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Register a new OAuth client."""
|
||||
self.clients[client_info.client_id] = client_info
|
||||
logger.info(f"Registered external OAuth client: {client_info.client_id}")
|
||||
|
||||
async def authorize(
|
||||
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
||||
) -> str:
|
||||
"""Create authorization URL redirecting to external provider."""
|
||||
# Store authorization request
|
||||
import secrets
|
||||
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
self.codes[state] = ExternalAuthorizationCode(
|
||||
code=state,
|
||||
scopes=params.scopes or [],
|
||||
expires_at=0, # Will be set by external provider
|
||||
client_id=client.client_id,
|
||||
code_challenge=params.code_challenge,
|
||||
redirect_uri=params.redirect_uri,
|
||||
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
||||
state=params.state,
|
||||
)
|
||||
|
||||
# Build external provider URL
|
||||
external_params = {
|
||||
"client_id": self.external_client_id,
|
||||
"redirect_uri": f"{self.issuer_url}/callback",
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
"scope": " ".join(params.scopes or []),
|
||||
}
|
||||
|
||||
return construct_redirect_uri(self.external_authorize_url, **external_params)
|
||||
|
||||
async def handle_callback(self, code: str, state: str) -> str:
|
||||
"""Handle callback from external provider."""
|
||||
# Get original authorization request
|
||||
auth_code = self.codes.get(state)
|
||||
if not auth_code:
|
||||
raise ValueError("Invalid state parameter")
|
||||
|
||||
# Exchange code with external provider
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": f"{self.issuer_url}/callback",
|
||||
"client_id": self.external_client_id,
|
||||
"client_secret": self.external_client_secret,
|
||||
}
|
||||
|
||||
response = await self.http_client.post(
|
||||
self.external_token_url,
|
||||
data=token_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
external_tokens = response.json()
|
||||
|
||||
# Store external tokens
|
||||
import secrets
|
||||
|
||||
internal_code = secrets.token_urlsafe(32)
|
||||
|
||||
self.codes[internal_code] = ExternalAuthorizationCode(
|
||||
code=internal_code,
|
||||
scopes=auth_code.scopes,
|
||||
expires_at=0,
|
||||
client_id=auth_code.client_id,
|
||||
code_challenge=auth_code.code_challenge,
|
||||
redirect_uri=auth_code.redirect_uri,
|
||||
redirect_uri_provided_explicitly=auth_code.redirect_uri_provided_explicitly,
|
||||
external_code=code,
|
||||
state=auth_code.state,
|
||||
)
|
||||
|
||||
self.tokens[internal_code] = external_tokens
|
||||
|
||||
# Redirect to original client
|
||||
return construct_redirect_uri(
|
||||
str(auth_code.redirect_uri),
|
||||
code=internal_code,
|
||||
state=auth_code.state,
|
||||
)
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> Optional[ExternalAuthorizationCode]:
|
||||
"""Load an authorization code."""
|
||||
code = self.codes.get(authorization_code)
|
||||
if code and code.client_id == client.client_id:
|
||||
return code
|
||||
return None
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: ExternalAuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Exchange authorization code for tokens."""
|
||||
# Get stored external tokens
|
||||
external_tokens = self.tokens.get(authorization_code.code)
|
||||
if not external_tokens:
|
||||
raise ValueError("No tokens found for authorization code")
|
||||
|
||||
# Map external tokens to MCP tokens
|
||||
access_token = external_tokens.get("access_token")
|
||||
refresh_token = external_tokens.get("refresh_token")
|
||||
expires_in = external_tokens.get("expires_in", 3600)
|
||||
|
||||
# Store the mapping
|
||||
self.tokens[access_token] = {
|
||||
"client_id": client.client_id,
|
||||
"external_token": access_token,
|
||||
"scopes": authorization_code.scopes,
|
||||
}
|
||||
|
||||
if refresh_token:
|
||||
self.tokens[refresh_token] = {
|
||||
"client_id": client.client_id,
|
||||
"external_token": refresh_token,
|
||||
"scopes": authorization_code.scopes,
|
||||
}
|
||||
|
||||
# Clean up authorization code
|
||||
del self.codes[authorization_code.code]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=expires_in,
|
||||
refresh_token=refresh_token,
|
||||
scope=" ".join(authorization_code.scopes) if authorization_code.scopes else None,
|
||||
)
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> Optional[ExternalRefreshToken]:
|
||||
"""Load a refresh token."""
|
||||
token_info = self.tokens.get(refresh_token)
|
||||
if token_info and token_info["client_id"] == client.client_id:
|
||||
return ExternalRefreshToken(
|
||||
token=refresh_token,
|
||||
client_id=client.client_id,
|
||||
scopes=token_info["scopes"],
|
||||
external_token=token_info.get("external_token"),
|
||||
)
|
||||
return None
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: ExternalRefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Exchange refresh token for new tokens."""
|
||||
# Exchange with external provider
|
||||
token_data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token.external_token or refresh_token.token,
|
||||
"client_id": self.external_client_id,
|
||||
"client_secret": self.external_client_secret,
|
||||
}
|
||||
|
||||
response = await self.http_client.post(
|
||||
self.external_token_url,
|
||||
data=token_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
external_tokens = response.json()
|
||||
|
||||
# Update stored tokens
|
||||
new_access_token = external_tokens.get("access_token")
|
||||
new_refresh_token = external_tokens.get("refresh_token", refresh_token.token)
|
||||
expires_in = external_tokens.get("expires_in", 3600)
|
||||
|
||||
self.tokens[new_access_token] = {
|
||||
"client_id": client.client_id,
|
||||
"external_token": new_access_token,
|
||||
"scopes": scopes or refresh_token.scopes,
|
||||
}
|
||||
|
||||
if new_refresh_token != refresh_token.token:
|
||||
self.tokens[new_refresh_token] = {
|
||||
"client_id": client.client_id,
|
||||
"external_token": new_refresh_token,
|
||||
"scopes": scopes or refresh_token.scopes,
|
||||
}
|
||||
del self.tokens[refresh_token.token]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=new_access_token,
|
||||
token_type="bearer",
|
||||
expires_in=expires_in,
|
||||
refresh_token=new_refresh_token,
|
||||
scope=" ".join(scopes or refresh_token.scopes),
|
||||
)
|
||||
|
||||
async def load_access_token(self, token: str) -> Optional[ExternalAccessToken]:
|
||||
"""Load and validate an access token."""
|
||||
token_info = self.tokens.get(token)
|
||||
if token_info:
|
||||
return ExternalAccessToken(
|
||||
token=token,
|
||||
client_id=token_info["client_id"],
|
||||
scopes=token_info["scopes"],
|
||||
external_token=token_info.get("external_token"),
|
||||
)
|
||||
return None
|
||||
|
||||
async def revoke_token(self, token: ExternalAccessToken | ExternalRefreshToken) -> None:
|
||||
"""Revoke a token."""
|
||||
self.tokens.pop(token.token, None)
|
||||
|
||||
|
||||
def create_github_provider() -> ExternalOAuthProvider:
|
||||
"""Create an OAuth provider for GitHub integration."""
|
||||
return ExternalOAuthProvider(
|
||||
issuer_url=os.getenv("FASTMCP_AUTH_ISSUER_URL", "http://localhost:8000"),
|
||||
external_provider="github",
|
||||
external_client_id=os.getenv("GITHUB_CLIENT_ID", ""),
|
||||
external_client_secret=os.getenv("GITHUB_CLIENT_SECRET", ""),
|
||||
external_authorize_url="https://github.com/login/oauth/authorize",
|
||||
external_token_url="https://github.com/login/oauth/access_token",
|
||||
external_userinfo_url="https://api.github.com/user",
|
||||
)
|
||||
|
||||
|
||||
def create_google_provider() -> ExternalOAuthProvider:
|
||||
"""Create an OAuth provider for Google integration."""
|
||||
return ExternalOAuthProvider(
|
||||
issuer_url=os.getenv("FASTMCP_AUTH_ISSUER_URL", "http://localhost:8000"),
|
||||
external_provider="google",
|
||||
external_client_id=os.getenv("GOOGLE_CLIENT_ID", ""),
|
||||
external_client_secret=os.getenv("GOOGLE_CLIENT_SECRET", ""),
|
||||
external_authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
|
||||
external_token_url="https://oauth2.googleapis.com/token",
|
||||
external_userinfo_url="https://www.googleapis.com/oauth2/v1/userinfo",
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Main MCP entrypoint for Basic Memory.
|
||||
|
||||
Creates and configures the shared MCP instance and handles server startup.
|
||||
"""
|
||||
|
||||
from loguru import logger # pragma: no cover
|
||||
|
||||
from basic_memory.config import config # pragma: no cover
|
||||
|
||||
# Import shared mcp instance
|
||||
from basic_memory.mcp.server import mcp # pragma: no cover
|
||||
|
||||
# Import tools to register them
|
||||
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
|
||||
|
||||
# Import prompts to register them
|
||||
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
home_dir = config.home
|
||||
logger.info("Starting Basic Memory MCP server")
|
||||
logger.info(f"Home directory: {home_dir}")
|
||||
mcp.run()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Project session management for Basic Memory MCP server.
|
||||
|
||||
Provides simple in-memory project context for MCP tools, allowing users to switch
|
||||
between projects during a conversation without restarting the server.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import ProjectConfig, get_project_config
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectSession:
|
||||
"""Simple in-memory project context for MCP session.
|
||||
|
||||
This class manages the current project context that tools use when no explicit
|
||||
project is specified. It's initialized with the default project from config
|
||||
and can be changed during the conversation.
|
||||
"""
|
||||
|
||||
current_project: Optional[str] = None
|
||||
default_project: Optional[str] = None
|
||||
|
||||
def initialize(self, default_project: str) -> None:
|
||||
"""Set the default project from config on startup.
|
||||
|
||||
Args:
|
||||
default_project: The project name from configuration
|
||||
"""
|
||||
self.default_project = default_project
|
||||
self.current_project = default_project
|
||||
logger.info(f"Initialized project session with default project: {default_project}")
|
||||
|
||||
def get_current_project(self) -> str:
|
||||
"""Get the currently active project name.
|
||||
|
||||
Returns:
|
||||
The current project name, falling back to default, then 'main'
|
||||
"""
|
||||
return self.current_project or self.default_project or "main"
|
||||
|
||||
def set_current_project(self, project_name: str) -> None:
|
||||
"""Set the current project context.
|
||||
|
||||
Args:
|
||||
project_name: The project to switch to
|
||||
"""
|
||||
previous = self.current_project
|
||||
self.current_project = project_name
|
||||
logger.info(f"Switched project context: {previous} -> {project_name}")
|
||||
|
||||
def get_default_project(self) -> str:
|
||||
"""Get the default project name from startup.
|
||||
|
||||
Returns:
|
||||
The default project name, or 'main' if not set
|
||||
"""
|
||||
return self.default_project or "main" # pragma: no cover
|
||||
|
||||
def reset_to_default(self) -> None: # pragma: no cover
|
||||
"""Reset current project back to the default project."""
|
||||
self.current_project = self.default_project # pragma: no cover
|
||||
logger.info(f"Reset project context to default: {self.default_project}") # pragma: no cover
|
||||
|
||||
|
||||
# Global session instance
|
||||
session = ProjectSession()
|
||||
|
||||
|
||||
def get_active_project(project_override: Optional[str] = None) -> ProjectConfig:
|
||||
"""Get the active project name for a tool call.
|
||||
|
||||
This is the main function tools should use to determine which project
|
||||
to operate on.
|
||||
|
||||
Args:
|
||||
project_override: Optional explicit project name from tool parameter
|
||||
|
||||
Returns:
|
||||
The project name to use (override takes precedence over session context)
|
||||
"""
|
||||
if project_override: # pragma: no cover
|
||||
project = get_project_config(project_override)
|
||||
session.set_current_project(project_override)
|
||||
return project
|
||||
|
||||
current_project = session.get_current_project()
|
||||
return get_project_config(current_project)
|
||||
|
||||
|
||||
def add_project_metadata(result: str, project_name: str) -> str:
|
||||
"""Add project context as metadata footer for LLM awareness.
|
||||
|
||||
Args:
|
||||
result: The tool result string
|
||||
project_name: The project name that was used
|
||||
|
||||
Returns:
|
||||
Result with project metadata footer
|
||||
"""
|
||||
return f"{result}\n\n<!-- Project: {project_name} -->" # pragma: no cover
|
||||
@@ -4,20 +4,17 @@ These prompts help users continue conversations and work across sessions,
|
||||
providing context from previous interactions to maintain continuity.
|
||||
"""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from basic_memory.mcp.prompts.utils import PromptContext, PromptContextItem, format_prompt_context
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.build_context import build_context
|
||||
from basic_memory.mcp.tools.recent_activity import recent_activity
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import GraphContext
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.schemas.prompt import ContinueConversationRequest
|
||||
|
||||
|
||||
@mcp.prompt(
|
||||
@@ -45,67 +42,20 @@ async def continue_conversation(
|
||||
"""
|
||||
logger.info(f"Continuing session, topic: {topic}, timeframe: {timeframe}")
|
||||
|
||||
# If topic provided, search for it
|
||||
if topic:
|
||||
search_results = await search_notes(
|
||||
query=topic, after_date=timeframe, entity_types=[SearchItemType.ENTITY]
|
||||
)
|
||||
# Create request model
|
||||
request = ContinueConversationRequest( # pyright: ignore [reportCallIssue]
|
||||
topic=topic, timeframe=timeframe
|
||||
)
|
||||
|
||||
# Build context from results
|
||||
contexts = []
|
||||
for result in search_results.results:
|
||||
if hasattr(result, "permalink") and result.permalink:
|
||||
context: GraphContext = await build_context(f"memory://{result.permalink}")
|
||||
if context.primary_results:
|
||||
contexts.append(
|
||||
PromptContextItem(
|
||||
primary_results=context.primary_results[:1], # pyright: ignore
|
||||
related_results=context.related_results[:3], # pyright: ignore
|
||||
)
|
||||
)
|
||||
project_url = get_project_config().project_url
|
||||
|
||||
# get context for the top 3 results
|
||||
prompt_context = format_prompt_context(
|
||||
PromptContext(topic=topic, timeframe=timeframe, results=contexts) # pyright: ignore
|
||||
)
|
||||
# Call the prompt API endpoint
|
||||
response = await call_post(
|
||||
client,
|
||||
f"{project_url}/prompt/continue-conversation",
|
||||
json=request.model_dump(exclude_none=True),
|
||||
)
|
||||
|
||||
else:
|
||||
# If no topic, get recent activity
|
||||
timeframe = timeframe or "7d"
|
||||
recent: GraphContext = await recent_activity(
|
||||
timeframe=timeframe, type=[SearchItemType.ENTITY]
|
||||
)
|
||||
prompt_context = format_prompt_context(
|
||||
PromptContext(
|
||||
topic=f"Recent Activity from ({timeframe})",
|
||||
timeframe=timeframe,
|
||||
results=[
|
||||
PromptContextItem(
|
||||
primary_results=recent.primary_results[:5], # pyright: ignore
|
||||
related_results=recent.related_results[:2], # pyright: ignore
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Add next steps with strong encouragement to write
|
||||
next_steps = dedent(f"""
|
||||
## Next Steps
|
||||
|
||||
You can:
|
||||
- Explore more with: `search_notes({{"text": "{topic}"}})`
|
||||
- See what's changed: `recent_activity(timeframe="{timeframe or "7d"}")`
|
||||
- **Record new learnings or decisions from this conversation:** `write_note(title="[Create a meaningful title]", content="[Content with observations and relations]")`
|
||||
|
||||
## Knowledge Capture Recommendation
|
||||
|
||||
As you continue this conversation, **actively look for opportunities to:**
|
||||
1. Record key information, decisions, or insights that emerge
|
||||
2. Link new knowledge to existing topics
|
||||
3. Suggest capturing important context when appropriate
|
||||
4. Create forward references to topics that might be created later
|
||||
|
||||
Remember that capturing knowledge during conversations is one of the most valuable aspects of Basic Memory.
|
||||
""")
|
||||
|
||||
return prompt_context + next_steps
|
||||
# Extract the rendered prompt from the response
|
||||
result = response.json()
|
||||
return result["prompt"]
|
||||
|
||||
@@ -40,20 +40,36 @@ async def recent_activity_prompt(
|
||||
|
||||
recent = await recent_activity(timeframe=timeframe, type=[SearchItemType.ENTITY])
|
||||
|
||||
# Extract primary results from the hierarchical structure
|
||||
primary_results = []
|
||||
related_results = []
|
||||
|
||||
if recent.results:
|
||||
# Take up to 5 primary results
|
||||
for item in recent.results[:5]:
|
||||
primary_results.append(item.primary_result)
|
||||
# Add up to 2 related results per primary item
|
||||
if item.related_results:
|
||||
related_results.extend(item.related_results[:2])
|
||||
|
||||
prompt_context = format_prompt_context(
|
||||
PromptContext(
|
||||
topic=f"Recent Activity from ({timeframe})",
|
||||
timeframe=timeframe,
|
||||
results=[
|
||||
PromptContextItem(
|
||||
primary_results=recent.primary_results[:5],
|
||||
related_results=recent.related_results[:2],
|
||||
primary_results=primary_results,
|
||||
related_results=related_results[:10], # Limit total related results
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Add suggestions for summarizing recent activity
|
||||
first_title = "Recent Topic"
|
||||
if primary_results and len(primary_results) > 0:
|
||||
first_title = primary_results[0].title
|
||||
|
||||
capture_suggestions = f"""
|
||||
## Opportunity to Capture Activity Summary
|
||||
|
||||
@@ -76,7 +92,7 @@ async def recent_activity_prompt(
|
||||
- [insight] [Connection between different activities]
|
||||
|
||||
## Relations
|
||||
- summarizes [[{recent.primary_results[0].title if recent.primary_results else "Recent Topic"}]]
|
||||
- summarizes [[{first_title}]]
|
||||
- relates_to [[Project Overview]]
|
||||
'''
|
||||
)
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
These prompts help users search and explore their knowledge base.
|
||||
"""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.search import search_notes as search_tool
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.search import SearchResponse
|
||||
from basic_memory.schemas.prompt import SearchPromptRequest
|
||||
|
||||
|
||||
@mcp.prompt(
|
||||
@@ -40,143 +41,16 @@ async def search_prompt(
|
||||
"""
|
||||
logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
|
||||
|
||||
search_results = await search_tool(query=query, after_date=timeframe)
|
||||
return format_search_results(query, search_results, timeframe)
|
||||
# Create request model
|
||||
request = SearchPromptRequest(query=query, timeframe=timeframe)
|
||||
|
||||
project_url = get_project_config().project_url
|
||||
|
||||
def format_search_results(
|
||||
query: str, results: SearchResponse, timeframe: Optional[TimeFrame] = None
|
||||
) -> str:
|
||||
"""Format search results into a helpful summary.
|
||||
# Call the prompt API endpoint
|
||||
response = await call_post(
|
||||
client, f"{project_url}/prompt/search", json=request.model_dump(exclude_none=True)
|
||||
)
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
results: Search results object
|
||||
timeframe: How far back results were searched
|
||||
|
||||
Returns:
|
||||
Formatted search results summary
|
||||
"""
|
||||
if not results.results:
|
||||
return dedent(f"""
|
||||
# Search Results for: "{query}"
|
||||
|
||||
I couldn't find any results for this query.
|
||||
|
||||
## Opportunity to Capture Knowledge!
|
||||
|
||||
This is an excellent opportunity to create new knowledge on this topic. Consider:
|
||||
|
||||
```python
|
||||
await write_note(
|
||||
title="{query.capitalize()}",
|
||||
content=f'''
|
||||
# {query.capitalize()}
|
||||
|
||||
## Overview
|
||||
[Summary of what we've discussed about {query}]
|
||||
|
||||
## Observations
|
||||
- [category] [First observation about {query}]
|
||||
- [category] [Second observation about {query}]
|
||||
|
||||
## Relations
|
||||
- relates_to [[Other Relevant Topic]]
|
||||
'''
|
||||
)
|
||||
```
|
||||
|
||||
## Other Suggestions
|
||||
- Try a different search term
|
||||
- Broaden your search criteria
|
||||
- Check recent activity with `recent_activity(timeframe="1w")`
|
||||
""")
|
||||
|
||||
# Start building our summary with header
|
||||
time_info = f" (after {timeframe})" if timeframe else ""
|
||||
summary = dedent(f"""
|
||||
# Search Results for: "{query}"{time_info}
|
||||
|
||||
This is a memory search session.
|
||||
Please use the available basic-memory tools to gather relevant context before responding.
|
||||
I found {len(results.results)} results that match your query.
|
||||
|
||||
Here are the most relevant results:
|
||||
""")
|
||||
|
||||
# Add each search result
|
||||
for i, result in enumerate(results.results[:5]): # Limit to top 5 results
|
||||
summary += dedent(f"""
|
||||
## {i + 1}. {result.title}
|
||||
- **Type**: {result.type.value}
|
||||
""")
|
||||
|
||||
# Add creation date if available in metadata
|
||||
if result.metadata and "created_at" in result.metadata:
|
||||
created_at = result.metadata["created_at"]
|
||||
if hasattr(created_at, "strftime"):
|
||||
summary += (
|
||||
f"- **Created**: {created_at.strftime('%Y-%m-%d %H:%M')}\n" # pragma: no cover
|
||||
)
|
||||
elif isinstance(created_at, str):
|
||||
summary += f"- **Created**: {created_at}\n"
|
||||
|
||||
# Add score and excerpt
|
||||
summary += f"- **Relevance Score**: {result.score:.2f}\n"
|
||||
|
||||
# Add excerpt if available in metadata
|
||||
if result.content:
|
||||
summary += f"- **Excerpt**:\n{result.content}\n"
|
||||
|
||||
# Add permalink for retrieving content
|
||||
if result.permalink:
|
||||
summary += dedent(f"""
|
||||
You can view this content with: `read_note("{result.permalink}")`
|
||||
Or explore its context with: `build_context("memory://{result.permalink}")`
|
||||
""")
|
||||
else:
|
||||
summary += dedent(f"""
|
||||
You can view this file with: `read_file("{result.file_path}")`
|
||||
""") # pragma: no cover
|
||||
|
||||
# Add next steps with strong write encouragement
|
||||
summary += dedent(f"""
|
||||
## Next Steps
|
||||
|
||||
You can:
|
||||
- Refine your search: `search_notes("{query} AND additional_term")`
|
||||
- Exclude terms: `search_notes("{query} NOT exclude_term")`
|
||||
- View more results: `search_notes("{query}", after_date=None)`
|
||||
- Check recent activity: `recent_activity()`
|
||||
|
||||
## Synthesize and Capture Knowledge
|
||||
|
||||
Consider creating a new note that synthesizes what you've learned:
|
||||
|
||||
```python
|
||||
await write_note(
|
||||
title="Synthesis of {query.capitalize()} Information",
|
||||
content='''
|
||||
# Synthesis of {query.capitalize()} Information
|
||||
|
||||
## Overview
|
||||
[Synthesis of the search results and your conversation]
|
||||
|
||||
## Key Insights
|
||||
[Summary of main points learned from these results]
|
||||
|
||||
## Observations
|
||||
- [insight] [Important observation from search results]
|
||||
- [connection] [How this connects to other topics]
|
||||
|
||||
## Relations
|
||||
- relates_to [[{results.results[0].title if results.results else "Related Topic"}]]
|
||||
- extends [[Another Relevant Topic]]
|
||||
'''
|
||||
)
|
||||
```
|
||||
|
||||
Remember that capturing synthesized knowledge is one of the most valuable features of Basic Memory.
|
||||
""")
|
||||
|
||||
return summary
|
||||
# Extract the rendered prompt from the response
|
||||
result = response.json()
|
||||
return result["prompt"]
|
||||
|
||||
@@ -35,7 +35,7 @@ def format_prompt_context(context: PromptContext) -> str:
|
||||
Returns:
|
||||
Formatted continuation summary
|
||||
"""
|
||||
if not context.results:
|
||||
if not context.results: # pragma: no cover
|
||||
return dedent(f"""
|
||||
# Continuing conversation on: {context.topic}
|
||||
|
||||
@@ -138,11 +138,11 @@ def format_prompt_context(context: PromptContext) -> str:
|
||||
- type: **{related.type}**
|
||||
- title: {related.title}
|
||||
""")
|
||||
if related.permalink:
|
||||
if related.permalink: # pragma: no cover
|
||||
section_content += (
|
||||
f'You can view this document with: `read_note("{related.permalink}")`'
|
||||
)
|
||||
else:
|
||||
else: # pragma: no cover
|
||||
section_content += (
|
||||
f'You can view this file with: `read_file("{related.file_path}")`'
|
||||
)
|
||||
|
||||
+6
-2
@@ -2,13 +2,15 @@
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas import ProjectInfoResponse
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@mcp.resource(
|
||||
uri="memory://project_info",
|
||||
description="Get information and statistics about the current Basic Memory project.",
|
||||
)
|
||||
async def project_info() -> ProjectInfoResponse:
|
||||
@@ -43,9 +45,11 @@ async def project_info() -> ProjectInfoResponse:
|
||||
print(f"Basic Memory version: {info.system.version}")
|
||||
"""
|
||||
logger.info("Getting project info")
|
||||
project_config = get_active_project()
|
||||
project_url = project_config.project_url
|
||||
|
||||
# Call the API endpoint
|
||||
response = await call_get(client, "/stats/project-info")
|
||||
response = await call_get(client, f"{project_url}/project/info")
|
||||
|
||||
# Convert response to ProjectInfoResponse
|
||||
return ProjectInfoResponse.model_validate(response.json())
|
||||
@@ -1,19 +1,32 @@
|
||||
"""Enhanced FastMCP server instance for Basic Memory."""
|
||||
"""
|
||||
Basic Memory FastMCP server.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.fastmcp.utilities.logging import configure_logging as mcp_configure_logging
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncIterator, Optional, Any
|
||||
|
||||
from basic_memory.config import config as project_config
|
||||
from dotenv import load_dotenv
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.logging import configure_logging as mcp_configure_logging
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
|
||||
from basic_memory.config import app_config
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
from basic_memory.mcp.auth_provider import BasicMemoryOAuthProvider
|
||||
from basic_memory.mcp.project_session import session
|
||||
from basic_memory.mcp.external_auth_provider import (
|
||||
create_github_provider,
|
||||
create_google_provider,
|
||||
)
|
||||
from basic_memory.mcp.supabase_auth_provider import SupabaseOAuthProvider
|
||||
|
||||
# mcp console logging
|
||||
mcp_configure_logging(level="ERROR")
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
@@ -24,7 +37,11 @@ class AppContext:
|
||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: no cover
|
||||
"""Manage application lifecycle with type-safe context"""
|
||||
# Initialize on startup
|
||||
watch_task = await initialize_app(project_config)
|
||||
watch_task = await initialize_app(app_config)
|
||||
|
||||
# Initialize project session with default project
|
||||
session.initialize(app_config.default_project)
|
||||
|
||||
try:
|
||||
yield AppContext(watch_task=watch_task)
|
||||
finally:
|
||||
@@ -33,5 +50,62 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma:
|
||||
watch_task.cancel()
|
||||
|
||||
|
||||
# OAuth configuration function
|
||||
def create_auth_config() -> tuple[AuthSettings | None, Any | None]:
|
||||
"""Create OAuth configuration if enabled."""
|
||||
# Check if OAuth is enabled via environment variable
|
||||
import os
|
||||
|
||||
if os.getenv("FASTMCP_AUTH_ENABLED", "false").lower() == "true":
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
# Configure OAuth settings
|
||||
issuer_url = os.getenv("FASTMCP_AUTH_ISSUER_URL", "http://localhost:8000")
|
||||
required_scopes = os.getenv("FASTMCP_AUTH_REQUIRED_SCOPES", "read,write")
|
||||
docs_url = os.getenv("FASTMCP_AUTH_DOCS_URL") or "http://localhost:8000/docs/oauth"
|
||||
|
||||
auth_settings = AuthSettings(
|
||||
issuer_url=AnyHttpUrl(issuer_url),
|
||||
service_documentation_url=AnyHttpUrl(docs_url),
|
||||
required_scopes=required_scopes.split(",") if required_scopes else ["read", "write"],
|
||||
)
|
||||
|
||||
# Create OAuth provider based on type
|
||||
provider_type = os.getenv("FASTMCP_AUTH_PROVIDER", "basic").lower()
|
||||
|
||||
if provider_type == "github":
|
||||
auth_provider = create_github_provider()
|
||||
elif provider_type == "google":
|
||||
auth_provider = create_google_provider()
|
||||
elif provider_type == "supabase":
|
||||
supabase_url = os.getenv("SUPABASE_URL")
|
||||
supabase_anon_key = os.getenv("SUPABASE_ANON_KEY")
|
||||
supabase_service_key = os.getenv("SUPABASE_SERVICE_KEY")
|
||||
|
||||
if not supabase_url or not supabase_anon_key:
|
||||
raise ValueError("SUPABASE_URL and SUPABASE_ANON_KEY must be set for Supabase auth")
|
||||
|
||||
auth_provider = SupabaseOAuthProvider(
|
||||
supabase_url=supabase_url,
|
||||
supabase_anon_key=supabase_anon_key,
|
||||
supabase_service_key=supabase_service_key,
|
||||
issuer_url=issuer_url,
|
||||
)
|
||||
else: # default to "basic"
|
||||
auth_provider = BasicMemoryOAuthProvider(issuer_url=issuer_url)
|
||||
|
||||
return auth_settings, auth_provider
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
# Create auth configuration
|
||||
auth_settings, auth_provider = create_auth_config()
|
||||
|
||||
# Create the shared server instance
|
||||
mcp = FastMCP("Basic Memory", log_level="ERROR", lifespan=app_lifespan)
|
||||
mcp = FastMCP(
|
||||
name="Basic Memory",
|
||||
log_level="DEBUG",
|
||||
auth_server_provider=auth_provider,
|
||||
auth=auth_settings,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Supabase OAuth provider for Basic Memory MCP server."""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from loguru import logger
|
||||
from mcp.server.auth.provider import (
|
||||
OAuthAuthorizationServerProvider,
|
||||
AuthorizationParams,
|
||||
AuthorizationCode,
|
||||
RefreshToken,
|
||||
AccessToken,
|
||||
TokenError,
|
||||
AuthorizeError,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupabaseAuthorizationCode(AuthorizationCode):
|
||||
"""Authorization code with Supabase metadata."""
|
||||
|
||||
user_id: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupabaseRefreshToken(RefreshToken):
|
||||
"""Refresh token with Supabase metadata."""
|
||||
|
||||
supabase_refresh_token: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupabaseAccessToken(AccessToken):
|
||||
"""Access token with Supabase metadata."""
|
||||
|
||||
supabase_access_token: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class SupabaseOAuthProvider(
|
||||
OAuthAuthorizationServerProvider[
|
||||
SupabaseAuthorizationCode, SupabaseRefreshToken, SupabaseAccessToken
|
||||
]
|
||||
):
|
||||
"""OAuth provider that integrates with Supabase Auth.
|
||||
|
||||
This provider uses Supabase as the authentication backend while
|
||||
maintaining compatibility with MCP's OAuth requirements.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
supabase_url: str,
|
||||
supabase_anon_key: str,
|
||||
supabase_service_key: Optional[str] = None,
|
||||
issuer_url: str = "http://localhost:8000",
|
||||
):
|
||||
self.supabase_url = supabase_url.rstrip("/")
|
||||
self.supabase_anon_key = supabase_anon_key
|
||||
self.supabase_service_key = supabase_service_key or supabase_anon_key
|
||||
self.issuer_url = issuer_url
|
||||
|
||||
# HTTP client for Supabase API calls
|
||||
self.http_client = httpx.AsyncClient()
|
||||
|
||||
# Temporary storage for auth flows (in production, use Supabase DB)
|
||||
self.pending_auth_codes: Dict[str, SupabaseAuthorizationCode] = {}
|
||||
self.mcp_to_supabase_tokens: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
|
||||
"""Get a client from Supabase.
|
||||
|
||||
In production, this would query a clients table in Supabase.
|
||||
"""
|
||||
# For now, we'll validate against a configured list of allowed clients
|
||||
# In production, query Supabase DB for client info
|
||||
allowed_clients = os.getenv("SUPABASE_ALLOWED_CLIENTS", "").split(",")
|
||||
|
||||
if client_id in allowed_clients:
|
||||
return OAuthClientInformationFull(
|
||||
client_id=client_id,
|
||||
client_secret="", # Supabase handles secrets
|
||||
redirect_uris=[], # Supabase handles redirect URIs
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Register a new OAuth client in Supabase.
|
||||
|
||||
In production, this would insert into a clients table.
|
||||
"""
|
||||
# For development, we just log the registration
|
||||
logger.info(f"Would register client {client_info.client_id} in Supabase")
|
||||
|
||||
# In production:
|
||||
# await self.supabase.table('oauth_clients').insert({
|
||||
# 'client_id': client_info.client_id,
|
||||
# 'client_secret': client_info.client_secret,
|
||||
# 'metadata': client_info.client_metadata,
|
||||
# }).execute()
|
||||
|
||||
async def authorize(
|
||||
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
||||
) -> str:
|
||||
"""Create authorization URL redirecting to Supabase Auth.
|
||||
|
||||
This initiates the OAuth flow with Supabase as the identity provider.
|
||||
"""
|
||||
# Generate state for this auth request
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Store the authorization request
|
||||
self.pending_auth_codes[state] = SupabaseAuthorizationCode(
|
||||
code=state,
|
||||
scopes=params.scopes or [],
|
||||
expires_at=(datetime.utcnow() + timedelta(minutes=10)).timestamp(),
|
||||
client_id=client.client_id,
|
||||
code_challenge=params.code_challenge,
|
||||
redirect_uri=params.redirect_uri,
|
||||
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
||||
)
|
||||
|
||||
# Build Supabase auth URL
|
||||
auth_params = {
|
||||
"redirect_to": f"{self.issuer_url}/auth/callback",
|
||||
"scopes": " ".join(params.scopes or ["openid", "email"]),
|
||||
"state": state,
|
||||
}
|
||||
|
||||
# Use Supabase's OAuth endpoint
|
||||
auth_url = f"{self.supabase_url}/auth/v1/authorize"
|
||||
query_string = "&".join(f"{k}={v}" for k, v in auth_params.items())
|
||||
|
||||
return f"{auth_url}?{query_string}"
|
||||
|
||||
async def handle_supabase_callback(self, code: str, state: str) -> str:
|
||||
"""Handle callback from Supabase after user authentication."""
|
||||
# Get the original auth request
|
||||
auth_request = self.pending_auth_codes.get(state)
|
||||
if not auth_request:
|
||||
raise AuthorizeError(
|
||||
error="invalid_request",
|
||||
error_description="Invalid state parameter",
|
||||
)
|
||||
|
||||
# Exchange code with Supabase for tokens
|
||||
token_response = await self.http_client.post(
|
||||
f"{self.supabase_url}/auth/v1/token",
|
||||
json={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": f"{self.issuer_url}/auth/callback",
|
||||
},
|
||||
headers={
|
||||
"apikey": self.supabase_anon_key,
|
||||
"Authorization": f"Bearer {self.supabase_anon_key}",
|
||||
},
|
||||
)
|
||||
|
||||
if not token_response.is_success:
|
||||
raise AuthorizeError(
|
||||
error="server_error",
|
||||
error_description="Failed to exchange code with Supabase",
|
||||
)
|
||||
|
||||
supabase_tokens = token_response.json()
|
||||
|
||||
# Get user info from Supabase
|
||||
user_response = await self.http_client.get(
|
||||
f"{self.supabase_url}/auth/v1/user",
|
||||
headers={
|
||||
"apikey": self.supabase_anon_key,
|
||||
"Authorization": f"Bearer {supabase_tokens['access_token']}",
|
||||
},
|
||||
)
|
||||
|
||||
user_data = user_response.json() if user_response.is_success else {}
|
||||
|
||||
# Generate MCP authorization code
|
||||
mcp_code = secrets.token_urlsafe(32)
|
||||
|
||||
# Update auth request with user info
|
||||
auth_request.code = mcp_code
|
||||
auth_request.user_id = user_data.get("id")
|
||||
auth_request.email = user_data.get("email")
|
||||
|
||||
# Store mapping
|
||||
self.pending_auth_codes[mcp_code] = auth_request
|
||||
self.mcp_to_supabase_tokens[mcp_code] = {
|
||||
"supabase_tokens": supabase_tokens,
|
||||
"user": user_data,
|
||||
}
|
||||
|
||||
# Clean up old state
|
||||
del self.pending_auth_codes[state]
|
||||
|
||||
# Redirect back to client
|
||||
redirect_uri = str(auth_request.redirect_uri)
|
||||
separator = "&" if "?" in redirect_uri else "?"
|
||||
|
||||
return f"{redirect_uri}{separator}code={mcp_code}&state={state}"
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> Optional[SupabaseAuthorizationCode]:
|
||||
"""Load an authorization code."""
|
||||
code = self.pending_auth_codes.get(authorization_code)
|
||||
|
||||
if code and code.client_id == client.client_id:
|
||||
# Check expiration
|
||||
if datetime.utcnow().timestamp() > code.expires_at:
|
||||
del self.pending_auth_codes[authorization_code]
|
||||
return None
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: SupabaseAuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Exchange authorization code for tokens."""
|
||||
# Get stored Supabase tokens
|
||||
token_data = self.mcp_to_supabase_tokens.get(authorization_code.code)
|
||||
if not token_data:
|
||||
raise TokenError(error="invalid_grant", error_description="Invalid authorization code")
|
||||
|
||||
supabase_tokens = token_data["supabase_tokens"]
|
||||
user = token_data["user"]
|
||||
|
||||
# Generate MCP tokens that wrap Supabase tokens
|
||||
access_token = self._generate_mcp_token(
|
||||
client_id=client.client_id,
|
||||
user_id=user.get("id", ""),
|
||||
email=user.get("email", ""),
|
||||
scopes=authorization_code.scopes,
|
||||
supabase_access_token=supabase_tokens["access_token"],
|
||||
)
|
||||
|
||||
refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Store the token mapping
|
||||
self.mcp_to_supabase_tokens[access_token] = {
|
||||
"client_id": client.client_id,
|
||||
"user_id": user.get("id"),
|
||||
"email": user.get("email"),
|
||||
"supabase_access_token": supabase_tokens["access_token"],
|
||||
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
||||
"scopes": authorization_code.scopes,
|
||||
}
|
||||
|
||||
# Store refresh token mapping
|
||||
self.mcp_to_supabase_tokens[refresh_token] = {
|
||||
"client_id": client.client_id,
|
||||
"user_id": user.get("id"),
|
||||
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
||||
"scopes": authorization_code.scopes,
|
||||
}
|
||||
|
||||
# Clean up authorization code
|
||||
del self.pending_auth_codes[authorization_code.code]
|
||||
del self.mcp_to_supabase_tokens[authorization_code.code]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=supabase_tokens.get("expires_in", 3600),
|
||||
refresh_token=refresh_token,
|
||||
scope=" ".join(authorization_code.scopes) if authorization_code.scopes else None,
|
||||
)
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> Optional[SupabaseRefreshToken]:
|
||||
"""Load a refresh token."""
|
||||
token_data = self.mcp_to_supabase_tokens.get(refresh_token)
|
||||
|
||||
if token_data and token_data["client_id"] == client.client_id:
|
||||
return SupabaseRefreshToken(
|
||||
token=refresh_token,
|
||||
client_id=client.client_id,
|
||||
scopes=token_data["scopes"],
|
||||
supabase_refresh_token=token_data["supabase_refresh_token"],
|
||||
user_id=token_data.get("user_id"),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: SupabaseRefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Exchange refresh token for new tokens using Supabase."""
|
||||
# Refresh with Supabase
|
||||
token_response = await self.http_client.post(
|
||||
f"{self.supabase_url}/auth/v1/token",
|
||||
json={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token.supabase_refresh_token,
|
||||
},
|
||||
headers={
|
||||
"apikey": self.supabase_anon_key,
|
||||
"Authorization": f"Bearer {self.supabase_anon_key}",
|
||||
},
|
||||
)
|
||||
|
||||
if not token_response.is_success:
|
||||
raise TokenError(
|
||||
error="invalid_grant",
|
||||
error_description="Failed to refresh with Supabase",
|
||||
)
|
||||
|
||||
supabase_tokens = token_response.json()
|
||||
|
||||
# Get updated user info
|
||||
user_response = await self.http_client.get(
|
||||
f"{self.supabase_url}/auth/v1/user",
|
||||
headers={
|
||||
"apikey": self.supabase_anon_key,
|
||||
"Authorization": f"Bearer {supabase_tokens['access_token']}",
|
||||
},
|
||||
)
|
||||
|
||||
user_data = user_response.json() if user_response.is_success else {}
|
||||
|
||||
# Generate new MCP tokens
|
||||
new_access_token = self._generate_mcp_token(
|
||||
client_id=client.client_id,
|
||||
user_id=user_data.get("id", ""),
|
||||
email=user_data.get("email", ""),
|
||||
scopes=scopes or refresh_token.scopes,
|
||||
supabase_access_token=supabase_tokens["access_token"],
|
||||
)
|
||||
|
||||
new_refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Update token mappings
|
||||
self.mcp_to_supabase_tokens[new_access_token] = {
|
||||
"client_id": client.client_id,
|
||||
"user_id": user_data.get("id"),
|
||||
"email": user_data.get("email"),
|
||||
"supabase_access_token": supabase_tokens["access_token"],
|
||||
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
||||
"scopes": scopes or refresh_token.scopes,
|
||||
}
|
||||
|
||||
self.mcp_to_supabase_tokens[new_refresh_token] = {
|
||||
"client_id": client.client_id,
|
||||
"user_id": user_data.get("id"),
|
||||
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
||||
"scopes": scopes or refresh_token.scopes,
|
||||
}
|
||||
|
||||
# Clean up old tokens
|
||||
del self.mcp_to_supabase_tokens[refresh_token.token]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=new_access_token,
|
||||
token_type="bearer",
|
||||
expires_in=supabase_tokens.get("expires_in", 3600),
|
||||
refresh_token=new_refresh_token,
|
||||
scope=" ".join(scopes or refresh_token.scopes),
|
||||
)
|
||||
|
||||
async def load_access_token(self, token: str) -> Optional[SupabaseAccessToken]:
|
||||
"""Load and validate an access token."""
|
||||
# First check our mapping
|
||||
token_data = self.mcp_to_supabase_tokens.get(token)
|
||||
if token_data:
|
||||
return SupabaseAccessToken(
|
||||
token=token,
|
||||
client_id=token_data["client_id"],
|
||||
scopes=token_data["scopes"],
|
||||
supabase_access_token=token_data.get("supabase_access_token"),
|
||||
user_id=token_data.get("user_id"),
|
||||
email=token_data.get("email"),
|
||||
)
|
||||
|
||||
# Try to decode as JWT
|
||||
try:
|
||||
# Verify with Supabase's JWT secret
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
os.getenv("SUPABASE_JWT_SECRET", ""),
|
||||
algorithms=["HS256"],
|
||||
audience="authenticated",
|
||||
)
|
||||
|
||||
return SupabaseAccessToken(
|
||||
token=token,
|
||||
client_id=payload.get("client_id", ""),
|
||||
scopes=payload.get("scopes", []),
|
||||
user_id=payload.get("sub"),
|
||||
email=payload.get("email"),
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
pass
|
||||
|
||||
# Validate with Supabase
|
||||
user_response = await self.http_client.get(
|
||||
f"{self.supabase_url}/auth/v1/user",
|
||||
headers={
|
||||
"apikey": self.supabase_anon_key,
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
)
|
||||
|
||||
if user_response.is_success:
|
||||
user_data = user_response.json()
|
||||
return SupabaseAccessToken(
|
||||
token=token,
|
||||
client_id="", # Unknown client for direct Supabase tokens
|
||||
scopes=[],
|
||||
supabase_access_token=token,
|
||||
user_id=user_data.get("id"),
|
||||
email=user_data.get("email"),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def revoke_token(self, token: SupabaseAccessToken | SupabaseRefreshToken) -> None:
|
||||
"""Revoke a token."""
|
||||
# Remove from our mapping
|
||||
self.mcp_to_supabase_tokens.pop(token.token, None)
|
||||
|
||||
# In production, also revoke in Supabase:
|
||||
# await self.supabase.auth.admin.sign_out(token.user_id)
|
||||
|
||||
def _generate_mcp_token(
|
||||
self,
|
||||
client_id: str,
|
||||
user_id: str,
|
||||
email: str,
|
||||
scopes: list[str],
|
||||
supabase_access_token: str,
|
||||
) -> str:
|
||||
"""Generate an MCP token that wraps Supabase authentication."""
|
||||
payload = {
|
||||
"iss": self.issuer_url,
|
||||
"sub": user_id,
|
||||
"client_id": client_id,
|
||||
"email": email,
|
||||
"scopes": scopes,
|
||||
"supabase_token": supabase_access_token[:10] + "...", # Reference only
|
||||
"exp": datetime.utcnow() + timedelta(hours=1),
|
||||
"iat": datetime.utcnow(),
|
||||
}
|
||||
|
||||
# Use Supabase JWT secret if available
|
||||
secret = os.getenv("SUPABASE_JWT_SECRET", secrets.token_urlsafe(32))
|
||||
|
||||
return jwt.encode(payload, secret, algorithm="HS256")
|
||||
@@ -14,14 +14,34 @@ from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.canvas import canvas
|
||||
from basic_memory.mcp.tools.list_directory import list_directory
|
||||
from basic_memory.mcp.tools.edit_note import edit_note
|
||||
from basic_memory.mcp.tools.move_note import move_note
|
||||
from basic_memory.mcp.tools.project_management import (
|
||||
list_projects,
|
||||
switch_project,
|
||||
get_current_project,
|
||||
set_default_project,
|
||||
create_project,
|
||||
delete_project,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_context",
|
||||
"canvas",
|
||||
"create_project",
|
||||
"delete_note",
|
||||
"delete_project",
|
||||
"edit_note",
|
||||
"get_current_project",
|
||||
"list_directory",
|
||||
"list_projects",
|
||||
"move_note",
|
||||
"read_content",
|
||||
"read_note",
|
||||
"recent_activity",
|
||||
"search_notes",
|
||||
"set_default_project",
|
||||
"switch_project",
|
||||
"write_note",
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from loguru import logger
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
@@ -35,6 +36,7 @@ async def build_context(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
project: Optional[str] = None,
|
||||
) -> GraphContext:
|
||||
"""Get context needed to continue a discussion.
|
||||
|
||||
@@ -49,6 +51,7 @@ async def build_context(
|
||||
page: Page number of results to return (default: 1)
|
||||
page_size: Number of results to return per page (default: 10)
|
||||
max_related: Maximum number of related results to return (default: 10)
|
||||
project: Optional project name to build context from. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
@@ -68,12 +71,19 @@ async def build_context(
|
||||
|
||||
# Research the history of a feature
|
||||
build_context("memory://features/knowledge-graph", timeframe="3 months ago")
|
||||
|
||||
# Build context from specific project
|
||||
build_context("memory://specs/search", project="work-project")
|
||||
"""
|
||||
logger.info(f"Building context from {url}")
|
||||
url = normalize_memory_url(url)
|
||||
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/memory/{memory_url_path(url)}",
|
||||
f"{project_url}/memory/{memory_url_path(url)}",
|
||||
params={
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
|
||||
@@ -4,13 +4,14 @@ This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_put
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -21,6 +22,7 @@ async def canvas(
|
||||
edges: List[Dict[str, Any]],
|
||||
title: str,
|
||||
folder: str,
|
||||
project: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Create an Obsidian canvas file with the provided nodes and edges.
|
||||
|
||||
@@ -34,6 +36,7 @@ async def canvas(
|
||||
edges: List of edge objects following JSON Canvas 1.0 spec
|
||||
title: The title of the canvas (will be saved as title.canvas)
|
||||
folder: The folder where the file should be saved
|
||||
project: Optional project name to create canvas in. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
A summary of the created canvas file
|
||||
@@ -71,7 +74,17 @@ async def canvas(
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Examples:
|
||||
# Create canvas in current project
|
||||
canvas(nodes=[...], edges=[...], title="My Canvas", folder="diagrams")
|
||||
|
||||
# Create canvas in specific project
|
||||
canvas(nodes=[...], edges=[...], title="My Canvas", folder="diagrams", project="work-project")
|
||||
"""
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Ensure path has .canvas extension
|
||||
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
|
||||
file_path = f"{folder}/{file_title}"
|
||||
@@ -84,7 +97,7 @@ async def canvas(
|
||||
|
||||
# Write the file using the resource API
|
||||
logger.info(f"Creating canvas file: {file_path}")
|
||||
response = await call_put(client, f"/resource/{file_path}", json=canvas_json)
|
||||
response = await call_put(client, f"{project_url}/resource/{file_path}", json=canvas_json)
|
||||
|
||||
# Parse response
|
||||
result = response.json()
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
from typing import Optional
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_delete
|
||||
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
|
||||
|
||||
@mcp.tool(description="Delete a note by title or permalink")
|
||||
async def delete_note(identifier: str) -> bool:
|
||||
async def delete_note(identifier: str, project: Optional[str] = None) -> bool:
|
||||
"""Delete a note from the knowledge base.
|
||||
|
||||
Args:
|
||||
identifier: Note title or permalink
|
||||
project: Optional project name to delete from. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
True if note was deleted, False otherwise
|
||||
@@ -22,7 +24,13 @@ async def delete_note(identifier: str) -> bool:
|
||||
|
||||
# Delete by permalink
|
||||
delete_note("notes/project-planning")
|
||||
|
||||
# Delete from specific project
|
||||
delete_note("notes/project-planning", project="work-project")
|
||||
"""
|
||||
response = await call_delete(client, f"/knowledge/entities/{identifier}")
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
return result.deleted
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Edit note tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_patch
|
||||
from basic_memory.schemas import EntityResponse
|
||||
|
||||
|
||||
def _format_error_response(
|
||||
error_message: str,
|
||||
operation: str,
|
||||
identifier: str,
|
||||
find_text: Optional[str] = None,
|
||||
expected_replacements: int = 1,
|
||||
) -> str:
|
||||
"""Format helpful error responses for edit_note failures that guide the AI to retry successfully."""
|
||||
|
||||
# Entity not found errors
|
||||
if "Entity not found" in error_message or "entity not found" in error_message.lower():
|
||||
return f"""# Edit Failed - Note Not Found
|
||||
|
||||
The note with identifier '{identifier}' could not be found.
|
||||
|
||||
## Suggestions to try:
|
||||
1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes
|
||||
2. **Try different identifier formats**:
|
||||
- If you used a permalink like "folder/note-title", try just the title: "{identifier.split("/")[-1].replace("-", " ").title()}"
|
||||
- If you used a title, try the permalink format: "{identifier.lower().replace(" ", "-")}"
|
||||
- Use `read_note()` first to verify the note exists and get the correct identifiers
|
||||
|
||||
## Alternative approach:
|
||||
Use `write_note()` to create the note first, then edit it."""
|
||||
|
||||
# Find/replace specific errors
|
||||
if operation == "find_replace":
|
||||
if "Text to replace not found" in error_message:
|
||||
return f"""# Edit Failed - Text Not Found
|
||||
|
||||
The text '{find_text}' was not found in the note '{identifier}'.
|
||||
|
||||
## Suggestions to try:
|
||||
1. **Read the note first**: Use `read_note("{identifier}")` to see the current content
|
||||
2. **Check for exact matches**: The search is case-sensitive and must match exactly
|
||||
3. **Try a broader search**: Search for just part of the text you want to replace
|
||||
4. **Use expected_replacements=0**: If you want to verify the text doesn't exist
|
||||
|
||||
## Alternative approaches:
|
||||
- Use `append` or `prepend` to add new content instead
|
||||
- Use `replace_section` if you're trying to update a specific section"""
|
||||
|
||||
if "Expected" in error_message and "occurrences" in error_message:
|
||||
# Extract the actual count from error message if possible
|
||||
import re
|
||||
|
||||
match = re.search(r"found (\d+)", error_message)
|
||||
actual_count = match.group(1) if match else "a different number of"
|
||||
|
||||
return f"""# Edit Failed - Wrong Replacement Count
|
||||
|
||||
Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}.
|
||||
|
||||
## How to fix:
|
||||
1. **Read the note first**: Use `read_note("{identifier}")` to see how many times '{find_text}' appears
|
||||
2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call
|
||||
3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific
|
||||
|
||||
## Example:
|
||||
```
|
||||
edit_note("{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
|
||||
```"""
|
||||
|
||||
# Section replacement errors
|
||||
if operation == "replace_section" and "Multiple sections" in error_message:
|
||||
return f"""# Edit Failed - Duplicate Section Headers
|
||||
|
||||
Multiple sections found with the same header in note '{identifier}'.
|
||||
|
||||
## How to fix:
|
||||
1. **Read the note first**: Use `read_note("{identifier}")` to see the document structure
|
||||
2. **Make headers unique**: Add more specific text to distinguish sections
|
||||
3. **Use append instead**: Add content at the end rather than replacing a specific section
|
||||
|
||||
## Alternative approach:
|
||||
Use `find_replace` to update specific text within the duplicate sections."""
|
||||
|
||||
# Generic server/request errors
|
||||
if (
|
||||
"Invalid request" in error_message or "malformed" in error_message.lower()
|
||||
): # pragma: no cover
|
||||
return f"""# Edit Failed - Request Error
|
||||
|
||||
There was a problem with the edit request to note '{identifier}': {error_message}.
|
||||
|
||||
## Common causes and fixes:
|
||||
1. **Note doesn't exist**: Use `search_notes()` or `read_note()` to verify the note exists
|
||||
2. **Invalid identifier format**: Try different identifier formats (title vs permalink)
|
||||
3. **Empty or invalid content**: Check that your content is properly formatted
|
||||
4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state
|
||||
|
||||
## Troubleshooting steps:
|
||||
1. Verify the note exists: `read_note("{identifier}")`
|
||||
2. If not found, search for it: `search_notes("{identifier.split("/")[-1]}")`
|
||||
3. Try again with the correct identifier from the search results"""
|
||||
|
||||
# Fallback for other errors
|
||||
return f"""# Edit Failed
|
||||
|
||||
Error editing note '{identifier}': {error_message}
|
||||
|
||||
## General troubleshooting:
|
||||
1. **Verify the note exists**: Use `read_note("{identifier}")` to check
|
||||
2. **Check your parameters**: Ensure all required parameters are provided correctly
|
||||
3. **Read the note content first**: Use `read_note()` to understand the current structure
|
||||
4. **Try a simpler operation**: Start with `append` if other operations fail
|
||||
|
||||
## Need help?
|
||||
- Use `search_notes()` to find notes
|
||||
- Use `read_note()` to examine content before editing
|
||||
- Check that identifiers, section headers, and find_text match exactly"""
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
|
||||
)
|
||||
async def edit_note(
|
||||
identifier: str,
|
||||
operation: str,
|
||||
content: str,
|
||||
section: Optional[str] = None,
|
||||
find_text: Optional[str] = None,
|
||||
expected_replacements: int = 1,
|
||||
project: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Edit an existing markdown note in the knowledge base.
|
||||
|
||||
This tool allows you to make targeted changes to existing notes without rewriting the entire content.
|
||||
It supports various operations for different editing scenarios.
|
||||
|
||||
Args:
|
||||
identifier: The title, permalink, or memory:// URL of the note to edit
|
||||
operation: The editing operation to perform:
|
||||
- "append": Add content to the end of the note
|
||||
- "prepend": Add content to the beginning of the note
|
||||
- "find_replace": Replace occurrences of find_text with content
|
||||
- "replace_section": Replace content under a specific markdown header
|
||||
content: The content to add or use for replacement
|
||||
section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
|
||||
find_text: For find_replace operation - the text to find and replace
|
||||
expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
|
||||
project: Optional project name to delete from. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
A markdown formatted summary of the edit operation and resulting semantic content
|
||||
|
||||
Examples:
|
||||
# Add new content to end of note
|
||||
edit_note("project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
|
||||
|
||||
# Add timestamp at beginning (frontmatter-aware)
|
||||
edit_note("meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
|
||||
|
||||
# Update version number (single occurrence)
|
||||
edit_note("config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
|
||||
|
||||
# Update version in multiple places with validation
|
||||
edit_note("api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
|
||||
|
||||
# Replace text that appears multiple times - validate count first
|
||||
edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
|
||||
|
||||
# Replace implementation section
|
||||
edit_note("api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
|
||||
|
||||
# Replace subsection with more specific header
|
||||
edit_note("docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
|
||||
|
||||
# Using different identifier formats
|
||||
edit_note("Meeting Notes", "append", "\\n- Follow up on action items") # title
|
||||
edit_note("docs/meeting-notes", "append", "\\n- Follow up tasks") # permalink
|
||||
edit_note("docs/Meeting Notes", "append", "\\n- Next steps") # folder/title
|
||||
|
||||
# Add new section to document
|
||||
edit_note("project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
|
||||
|
||||
# Update status across document (expecting exactly 2 occurrences)
|
||||
edit_note("status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
|
||||
|
||||
# Replace text in a file, specifying project name
|
||||
edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", project="my-project"))
|
||||
|
||||
"""
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
|
||||
|
||||
# Validate operation
|
||||
valid_operations = ["append", "prepend", "find_replace", "replace_section"]
|
||||
if operation not in valid_operations:
|
||||
raise ValueError(
|
||||
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
|
||||
)
|
||||
|
||||
# Validate required parameters for specific operations
|
||||
if operation == "find_replace" and not find_text:
|
||||
raise ValueError("find_text parameter is required for find_replace operation")
|
||||
if operation == "replace_section" and not section:
|
||||
raise ValueError("section parameter is required for replace_section operation")
|
||||
|
||||
# Use the PATCH endpoint to edit the entity
|
||||
try:
|
||||
# Prepare the edit request data
|
||||
edit_data = {
|
||||
"operation": operation,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if section:
|
||||
edit_data["section"] = section
|
||||
if find_text:
|
||||
edit_data["find_text"] = find_text
|
||||
if expected_replacements != 1: # Only send if different from default
|
||||
edit_data["expected_replacements"] = str(expected_replacements)
|
||||
|
||||
# Call the PATCH endpoint
|
||||
url = f"{project_url}/knowledge/entities/{identifier}"
|
||||
response = await call_patch(client, url, json=edit_data)
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
|
||||
# Format summary
|
||||
summary = [
|
||||
f"# Edited note ({operation})",
|
||||
f"project: {active_project.name}",
|
||||
f"file_path: {result.file_path}",
|
||||
f"permalink: {result.permalink}",
|
||||
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
||||
]
|
||||
|
||||
# Add operation-specific details
|
||||
if operation == "append":
|
||||
lines_added = len(content.split("\n"))
|
||||
summary.append(f"operation: Added {lines_added} lines to end of note")
|
||||
elif operation == "prepend":
|
||||
lines_added = len(content.split("\n"))
|
||||
summary.append(f"operation: Added {lines_added} lines to beginning of note")
|
||||
elif operation == "find_replace":
|
||||
# For find_replace, we can't easily count replacements from here
|
||||
# since we don't have the original content, but the server handled it
|
||||
summary.append("operation: Find and replace operation completed")
|
||||
elif operation == "replace_section":
|
||||
summary.append(f"operation: Replaced content under section '{section}'")
|
||||
|
||||
# Count observations by category (reuse logic from write_note)
|
||||
categories = {}
|
||||
if result.observations:
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
|
||||
summary.append("\\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
|
||||
# Count resolved/unresolved relations
|
||||
unresolved = 0
|
||||
resolved = 0
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
|
||||
summary.append("\\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
|
||||
logger.info(
|
||||
"MCP tool response",
|
||||
tool="edit_note",
|
||||
operation=operation,
|
||||
permalink=result.permalink,
|
||||
observations_count=len(result.observations),
|
||||
relations_count=len(result.relations),
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
return "\n".join(summary)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing note: {e}")
|
||||
return _format_error_response(
|
||||
str(e), operation, identifier, find_text, expected_replacements
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""List directory tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="List directory contents with filtering and depth control.",
|
||||
)
|
||||
async def list_directory(
|
||||
dir_name: str = "/",
|
||||
depth: int = 1,
|
||||
file_name_glob: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
) -> str:
|
||||
"""List directory contents from the knowledge base with optional filtering.
|
||||
|
||||
This tool provides 'ls' functionality for browsing the knowledge base directory structure.
|
||||
It can list immediate children or recursively explore subdirectories with depth control,
|
||||
and supports glob pattern filtering for finding specific files.
|
||||
|
||||
Args:
|
||||
dir_name: Directory path to list (default: root "/")
|
||||
Examples: "/", "/projects", "/research/ml"
|
||||
depth: Recursion depth (1-10, default: 1 for immediate children only)
|
||||
Higher values show subdirectory contents recursively
|
||||
file_name_glob: Optional glob pattern for filtering file names
|
||||
Examples: "*.md", "*meeting*", "project_*"
|
||||
project: Optional project name to delete from. If not provided, uses current active project.
|
||||
Returns:
|
||||
Formatted listing of directory contents with file metadata
|
||||
|
||||
Examples:
|
||||
# List root directory contents
|
||||
list_directory()
|
||||
|
||||
# List specific folder
|
||||
list_directory(dir_name="/projects")
|
||||
|
||||
# Find all Python files
|
||||
list_directory(file_name_glob="*.py")
|
||||
|
||||
# Deep exploration of research folder
|
||||
list_directory(dir_name="/research", depth=3)
|
||||
|
||||
# Find meeting notes in projects folder
|
||||
list_directory(dir_name="/projects", file_name_glob="*meeting*")
|
||||
|
||||
# Find meeting notes in a specific project
|
||||
list_directory(dir_name="/projects", file_name_glob="*meeting*", project="work-project")
|
||||
"""
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Prepare query parameters
|
||||
params = {
|
||||
"dir_name": dir_name,
|
||||
"depth": str(depth),
|
||||
}
|
||||
if file_name_glob:
|
||||
params["file_name_glob"] = file_name_glob
|
||||
|
||||
logger.debug(f"Listing directory '{dir_name}' with depth={depth}, glob='{file_name_glob}'")
|
||||
|
||||
# Call the API endpoint
|
||||
response = await call_get(
|
||||
client,
|
||||
f"{project_url}/directory/list",
|
||||
params=params,
|
||||
)
|
||||
|
||||
nodes = response.json()
|
||||
|
||||
if not nodes:
|
||||
filter_desc = ""
|
||||
if file_name_glob:
|
||||
filter_desc = f" matching '{file_name_glob}'"
|
||||
return f"No files found in directory '{dir_name}'{filter_desc}"
|
||||
|
||||
# Format the results
|
||||
output_lines = []
|
||||
if file_name_glob:
|
||||
output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):")
|
||||
else:
|
||||
output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
|
||||
output_lines.append("")
|
||||
|
||||
# Group by type and sort
|
||||
directories = [n for n in nodes if n["type"] == "directory"]
|
||||
files = [n for n in nodes if n["type"] == "file"]
|
||||
|
||||
# Sort by name
|
||||
directories.sort(key=lambda x: x["name"])
|
||||
files.sort(key=lambda x: x["name"])
|
||||
|
||||
# Display directories first
|
||||
for node in directories:
|
||||
path_display = node["directory_path"]
|
||||
output_lines.append(f"📁 {node['name']:<30} {path_display}")
|
||||
|
||||
# Add separator if we have both directories and files
|
||||
if directories and files:
|
||||
output_lines.append("")
|
||||
|
||||
# Display files with metadata
|
||||
for node in files:
|
||||
path_display = node["directory_path"]
|
||||
title = node.get("title", "")
|
||||
updated = node.get("updated_at", "")
|
||||
|
||||
# Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
|
||||
if path_display.startswith("/"):
|
||||
path_display = path_display[1:]
|
||||
|
||||
# Format date if available
|
||||
date_str = ""
|
||||
if updated:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
||||
date_str = dt.strftime("%Y-%m-%d")
|
||||
except Exception: # pragma: no cover
|
||||
date_str = updated[:10] if len(updated) >= 10 else ""
|
||||
|
||||
# Create formatted line
|
||||
file_line = f"📄 {node['name']:<30} {path_display}"
|
||||
if title and title != node["name"]:
|
||||
file_line += f" | {title}"
|
||||
if date_str:
|
||||
file_line += f" | {date_str}"
|
||||
|
||||
output_lines.append(file_line)
|
||||
|
||||
# Add summary
|
||||
output_lines.append("")
|
||||
total_count = len(directories) + len(files)
|
||||
summary_parts = []
|
||||
if directories:
|
||||
summary_parts.append(
|
||||
f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
|
||||
)
|
||||
if files:
|
||||
summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
|
||||
|
||||
output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
|
||||
|
||||
return "\n".join(output_lines)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Move note tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas import EntityResponse
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Move a note to a new location, updating database and maintaining links.",
|
||||
)
|
||||
async def move_note(
|
||||
identifier: str,
|
||||
destination_path: str,
|
||||
project: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Move a note to a new file location within the same project.
|
||||
|
||||
Args:
|
||||
identifier: Entity identifier (title, permalink, or memory:// URL)
|
||||
destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
|
||||
project: Optional project name (defaults to current session project)
|
||||
|
||||
Returns:
|
||||
Success message with move details
|
||||
|
||||
Examples:
|
||||
- Move to new folder: move_note("My Note", "work/notes/my-note.md")
|
||||
- Move by permalink: move_note("my-note-permalink", "archive/old-notes/my-note.md")
|
||||
- Specify project: move_note("My Note", "archive/my-note.md", project="work-project")
|
||||
|
||||
Note: This operation moves notes within the specified project only. Moving notes
|
||||
between different projects is not currently supported.
|
||||
|
||||
The move operation:
|
||||
- Updates the entity's file_path in the database
|
||||
- Moves the physical file on the filesystem
|
||||
- Optionally updates permalinks if configured
|
||||
- Re-indexes the entity for search
|
||||
- Maintains all observations and relations
|
||||
"""
|
||||
logger.debug(f"Moving note: {identifier} to {destination_path}")
|
||||
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Prepare move request
|
||||
move_data = {
|
||||
"identifier": identifier,
|
||||
"destination_path": destination_path,
|
||||
"project": active_project.name,
|
||||
}
|
||||
|
||||
# Call the move API endpoint
|
||||
url = f"{project_url}/knowledge/move"
|
||||
response = await call_post(client, url, json=move_data)
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
|
||||
# 10. Build success message
|
||||
result_lines = [
|
||||
"✅ Note moved successfully",
|
||||
"",
|
||||
f"📁 **{identifier}** → **{result.file_path}**",
|
||||
f"🔗 Permalink: {result.permalink}",
|
||||
"📊 Database and search index updated",
|
||||
"",
|
||||
f"<!-- Project: {active_project.name} -->",
|
||||
]
|
||||
|
||||
# Return the response text which contains the formatted success message
|
||||
result = "\n".join(result_lines)
|
||||
|
||||
# Log the operation
|
||||
logger.info(
|
||||
"Move note completed",
|
||||
identifier=identifier,
|
||||
destination_path=destination_path,
|
||||
project=active_project.name,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Project management tools for Basic Memory MCP server.
|
||||
|
||||
These tools allow users to switch between projects, list available projects,
|
||||
and manage project context during conversations.
|
||||
"""
|
||||
|
||||
from fastmcp import Context
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.project_session import session, add_project_metadata
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get, call_put, call_post, call_delete
|
||||
from basic_memory.schemas import ProjectInfoResponse
|
||||
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse, ProjectInfoRequest
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_projects(ctx: Context | None = None) -> str:
|
||||
"""List all available projects with their status.
|
||||
|
||||
Shows all Basic Memory projects that are available, indicating which one
|
||||
is currently active and which is the default.
|
||||
|
||||
Returns:
|
||||
Formatted list of projects with status indicators
|
||||
|
||||
Example:
|
||||
list_projects()
|
||||
"""
|
||||
if ctx: # pragma: no cover
|
||||
await ctx.info("Listing all available projects")
|
||||
|
||||
# Get projects from API
|
||||
response = await call_get(client, "/projects/projects")
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
|
||||
current = session.get_current_project()
|
||||
|
||||
result = "Available projects:\n"
|
||||
|
||||
for project in project_list.projects:
|
||||
indicators = []
|
||||
if project.name == current:
|
||||
indicators.append("current")
|
||||
if project.is_default:
|
||||
indicators.append("default")
|
||||
|
||||
if indicators:
|
||||
result += f"• {project.name} ({', '.join(indicators)})\n"
|
||||
else:
|
||||
result += f"• {project.name}\n"
|
||||
|
||||
return add_project_metadata(result, current)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def switch_project(project_name: str, ctx: Context | None = None) -> str:
|
||||
"""Switch to a different project context.
|
||||
|
||||
Changes the active project context for all subsequent tool calls.
|
||||
Shows a project summary after switching successfully.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to switch to
|
||||
|
||||
Returns:
|
||||
Confirmation message with project summary
|
||||
|
||||
Example:
|
||||
switch_project("work-notes")
|
||||
switch_project("personal-journal")
|
||||
"""
|
||||
if ctx: # pragma: no cover
|
||||
await ctx.info(f"Switching to project: {project_name}")
|
||||
|
||||
current_project = session.get_current_project()
|
||||
try:
|
||||
# Validate project exists by getting project list
|
||||
response = await call_get(client, "/projects/projects")
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
|
||||
# Check if project exists
|
||||
project_exists = any(p.name == project_name for p in project_list.projects)
|
||||
if not project_exists:
|
||||
available_projects = [p.name for p in project_list.projects]
|
||||
return f"Error: Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
|
||||
|
||||
# Switch to the project
|
||||
session.set_current_project(project_name)
|
||||
current_project = session.get_current_project()
|
||||
project_config = get_project_config(current_project)
|
||||
|
||||
# Get project info to show summary
|
||||
try:
|
||||
response = await call_get(client, f"{project_config.project_url}/project/info")
|
||||
project_info = ProjectInfoResponse.model_validate(response.json())
|
||||
|
||||
result = f"✓ Switched to {project_name} project\n\n"
|
||||
result += "Project Summary:\n"
|
||||
result += f"• {project_info.statistics.total_entities} entities\n"
|
||||
result += f"• {project_info.statistics.total_observations} observations\n"
|
||||
result += f"• {project_info.statistics.total_relations} relations\n"
|
||||
|
||||
except Exception as e:
|
||||
# If we can't get project info, still confirm the switch
|
||||
logger.warning(f"Could not get project info for {project_name}: {e}")
|
||||
result = f"✓ Switched to {project_name} project\n\n"
|
||||
result += "Project summary unavailable.\n"
|
||||
|
||||
return add_project_metadata(result, project_name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error switching to project {project_name}: {e}")
|
||||
# Revert to previous project on error
|
||||
session.set_current_project(current_project)
|
||||
raise e
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_current_project(ctx: Context | None = None) -> str:
|
||||
"""Show the currently active project and basic stats.
|
||||
|
||||
Displays which project is currently active and provides basic information
|
||||
about it.
|
||||
|
||||
Returns:
|
||||
Current project name and basic statistics
|
||||
|
||||
Example:
|
||||
get_current_project()
|
||||
"""
|
||||
if ctx: # pragma: no cover
|
||||
await ctx.info("Getting current project information")
|
||||
|
||||
current_project = session.get_current_project()
|
||||
project_config = get_project_config(current_project)
|
||||
result = f"Current project: {current_project}\n\n"
|
||||
|
||||
# get project stats
|
||||
response = await call_get(client, f"{project_config.project_url}/project/info")
|
||||
project_info = ProjectInfoResponse.model_validate(response.json())
|
||||
|
||||
result += f"• {project_info.statistics.total_entities} entities\n"
|
||||
result += f"• {project_info.statistics.total_observations} observations\n"
|
||||
result += f"• {project_info.statistics.total_relations} relations\n"
|
||||
|
||||
default_project = session.get_default_project()
|
||||
if current_project != default_project:
|
||||
result += f"• Default project: {default_project}\n"
|
||||
|
||||
return add_project_metadata(result, current_project)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def set_default_project(project_name: str, ctx: Context | None = None) -> str:
|
||||
"""Set default project in config. Requires restart to take effect.
|
||||
|
||||
Updates the configuration to use a different default project. This change
|
||||
only takes effect after restarting the Basic Memory server.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to set as default
|
||||
|
||||
Returns:
|
||||
Confirmation message about config update
|
||||
|
||||
Example:
|
||||
set_default_project("work-notes")
|
||||
"""
|
||||
if ctx: # pragma: no cover
|
||||
await ctx.info(f"Setting default project to: {project_name}")
|
||||
|
||||
# Call API to set default project
|
||||
response = await call_put(client, f"/projects/{project_name}/default")
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
result = f"✓ {status_response.message}\n\n"
|
||||
result += "Restart Basic Memory for this change to take effect:\n"
|
||||
result += "basic-memory mcp\n"
|
||||
|
||||
if status_response.old_project:
|
||||
result += f"\nPrevious default: {status_response.old_project.name}\n"
|
||||
|
||||
return add_project_metadata(result, session.get_current_project())
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def create_project(
|
||||
project_name: str, project_path: str, set_default: bool = False, ctx: Context | None = None
|
||||
) -> str:
|
||||
"""Create a new Basic Memory project.
|
||||
|
||||
Creates a new project with the specified name and path. The project directory
|
||||
will be created if it doesn't exist. Optionally sets the new project as default.
|
||||
|
||||
Args:
|
||||
project_name: Name for the new project (must be unique)
|
||||
project_path: File system path where the project will be stored
|
||||
set_default: Whether to set this project as the default (optional, defaults to False)
|
||||
|
||||
Returns:
|
||||
Confirmation message with project details
|
||||
|
||||
Example:
|
||||
create_project("my-research", "~/Documents/research")
|
||||
create_project("work-notes", "/home/user/work", set_default=True)
|
||||
"""
|
||||
if ctx: # pragma: no cover
|
||||
await ctx.info(f"Creating project: {project_name} at {project_path}")
|
||||
|
||||
# Create the project request
|
||||
project_request = ProjectInfoRequest(
|
||||
name=project_name, path=project_path, set_default=set_default
|
||||
)
|
||||
|
||||
# Call API to create project
|
||||
response = await call_post(client, "/projects/projects", json=project_request.model_dump())
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
result = f"✓ {status_response.message}\n\n"
|
||||
|
||||
if status_response.new_project:
|
||||
result += "Project Details:\n"
|
||||
result += f"• Name: {status_response.new_project.name}\n"
|
||||
result += f"• Path: {status_response.new_project.path}\n"
|
||||
|
||||
if set_default:
|
||||
result += "• Set as default project\n"
|
||||
|
||||
result += "\nProject is now available for use.\n"
|
||||
|
||||
# If project was set as default, update session
|
||||
if set_default:
|
||||
session.set_current_project(project_name)
|
||||
|
||||
return add_project_metadata(result, session.get_current_project())
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_project(project_name: str, ctx: Context | None = None) -> str:
|
||||
"""Delete a Basic Memory project.
|
||||
|
||||
Removes a project from the configuration and database. This does NOT delete
|
||||
the actual files on disk - only removes the project from Basic Memory's
|
||||
configuration and database records.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to delete
|
||||
|
||||
Returns:
|
||||
Confirmation message about project deletion
|
||||
|
||||
Example:
|
||||
delete_project("old-project")
|
||||
|
||||
Warning:
|
||||
This action cannot be undone. The project will need to be re-added
|
||||
to access its content through Basic Memory again.
|
||||
"""
|
||||
if ctx: # pragma: no cover
|
||||
await ctx.info(f"Deleting project: {project_name}")
|
||||
|
||||
current_project = session.get_current_project()
|
||||
|
||||
# Check if trying to delete current project
|
||||
if project_name == current_project:
|
||||
raise ValueError(
|
||||
f"Cannot delete the currently active project '{project_name}'. Switch to a different project first."
|
||||
)
|
||||
|
||||
# Get project info before deletion to validate it exists
|
||||
response = await call_get(client, "/projects/projects")
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
|
||||
# Check if project exists
|
||||
project_exists = any(p.name == project_name for p in project_list.projects)
|
||||
if not project_exists:
|
||||
available_projects = [p.name for p in project_list.projects]
|
||||
raise ValueError(
|
||||
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
|
||||
)
|
||||
|
||||
# Call API to delete project
|
||||
response = await call_delete(client, f"/projects/{project_name}")
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
result = f"✓ {status_response.message}\n\n"
|
||||
|
||||
if status_response.old_project:
|
||||
result += "Removed project details:\n"
|
||||
result += f"• Name: {status_response.old_project.name}\n"
|
||||
if hasattr(status_response.old_project, "path"):
|
||||
result += f"• Path: {status_response.old_project.path}\n"
|
||||
|
||||
result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
|
||||
result += "Re-add the project to access its content again.\n"
|
||||
|
||||
return add_project_metadata(result, session.get_current_project())
|
||||
@@ -5,17 +5,19 @@ supporting various file types including text, images, and other binary files.
|
||||
Files are read directly without any knowledge graph processing.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import base64
|
||||
import io
|
||||
|
||||
from loguru import logger
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image as PILImage
|
||||
|
||||
|
||||
def calculate_target_params(content_length):
|
||||
"""Calculate initial quality and size based on input file size"""
|
||||
@@ -144,7 +146,7 @@ def optimize_image(img, content_length, max_output_bytes=350000):
|
||||
|
||||
|
||||
@mcp.tool(description="Read a file's raw content by path or permalink")
|
||||
async def read_content(path: str) -> dict:
|
||||
async def read_content(path: str, project: Optional[str] = None) -> dict:
|
||||
"""Read a file's raw content by path or permalink.
|
||||
|
||||
This tool provides direct access to file content in the knowledge base,
|
||||
@@ -158,6 +160,7 @@ async def read_content(path: str) -> dict:
|
||||
- A regular file path (docs/example.md)
|
||||
- A memory URL (memory://docs/example)
|
||||
- A permalink (docs/example)
|
||||
project: Optional project name to read from. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
A dictionary with the file content and metadata:
|
||||
@@ -175,11 +178,17 @@ async def read_content(path: str) -> dict:
|
||||
|
||||
# Read using memory URL
|
||||
content = await read_file("memory://docs/architecture")
|
||||
|
||||
# Read from specific project
|
||||
content = await read_content("docs/example.md", project="work-project")
|
||||
"""
|
||||
logger.info("Reading file", path=path)
|
||||
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
url = memory_url_path(path)
|
||||
response = await call_get(client, f"/resource/{url}")
|
||||
response = await call_get(client, f"{project_url}/resource/{url}")
|
||||
content_type = response.headers.get("content-type", "application/octet-stream")
|
||||
content_length = int(response.headers.get("content-length", 0))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Read note tool for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -8,13 +9,16 @@ from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Read a markdown note by title or permalink.",
|
||||
)
|
||||
async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
|
||||
async def read_note(
|
||||
identifier: str, page: int = 1, page_size: int = 10, project: Optional[str] = None
|
||||
) -> str:
|
||||
"""Read a markdown note from the knowledge base.
|
||||
|
||||
This tool finds and retrieves a note by its title, permalink, or content search,
|
||||
@@ -26,6 +30,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
|
||||
Can be a full memory:// URL, a permalink, a title, or search text
|
||||
page: Page number for paginated results (default: 1)
|
||||
page_size: Number of items per page (default: 10)
|
||||
project: Optional project name to read from. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
The full markdown content of the note if found, or helpful guidance if not found.
|
||||
@@ -42,10 +47,17 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
|
||||
|
||||
# Read with pagination
|
||||
read_note("Project Updates", page=2, page_size=5)
|
||||
|
||||
# Read from specific project
|
||||
read_note("Meeting Notes", project="work-project")
|
||||
"""
|
||||
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Get the file via REST API - first try direct permalink lookup
|
||||
entity_path = memory_url_path(identifier)
|
||||
path = f"/resource/{entity_path}"
|
||||
path = f"{project_url}/resource/{entity_path}"
|
||||
logger.info(f"Attempting to read note from URL: {path}")
|
||||
|
||||
try:
|
||||
@@ -62,14 +74,14 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
|
||||
|
||||
# Fallback 1: Try title search via API
|
||||
logger.info(f"Search title for: {identifier}")
|
||||
title_results = await search_notes(query=identifier, search_type="title")
|
||||
title_results = await search_notes(query=identifier, search_type="title", project=project)
|
||||
|
||||
if title_results and title_results.results:
|
||||
result = title_results.results[0] # Get the first/best match
|
||||
if result.permalink:
|
||||
try:
|
||||
# Try to fetch the content using the found permalink
|
||||
path = f"/resource/{result.permalink}"
|
||||
path = f"{project_url}/resource/{result.permalink}"
|
||||
response = await call_get(
|
||||
client, path, params={"page": page, "page_size": page_size}
|
||||
)
|
||||
@@ -86,7 +98,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
|
||||
|
||||
# Fallback 2: Text search as a last resort
|
||||
logger.info(f"Title search failed, trying text search for: {identifier}")
|
||||
text_results = await search_notes(query=identifier, search_type="text")
|
||||
text_results = await search_notes(query=identifier, search_type="text", project=project)
|
||||
|
||||
# We didn't find a direct match, construct a helpful error message
|
||||
if not text_results or not text_results.results:
|
||||
@@ -111,7 +123,7 @@ def format_not_found_message(identifier: str) -> str:
|
||||
## Search Instead
|
||||
Try searching for related content:
|
||||
```
|
||||
search(query="{identifier}")
|
||||
search_notes(query="{identifier}")
|
||||
```
|
||||
|
||||
## Recent Activity
|
||||
@@ -172,7 +184,7 @@ def format_related_results(identifier: str, results) -> str:
|
||||
## Search For More Results
|
||||
To see more related content:
|
||||
```
|
||||
search(query="{identifier}")
|
||||
search_notes(query="{identifier}")
|
||||
```
|
||||
|
||||
## Create New Note
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Recent activity tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional, List
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import GraphContext
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
@@ -14,7 +15,7 @@ from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
@mcp.tool(
|
||||
description="""Get recent activity from across the knowledge base.
|
||||
|
||||
|
||||
Timeframe supports natural language formats like:
|
||||
- "2 days ago"
|
||||
- "last week"
|
||||
@@ -25,21 +26,25 @@ from basic_memory.schemas.search import SearchItemType
|
||||
""",
|
||||
)
|
||||
async def recent_activity(
|
||||
type: Optional[List[SearchItemType]] = None,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
type: Union[str, List[str]] = "",
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
project: Optional[str] = None,
|
||||
) -> GraphContext:
|
||||
"""Get recent activity across the knowledge base.
|
||||
|
||||
Args:
|
||||
type: Filter by content type(s). Valid options:
|
||||
- ["entity"] for knowledge entities
|
||||
- ["relation"] for connections between entities
|
||||
- ["observation"] for notes and observations
|
||||
type: Filter by content type(s). Can be a string or list of strings.
|
||||
Valid options:
|
||||
- "entity" or ["entity"] for knowledge entities
|
||||
- "relation" or ["relation"] for connections between entities
|
||||
- "observation" or ["observation"] for notes and observations
|
||||
Multiple types can be combined: ["entity", "relation"]
|
||||
Case-insensitive: "ENTITY" and "entity" are treated the same.
|
||||
Default is an empty string, which returns all types.
|
||||
depth: How many relation hops to traverse (1-3 recommended)
|
||||
timeframe: Time window to search. Supports natural language:
|
||||
- Relative: "2 days ago", "last week", "yesterday"
|
||||
@@ -48,6 +53,7 @@ async def recent_activity(
|
||||
page: Page number of results to return (default: 1)
|
||||
page_size: Number of results to return per page (default: 10)
|
||||
max_related: Maximum number of related results to return (default: 10)
|
||||
project: Optional project name to get activity from. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
@@ -59,14 +65,20 @@ async def recent_activity(
|
||||
# Get all entities for the last 10 days (default)
|
||||
recent_activity()
|
||||
|
||||
# Get all entities from yesterday
|
||||
# Get all entities from yesterday (string format)
|
||||
recent_activity(type="entity", timeframe="yesterday")
|
||||
|
||||
# Get all entities from yesterday (list format)
|
||||
recent_activity(type=["entity"], timeframe="yesterday")
|
||||
|
||||
# Get recent relations and observations
|
||||
recent_activity(type=["relation", "observation"], timeframe="today")
|
||||
|
||||
# Look back further with more context
|
||||
recent_activity(type=["entity"], depth=2, timeframe="2 weeks ago")
|
||||
recent_activity(type="entity", depth=2, timeframe="2 weeks ago")
|
||||
|
||||
# Get activity from specific project
|
||||
recent_activity(type="entity", project="work-project")
|
||||
|
||||
Notes:
|
||||
- Higher depth values (>3) may impact performance with large result sets
|
||||
@@ -86,15 +98,34 @@ async def recent_activity(
|
||||
if timeframe:
|
||||
params["timeframe"] = timeframe # pyright: ignore
|
||||
|
||||
# send enum values if we have an enum, else send string value
|
||||
# Validate and convert type parameter
|
||||
if type:
|
||||
params["type"] = [ # pyright: ignore
|
||||
type.value if isinstance(type, SearchItemType) else type for type in type
|
||||
]
|
||||
# Convert single string to list
|
||||
if isinstance(type, str):
|
||||
type_list = [type]
|
||||
else:
|
||||
type_list = type
|
||||
|
||||
# Validate each type against SearchItemType enum
|
||||
validated_types = []
|
||||
for t in type_list:
|
||||
try:
|
||||
# Try to convert string to enum
|
||||
if isinstance(t, str):
|
||||
validated_types.append(SearchItemType(t.lower()))
|
||||
except ValueError:
|
||||
valid_types = [t.value for t in SearchItemType]
|
||||
raise ValueError(f"Invalid type: {t}. Valid types are: {valid_types}")
|
||||
|
||||
# Add validated types to params
|
||||
params["type"] = [t.value for t in validated_types] # pyright: ignore
|
||||
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
response = await call_get(
|
||||
client,
|
||||
"/memory/recent",
|
||||
f"{project_url}/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
@@ -7,6 +7,7 @@ from loguru import logger
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
|
||||
|
||||
|
||||
@@ -21,6 +22,7 @@ async def search_notes(
|
||||
types: Optional[List[str]] = None,
|
||||
entity_types: Optional[List[str]] = None,
|
||||
after_date: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
) -> SearchResponse:
|
||||
"""Search across all content in the knowledge base.
|
||||
|
||||
@@ -36,6 +38,7 @@ async def search_notes(
|
||||
types: Optional list of note types to search (e.g., ["note", "person"])
|
||||
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
|
||||
after_date: Optional date filter for recent content (e.g., "1 week", "2d")
|
||||
project: Optional project name to search in. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
SearchResponse with results and pagination info
|
||||
@@ -79,6 +82,9 @@ async def search_notes(
|
||||
query="docs/meeting-*",
|
||||
search_type="permalink"
|
||||
)
|
||||
|
||||
# Search in specific project
|
||||
results = await search_notes("meeting notes", project="work-project")
|
||||
"""
|
||||
# Create a SearchQuery object based on the parameters
|
||||
search_query = SearchQuery()
|
||||
@@ -103,10 +109,13 @@ async def search_notes(
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
logger.info(f"Searching for {search_query}")
|
||||
response = await call_post(
|
||||
client,
|
||||
"/search/",
|
||||
f"{project_url}/search/",
|
||||
json=search_query.model_dump(),
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ to the Basic Memory API, with improved error handling and logging.
|
||||
"""
|
||||
|
||||
import typing
|
||||
from typing import Optional
|
||||
|
||||
from httpx import Response, URL, AsyncClient, HTTPStatusError
|
||||
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
|
||||
@@ -23,7 +24,9 @@ from loguru import logger
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
|
||||
def get_error_message(status_code: int, url: URL | str, method: str) -> str:
|
||||
def get_error_message(
|
||||
status_code: int, url: URL | str, method: str, msg: Optional[str] = None
|
||||
) -> str:
|
||||
"""Get a friendly error message based on the HTTP status code.
|
||||
|
||||
Args:
|
||||
@@ -103,6 +106,7 @@ async def call_get(
|
||||
ToolError: If the request fails with an appropriate error message
|
||||
"""
|
||||
logger.debug(f"Calling GET '{url}' params: '{params}'")
|
||||
error_message = None
|
||||
try:
|
||||
response = await client.get(
|
||||
url,
|
||||
@@ -120,7 +124,12 @@ async def call_get(
|
||||
|
||||
# Handle different status codes differently
|
||||
status_code = response.status_code
|
||||
error_message = get_error_message(status_code, url, "GET")
|
||||
# get the message if available
|
||||
response_data = response.json()
|
||||
if isinstance(response_data, dict) and "detail" in response_data:
|
||||
error_message = response_data["detail"]
|
||||
else:
|
||||
error_message = get_error_message(status_code, url, "PUT")
|
||||
|
||||
# Log at appropriate level based on status code
|
||||
if 400 <= status_code < 500:
|
||||
@@ -138,8 +147,6 @@ async def call_get(
|
||||
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
|
||||
|
||||
except HTTPStatusError as e:
|
||||
status_code = e.response.status_code
|
||||
error_message = get_error_message(status_code, url, "GET")
|
||||
raise ToolError(error_message) from e
|
||||
|
||||
|
||||
@@ -183,6 +190,8 @@ async def call_put(
|
||||
ToolError: If the request fails with an appropriate error message
|
||||
"""
|
||||
logger.debug(f"Calling PUT '{url}'")
|
||||
error_message = None
|
||||
|
||||
try:
|
||||
response = await client.put(
|
||||
url,
|
||||
@@ -204,7 +213,13 @@ async def call_put(
|
||||
|
||||
# Handle different status codes differently
|
||||
status_code = response.status_code
|
||||
error_message = get_error_message(status_code, url, "PUT")
|
||||
|
||||
# get the message if available
|
||||
response_data = response.json()
|
||||
if isinstance(response_data, dict) and "detail" in response_data:
|
||||
error_message = response_data["detail"] # pragma: no cover
|
||||
else:
|
||||
error_message = get_error_message(status_code, url, "PUT")
|
||||
|
||||
# Log at appropriate level based on status code
|
||||
if 400 <= status_code < 500:
|
||||
@@ -221,9 +236,110 @@ async def call_put(
|
||||
response.raise_for_status() # Will always raise since we're in the error case
|
||||
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
|
||||
|
||||
except HTTPStatusError as e:
|
||||
raise ToolError(error_message) from e
|
||||
|
||||
|
||||
async def call_patch(
|
||||
client: AsyncClient,
|
||||
url: URL | str,
|
||||
*,
|
||||
content: RequestContent | None = None,
|
||||
data: RequestData | None = None,
|
||||
files: RequestFiles | None = None,
|
||||
json: typing.Any | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
cookies: CookieTypes | None = None,
|
||||
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
||||
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
|
||||
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
||||
extensions: RequestExtensions | None = None,
|
||||
) -> Response:
|
||||
"""Make a PATCH request and handle errors appropriately.
|
||||
|
||||
Args:
|
||||
client: The HTTPX AsyncClient to use
|
||||
url: The URL to request
|
||||
content: Request content
|
||||
data: Form data
|
||||
files: Files to upload
|
||||
json: JSON data
|
||||
params: Query parameters
|
||||
headers: HTTP headers
|
||||
cookies: HTTP cookies
|
||||
auth: Authentication
|
||||
follow_redirects: Whether to follow redirects
|
||||
timeout: Request timeout
|
||||
extensions: HTTPX extensions
|
||||
|
||||
Returns:
|
||||
The HTTP response
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails with an appropriate error message
|
||||
"""
|
||||
logger.debug(f"Calling PATCH '{url}'")
|
||||
try:
|
||||
response = await client.patch(
|
||||
url,
|
||||
content=content,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
|
||||
# Handle different status codes differently
|
||||
status_code = response.status_code
|
||||
|
||||
# Try to extract specific error message from response body
|
||||
try:
|
||||
response_data = response.json()
|
||||
if isinstance(response_data, dict) and "detail" in response_data:
|
||||
error_message = response_data["detail"]
|
||||
else:
|
||||
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
|
||||
except Exception: # pragma: no cover
|
||||
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
|
||||
|
||||
# Log at appropriate level based on status code
|
||||
if 400 <= status_code < 500:
|
||||
# Client errors: log as info except for 429 (Too Many Requests)
|
||||
if status_code == 429: # pragma: no cover
|
||||
logger.warning(f"Rate limit exceeded: PATCH {url}: {error_message}")
|
||||
else:
|
||||
logger.info(f"Client error: PATCH {url}: {error_message}")
|
||||
else: # pragma: no cover
|
||||
# Server errors: log as error
|
||||
logger.error(f"Server error: PATCH {url}: {error_message}") # pragma: no cover
|
||||
|
||||
# Raise a tool error with the friendly message
|
||||
response.raise_for_status() # Will always raise since we're in the error case
|
||||
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
|
||||
|
||||
except HTTPStatusError as e:
|
||||
status_code = e.response.status_code
|
||||
error_message = get_error_message(status_code, url, "PUT")
|
||||
|
||||
# Try to extract specific error message from response body
|
||||
try:
|
||||
response_data = e.response.json()
|
||||
if isinstance(response_data, dict) and "detail" in response_data:
|
||||
error_message = response_data["detail"]
|
||||
else:
|
||||
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
|
||||
except Exception: # pragma: no cover
|
||||
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
|
||||
|
||||
raise ToolError(error_message) from e
|
||||
|
||||
|
||||
@@ -267,6 +383,7 @@ async def call_post(
|
||||
ToolError: If the request fails with an appropriate error message
|
||||
"""
|
||||
logger.debug(f"Calling POST '{url}'")
|
||||
error_message = None
|
||||
try:
|
||||
response = await client.post(
|
||||
url=url,
|
||||
@@ -282,13 +399,19 @@ async def call_post(
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
logger.debug(f"response: {response.json()}")
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
|
||||
# Handle different status codes differently
|
||||
status_code = response.status_code
|
||||
error_message = get_error_message(status_code, url, "POST")
|
||||
# get the message if available
|
||||
response_data = response.json()
|
||||
if isinstance(response_data, dict) and "detail" in response_data:
|
||||
error_message = response_data["detail"]
|
||||
else:
|
||||
error_message = get_error_message(status_code, url, "POST")
|
||||
|
||||
# Log at appropriate level based on status code
|
||||
if 400 <= status_code < 500:
|
||||
@@ -306,8 +429,6 @@ async def call_post(
|
||||
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
|
||||
|
||||
except HTTPStatusError as e:
|
||||
status_code = e.response.status_code
|
||||
error_message = get_error_message(status_code, url, "POST")
|
||||
raise ToolError(error_message) from e
|
||||
|
||||
|
||||
@@ -343,6 +464,7 @@ async def call_delete(
|
||||
ToolError: If the request fails with an appropriate error message
|
||||
"""
|
||||
logger.debug(f"Calling DELETE '{url}'")
|
||||
error_message = None
|
||||
try:
|
||||
response = await client.delete(
|
||||
url=url,
|
||||
@@ -360,7 +482,12 @@ async def call_delete(
|
||||
|
||||
# Handle different status codes differently
|
||||
status_code = response.status_code
|
||||
error_message = get_error_message(status_code, url, "DELETE")
|
||||
# get the message if available
|
||||
response_data = response.json()
|
||||
if isinstance(response_data, dict) and "detail" in response_data:
|
||||
error_message = response_data["detail"] # pragma: no cover
|
||||
else:
|
||||
error_message = get_error_message(status_code, url, "DELETE")
|
||||
|
||||
# Log at appropriate level based on status code
|
||||
if 400 <= status_code < 500:
|
||||
@@ -378,6 +505,4 @@ async def call_delete(
|
||||
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
|
||||
|
||||
except HTTPStatusError as e:
|
||||
status_code = e.response.status_code
|
||||
error_message = get_error_message(status_code, url, "DELETE")
|
||||
raise ToolError(error_message) from e
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Write note tool for Basic Memory MCP server."""
|
||||
|
||||
from typing import List, Union
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_put
|
||||
from basic_memory.mcp.project_session import get_active_project
|
||||
from basic_memory.schemas import EntityResponse
|
||||
from basic_memory.schemas.base import Entity
|
||||
from basic_memory.utils import parse_tags
|
||||
@@ -26,6 +27,7 @@ async def write_note(
|
||||
content: str,
|
||||
folder: str,
|
||||
tags=None, # Remove type hint completely to avoid schema issues
|
||||
project: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Write a markdown note to the knowledge base.
|
||||
|
||||
@@ -55,6 +57,7 @@ async def write_note(
|
||||
folder: the folder where the file should be saved
|
||||
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
|
||||
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
|
||||
project: Optional project name to write to. If not provided, uses current active project.
|
||||
|
||||
Returns:
|
||||
A markdown formatted summary of the semantic content, including:
|
||||
@@ -64,12 +67,12 @@ async def write_note(
|
||||
- Relation counts (resolved/unresolved)
|
||||
- Tags if present
|
||||
"""
|
||||
logger.info("MCP tool call", tool="write_note", folder=folder, title=title, tags=tags)
|
||||
logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")
|
||||
|
||||
# Process tags using the helper function
|
||||
tag_list = parse_tags(tags)
|
||||
# Create the entity request
|
||||
metadata = {"tags": [f"#{tag}" for tag in tag_list]} if tag_list else None
|
||||
metadata = {"tags": tag_list} if tag_list else None
|
||||
entity = Entity(
|
||||
title=title,
|
||||
folder=folder,
|
||||
@@ -78,10 +81,12 @@ async def write_note(
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
active_project = get_active_project(project)
|
||||
project_url = active_project.project_url
|
||||
|
||||
# Create or update via knowledge API
|
||||
logger.debug("Creating entity via API", permalink=entity.permalink)
|
||||
url = f"/knowledge/entities/{entity.permalink}"
|
||||
logger.debug(f"Creating entity via API permalink={entity.permalink}")
|
||||
url = f"{project_url}/knowledge/entities/{entity.permalink}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
|
||||
@@ -122,15 +127,6 @@ async def write_note(
|
||||
|
||||
# Log the response with structured data
|
||||
logger.info(
|
||||
"MCP tool response",
|
||||
tool="write_note",
|
||||
action=action,
|
||||
permalink=result.permalink,
|
||||
observations_count=len(result.observations),
|
||||
relations_count=len(result.relations),
|
||||
resolved_relations=resolved,
|
||||
unresolved_relations=unresolved,
|
||||
status_code=response.status_code,
|
||||
f"MCP tool response: tool=write_note action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved} status_code={response.status_code}"
|
||||
)
|
||||
|
||||
return "\n".join(summary)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user