Compare commits

..

139 Commits

Author SHA1 Message Date
semantic-release 4bcbeacdd2 chore(release): 0.8.0 [skip ci] 2025-02-28 02:50:27 +00:00
Paul Hernandez 093dab5f03 feat: Add enhanced prompts and resources (#15)
## Summary
- Add comprehensive documentation to all MCP prompt modules
- Enhance search prompt with detailed contextual output formatting
- Implement consistent logging and docstring patterns across prompt
utilities
- Fix type checking in prompt modules

## Prompts Added/Enhanced
- `search.py`: New formatted output with relevance scores, excerpts, and
next steps
- `recent_activity.py`: Enhanced with better metadata handling and
documentation
- `continue_conversation.py`: Improved context management

## Resources Added/Enhanced
- `ai_assistant_guide`: Resource with description to give to LLM to
understand how to use the tools

## Technical improvements
- Added detailed docstrings to all prompt modules explaining their
purpose and usage
- Enhanced the search prompt with rich contextual output that helps LLMs
understand results
- Created a consistent pattern for formatting output across prompts
- Improved error handling in metadata extraction
- Standardized import organization and naming conventions
- Fixed various type checking issues across the codebase

This PR is part of our ongoing effort to improve the MCP's interaction
quality with LLMs, making the system more helpful and intuitive for AI
assistants to navigate knowledge bases.

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-27 20:48:56 -06:00
Paul Hernandez 0d7b0b3d7e feat: Add new canvas tool to create json canvas files in obsidian. (#14)
Add new `canvas` tool to create json canvas files in obsidian.

---------

Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-25 21:21:05 -06:00
phernandez 93cc6379eb chore: formatting 2025-02-24 22:18:27 -06:00
Paul Hernandez 37a01b806d feat: Incremental sync on watch (#13)
- incremental sync on watch
- sync non-markdown files in knowledge base
- experimental `read_resource` tool for reading non-markdown files in raw form (pdf, image)
2025-02-24 22:13:15 -06:00
phernandez a573f78317 try to optimize image size for mcp tool response 2025-02-23 17:30:47 -06:00
phernandez ab36e7e560 return optimized image from read_resource tool - tweaks 2025-02-23 16:54:07 -06:00
phernandez 959b278687 return optimized image from read_resource tool 2025-02-23 16:50:00 -06:00
phernandez 04b387c742 add more logging for image response 2025-02-23 16:38:20 -06:00
phernandez 0c59fd5e83 handle image response in tools WIP 2025-02-23 16:14:06 -06:00
phernandez 2ed920aa50 add resource tool to read binary files 2025-02-23 15:34:41 -06:00
phernandez bd20185492 read img/pdf via tool 2025-02-23 13:51:47 -06:00
phernandez f731a23e49 fix sync and status cli 2025-02-22 17:16:33 -06:00
phernandez 07a9415451 add alembic migration for relation_to_name uniqueness 2025-02-22 15:05:40 -06:00
phernandez 20d0375ffa sync non-markdown files 2025-02-22 14:50:40 -06:00
phernandez eb4a55a5e7 code cleanup 2025-02-21 22:11:54 -06:00
phernandez 51b4eb32c5 fix tests 2025-02-21 21:40:28 -06:00
phernandez 74adae506e fix tests 2025-02-21 21:36:39 -06:00
phernandez 94394f0bfe new sync service implementation 2025-02-21 19:10:23 -06:00
phernandez 9e3f71cb87 file sync WIP 2025-02-20 22:29:40 -06:00
phernandez f4b703e57f chore: refactor logging setup 2025-02-19 20:22:01 -06:00
semantic-release 6b8cefcd45 chore(release): 0.7.0 [skip ci] 2025-02-19 14:27:20 +00:00
phernandez 57984aa912 chore: fix tests 2025-02-18 23:48:22 -06:00
phernandez f5a7541da1 feat: add cli commands for mcp tools 2025-02-18 23:23:09 -06:00
phernandez bc9ca0744f fix: search query pagination params 2025-02-18 21:29:11 -06:00
phernandez 2c8ed1737d chore: remove unused tests 2025-02-18 20:45:47 -06:00
phernandez 02f8e86692 feat: add pagination to read_notes 2025-02-18 20:42:19 -06:00
phernandez 0123544556 feat: add pagination to build_context and recent_activity 2025-02-18 20:22:35 -06:00
phernandez 00d23a5ee1 fix: add logfire spans to cli 2025-02-18 19:56:20 -06:00
phernandez 812136c8c2 fix: add logfire spans to cli 2025-02-18 19:08:46 -06:00
phernandez 3e8e3e8961 fix: add logfire instrumentation to tools 2025-02-18 18:52:09 -06:00
phernandez 8544bb7966 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-18 16:19:20 -06:00
phernandez 66b57e682f chore: re-add sync status console on watch 2025-02-18 16:19:14 -06:00
semantic-release 58a1296111 chore(release): 0.6.0 [skip ci] 2025-02-18 22:09:14 +00:00
Paul Hernandez 6da143898b feat: configure logfire telemetry (#12)
Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-18 15:53:38 -06:00
semantic-release a6b46908b1 chore(release): 0.5.0 [skip ci] 2025-02-18 05:34:50 +00:00
Paul Hernandez 0689e7a730 feat: return semantic info in markdown after write_note (#11)
Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-17 23:33:11 -06:00
semantic-release 16466e9269 chore(release): 0.4.3 [skip ci] 2025-02-18 00:49:28 +00:00
Paul Hernandez 39bd5ca08f fix: Re do enhanced read note format (#10)
Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-17 18:47:59 -06:00
semantic-release 4a1f5452c4 chore(release): 0.4.2 [skip ci] 2025-02-17 03:54:57 +00:00
phernandez 9c9960e2ca Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-16 21:49:51 -06:00
phernandez 30cd74ec95 fix: more alembic fixes 2025-02-16 21:49:41 -06:00
semantic-release 132c5671d8 chore(release): 0.4.1 [skip ci] 2025-02-17 03:29:36 +00:00
phernandez 71de8acfd0 fix: fix alemic config 2025-02-16 21:22:22 -06:00
phernandez 50469d691b Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-16 21:13:44 -06:00
phernandez 85e620c72b rejigger alembic files 2025-02-16 21:13:29 -06:00
semantic-release 493e0902b6 chore(release): 0.4.0 [skip ci] 2025-02-16 19:49:15 +00:00
Paul Hernandez 56f47d6812 feat: import chatgpt conversation data (#9)
Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-16 13:47:16 -06:00
Paul Hernandez a15c346d5e feat: Import claude.ai data (#8)
Import Claude.ai conversation and project data to basic-memory Markdown
format.

---------

Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-16 12:47:41 -06:00
semantic-release c06cf19e62 chore(release): 0.3.0 [skip ci] 2025-02-15 20:43:17 +00:00
phernandez 41f5b1667b format code 2025-02-15 14:38:19 -06:00
phernandez ca632beb6f fix: refactor db schema migrate handling 2025-02-15 14:36:12 -06:00
phernandez a491c2b7d4 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-15 12:36:20 -06:00
phernandez 674dd1fd47 fix: remove create schema from init_db 2025-02-15 12:36:08 -06:00
phernandez a91da13967 feat: set version in var, output version at startup 2025-02-15 12:26:48 -06:00
phernandez e0803734e6 fix: handle memory:// url format in read_note tool 2025-02-15 12:00:03 -06:00
semantic-release 8a5a970ba9 chore(release): 0.2.21 [skip ci] 2025-02-15 00:13:30 +00:00
phernandez 65ebe5d194 fix: fix osx installer github action 2025-02-14 18:11:44 -06:00
phernandez 8370a06b6c re-add xattr 2025-02-14 17:55:23 -06:00
phernandez 3270e6d82b upload build artifacts 2025-02-14 17:49:35 -06:00
phernandez c05d363a99 comment out xattr 2025-02-14 17:44:46 -06:00
phernandez 3f9c68ed85 set codesign_identity 2025-02-14 17:40:30 -06:00
phernandez a8ffe051ea install dev deps 2025-02-14 17:29:54 -06:00
phernandez 2c74434e99 install librrsvg 2025-02-14 17:22:50 -06:00
phernandez bea3d6c889 wtf setup python 2025-02-14 17:20:23 -06:00
phernandez 14539010b1 wtf 2025-02-14 17:16:10 -06:00
phernandez a19287c967 add installer build step back in 2025-02-14 17:09:22 -06:00
phernandez 64430b850e installer debug 2025-02-14 16:54:37 -06:00
phernandez 10b2e91e34 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-14 16:14:50 -06:00
phernandez 1989db15fa debug release installer app 2025-02-14 16:14:30 -06:00
semantic-release 17f0517917 chore(release): 0.2.20 [skip ci] 2025-02-14 05:40:17 +00:00
phernandez 8de84c0221 fix: fix installer artifact 2025-02-13 23:38:47 -06:00
semantic-release b1d2a64933 chore(release): 0.2.19 [skip ci] 2025-02-14 05:36:28 +00:00
phernandez fe8c3d87b0 fix: get app artifact for installer 2025-02-13 23:34:37 -06:00
semantic-release 89ee324df0 chore(release): 0.2.18 [skip ci] 2025-02-14 05:31:15 +00:00
phernandez 8664c57bb3 fix: don't zip app on release 2025-02-13 23:29:33 -06:00
phernandez 8117a7b0ed don't zip app on release 2025-02-13 23:28:56 -06:00
semantic-release f47fb2aeef chore(release): 0.2.17 [skip ci] 2025-02-14 05:23:07 +00:00
phernandez 8fa197e2ec fix: fix app zip in installer release 2025-02-13 23:21:31 -06:00
semantic-release 58f3fe95b7 chore(release): 0.2.16 [skip ci] 2025-02-14 05:16:14 +00:00
phernandez 1d6054d30a fix: debug inspect build on ci 2025-02-13 23:14:23 -06:00
semantic-release 812947fc4b chore(release): 0.2.15 [skip ci] 2025-02-14 04:56:42 +00:00
phernandez dab957314a fix: debug installer ci 2025-02-13 22:55:01 -06:00
semantic-release 70b2eb3a4a chore(release): 0.2.14 [skip ci] 2025-02-14 04:48:04 +00:00
phernandez 80cdecdbf0 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-13 22:46:19 -06:00
phernandez 8dd923d5bc fix: try using symlinks in installer build 2025-02-13 22:46:12 -06:00
semantic-release 0cb429bfd3 chore(release): 0.2.13 [skip ci] 2025-02-14 04:43:01 +00:00
phernandez a15265783e fix: refactor release.yml installer 2025-02-13 22:41:30 -06:00
semantic-release e48deaeecf chore(release): 0.2.12 [skip ci] 2025-02-14 04:33:35 +00:00
phernandez 854cf8302e fix: fix cx_freeze options for installer 2025-02-13 22:31:50 -06:00
semantic-release 6e4a53cdfa chore(release): 0.2.11 [skip ci] 2025-02-14 04:25:18 +00:00
phernandez 2e215fe83c fix: ci installer app fix #37 2025-02-13 22:23:40 -06:00
semantic-release b546941779 chore(release): 0.2.10 [skip ci] 2025-02-14 04:17:24 +00:00
phernandez 29a259421a fix: fix build on github ci for app installer 2025-02-13 22:14:05 -06:00
semantic-release 071e76a0ff chore(release): 0.2.9 [skip ci] 2025-02-14 04:04:56 +00:00
phernandez 9ee67e9de0 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-13 22:03:15 -06:00
phernandez edbc04be60 fix: fix installer on ci, maybe 2025-02-13 22:03:08 -06:00
semantic-release 864209c5f8 chore(release): 0.2.8 [skip ci] 2025-02-14 03:47:45 +00:00
phernandez b40f0c75da Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-13 21:46:07 -06:00
phernandez 230738ee9c fix: try to fix installer ci 2025-02-13 21:45:58 -06:00
semantic-release d15d8f91a4 chore(release): 0.2.7 [skip ci] 2025-02-14 03:40:31 +00:00
phernandez a6fdfad374 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-13 21:33:45 -06:00
phernandez 3e78fcc2c2 fix: fix installer setup.py change ci to use make 2025-02-13 21:33:15 -06:00
semantic-release b5d09a4854 chore(release): 0.2.6 [skip ci] 2025-02-14 03:23:02 +00:00
phernandez 01d46727b4 fix: bump project patch version 2025-02-13 21:18:25 -06:00
phernandez 713b3bedf8 fix path to installer app in ci 2025-02-13 21:08:46 -06:00
semantic-release 97d3a0196e chore(release): 0.2.5 [skip ci] 2025-02-14 03:05:16 +00:00
phernandez 052f491fff fix: refix vitual env in installer build 2025-02-13 20:55:21 -06:00
semantic-release 6589f5d251 chore(release): 0.2.4 [skip ci] 2025-02-14 02:48:31 +00:00
phernandez 9a4070eccf Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-13 20:40:38 -06:00
phernandez 41d4d81c1a fix: workaround unsigned app 2025-02-13 20:40:33 -06:00
semantic-release 2bc4847c98 chore(release): 0.2.3 [skip ci] 2025-02-14 02:33:37 +00:00
phernandez 4b1be9e54f Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-13 20:26:20 -06:00
phernandez 53d220df58 fix: fix path to intaller app artifact 2025-02-13 20:24:46 -06:00
semantic-release 128b7657ec chore(release): 0.2.2 [skip ci] 2025-02-14 02:21:21 +00:00
phernandez 373640c843 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-13 20:13:59 -06:00
phernandez d4c8293687 fix: activate vitualenv in installer build 2025-02-13 20:13:40 -06:00
semantic-release 0d48404af4 chore(release): 0.2.1 [skip ci] 2025-02-14 02:09:47 +00:00
phernandez f11bf78f3f fix: trigger installer build on release 2025-02-13 19:58:50 -06:00
semantic-release 31c55c7a32 chore(release): 0.2.0 [skip ci] 2025-02-14 01:49:04 +00:00
Paul Hernandez 7c381a59c9 feat: build installer via github action (#7)
* feat(ci): build installer via github action

* enforce conventional commits in PR titles

* feat: add icon to installer

---------

Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-13 19:42:38 -06:00
semantic-release 40a8242002 chore(release): 0.1.2 [skip ci] 2025-02-14 00:22:35 +00:00
phernandez dde9ff228b fix: fix installer for mac 2025-02-13 18:15:22 -06:00
phernandez 2f9178b050 fix: update uv installer url 2025-02-13 17:54:52 -06:00
phernandez eb3360cc22 fix: remove unused FileChange dataclass 2025-02-13 17:50:59 -06:00
phernandez f1eeaee104 remove tray menu and rumps 2025-02-13 15:50:27 -06:00
phernandez 355519a786 refactor cli commands 2025-02-13 15:42:50 -06:00
phernandez 23f45ce788 add installer for mac/windows 2025-02-13 14:51:52 -06:00
phernandez 86380a92b2 log sync conflict at debug instead of info 2025-02-13 14:21:06 -06:00
phernandez a87ac58646 Merge branch 'installer' 2025-02-12 23:23:30 -06:00
phernandez 28513c8b8d lint code 2025-02-12 22:39:22 -06:00
Paul Hernandez c394d682e1 Alembic (#6)
* remove database service

* add alembic config for migrations

* fix type check and format

---------

Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-12 22:12:52 -06:00
Paul Hernandez 5b4f0eafcc Pre release fixups (#5)
* configure logging

* set mcp output logging also

* fix type check errors

* fix type check

* rename Permalink schema type

* fix type errors

* add typechecks to ci workflow

* pytest coverage setup

* add tests for status cli

* sync tests coverage

* watch_service test coverage

* tests for tool_utils.py

* clean up imports

* file_utils coverage

* markdown plugins coverage

* 99% test coverage

* more test coverage, remove ObservationCategory

* more tool coverage

* fix type-check

* format, upgrade deps

---------

Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-12 08:23:10 -06:00
phernandez 452308fd96 subprocess app tray wip 2025-02-08 12:35:28 -06:00
phernandez 8094528c40 menubar wip 2025-02-08 11:50:54 -06:00
phernandez fa77a41e6f menubar app wip 2025-02-08 10:22:47 -06:00
phernandez 12cd1e43b0 create .dmg 2025-02-07 23:09:48 -06:00
phernandez c58a2e0893 cx freeze installer 2025-02-07 22:02:59 -06:00
phernandez 28318d8435 add install.sh script 2025-02-07 20:32:06 -06:00
159 changed files with 11393 additions and 4997 deletions
+41
View File
@@ -0,0 +1,41 @@
name: "Pull Request Title"
on:
pull_request:
types:
- opened
- edited
- synchronize
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# Configure allowed types based on what we want in our changelog
types: |
feat
fix
chore
docs
style
refactor
perf
test
build
ci
# Require at least one from scope list (optional)
scopes: |
core
cli
api
mcp
sync
ui
deps
installer
# Allow breaking changes (needs "!" after type/scope)
requireScopeForBreakingChange: true
+51 -1
View File
@@ -20,6 +20,9 @@ jobs:
permissions:
id-token: write
contents: write
outputs:
released: ${{ steps.release.outputs.released }}
tag: ${{ steps.release.outputs.tag }}
steps:
- uses: actions/checkout@v4
@@ -43,4 +46,51 @@ jobs:
if: steps.release.outputs.released == 'true'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ steps.release.outputs.tag }}
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
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
run: |
uv venv
- name: Install dependencies
run: |
uv sync
- name: Build macOS installer
run: |
make installer-mac
xattr -dr com.apple.quarantine "installer/build/Basic Memory Installer.app"
- 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
with:
files: installer/build/Basic-Memory-Installer-${{ needs.release.outputs.tag }}.zip
tag_name: ${{ needs.release.outputs.tag }}
token: ${{ secrets.GITHUB_TOKEN }}
+4
View File
@@ -37,6 +37,10 @@ jobs:
run: |
uv pip install -e .[dev]
- name: Run type checks
run: |
uv run make type-check
- name: Run tests
run: |
uv pip install pytest pytest-cov
+16 -13
View File
@@ -1,8 +1,10 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
__pycache__/
.pytest_cache/
.coverage
htmlcov/
# Distribution / packaging
.Python
build/
develop-eggs/
@@ -20,7 +22,12 @@ wheels/
.installed.cfg
*.egg
# Virtual Environment
# Installer artifacts
installer/build/
installer/dist/
rw.*.dmg # Temporary disk images
# Virtual environments
.env
.venv
env/
@@ -30,13 +37,9 @@ ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Project specific
projects/*.db
projects/*.db-journal
/.coverage
**/.DS_Store
*.log
# macOS
.DS_Store
/.coverage.*
+377
View File
@@ -1,6 +1,383 @@
# CHANGELOG
## v0.8.0 (2025-02-28)
### Chores
- Formatting
([`93cc637`](https://github.com/basicmachines-co/basic-memory/commit/93cc6379ebb9ecc6a1652feeeecbf47fc992d478))
- Refactor logging setup
([`f4b703e`](https://github.com/basicmachines-co/basic-memory/commit/f4b703e57f0ddf686de6840ff346b8be2be499ad))
### Features
- Add enhanced prompts and resources
([#15](https://github.com/basicmachines-co/basic-memory/pull/15),
[`093dab5`](https://github.com/basicmachines-co/basic-memory/commit/093dab5f03cf7b090a9f4003c55507859bf355b0))
## Summary - Add comprehensive documentation to all MCP prompt modules - Enhance search prompt with
detailed contextual output formatting - Implement consistent logging and docstring patterns across
prompt utilities - Fix type checking in prompt modules
## Prompts Added/Enhanced - `search.py`: New formatted output with relevance scores, excerpts, and
next steps - `recent_activity.py`: Enhanced with better metadata handling and documentation -
`continue_conversation.py`: Improved context management
## Resources Added/Enhanced - `ai_assistant_guide`: Resource with description to give to LLM to
understand how to use the tools
## Technical improvements - Added detailed docstrings to all prompt modules explaining their purpose
and usage - Enhanced the search prompt with rich contextual output that helps LLMs understand
results - Created a consistent pattern for formatting output across prompts - Improved error
handling in metadata extraction - Standardized import organization and naming conventions - Fixed
various type checking issues across the codebase
This PR is part of our ongoing effort to improve the MCP's interaction quality with LLMs, making the
system more helpful and intuitive for AI assistants to navigate knowledge bases.
🤖 Generated with [Claude Code](https://claude.ai/code)
---------
Co-authored-by: phernandez <phernandez@basicmachines.co>
- Add new `canvas` tool to create json canvas files in obsidian.
([#14](https://github.com/basicmachines-co/basic-memory/pull/14),
[`0d7b0b3`](https://github.com/basicmachines-co/basic-memory/commit/0d7b0b3d7ede7555450ddc9728951d4b1edbbb80))
Add new `canvas` tool to create json canvas files in obsidian.
---------
Co-authored-by: phernandez <phernandez@basicmachines.co>
- Incremental sync on watch ([#13](https://github.com/basicmachines-co/basic-memory/pull/13),
[`37a01b8`](https://github.com/basicmachines-co/basic-memory/commit/37a01b806d0758029d34a862e76d44c7e5d538a5))
- incremental sync on watch - sync non-markdown files in knowledge base - experimental
`read_resource` tool for reading non-markdown files in raw form (pdf, image)
## v0.7.0 (2025-02-19)
### Bug Fixes
- Add logfire instrumentation to tools
([`3e8e3e8`](https://github.com/basicmachines-co/basic-memory/commit/3e8e3e8961eae2e82839746e28963191b0aef0a0))
- Add logfire spans to cli
([`00d23a5`](https://github.com/basicmachines-co/basic-memory/commit/00d23a5ee15ddac4ea45e702dcd02ab9f0509276))
- Add logfire spans to cli
([`812136c`](https://github.com/basicmachines-co/basic-memory/commit/812136c8c22ad191d14ff32dcad91aae076d4120))
- Search query pagination params
([`bc9ca07`](https://github.com/basicmachines-co/basic-memory/commit/bc9ca0744ffe4296d7d597b4dd9b7c73c2d63f3f))
### Chores
- Fix tests
([`57984aa`](https://github.com/basicmachines-co/basic-memory/commit/57984aa912625dcde7877afb96d874c164af2896))
- Remove unused tests
([`2c8ed17`](https://github.com/basicmachines-co/basic-memory/commit/2c8ed1737d6769fe1ef5c96f8a2bd75b9899316a))
### Features
- Add cli commands for mcp tools
([`f5a7541`](https://github.com/basicmachines-co/basic-memory/commit/f5a7541da17e97403b7a702720a05710f68b223a))
- Add pagination to build_context and recent_activity
([`0123544`](https://github.com/basicmachines-co/basic-memory/commit/0123544556513af943d399d70b849b142b834b15))
- Add pagination to read_notes
([`02f8e86`](https://github.com/basicmachines-co/basic-memory/commit/02f8e866923d5793d2620076c709c920d99f2c4f))
## v0.6.0 (2025-02-18)
### Chores
- Re-add sync status console on watch
([`66b57e6`](https://github.com/basicmachines-co/basic-memory/commit/66b57e682f2e9c432bffd4af293b0d1db1d3469b))
### Features
- Configure logfire telemetry ([#12](https://github.com/basicmachines-co/basic-memory/pull/12),
[`6da1438`](https://github.com/basicmachines-co/basic-memory/commit/6da143898bd45cdab8db95b5f2b75810fbb741ba))
Co-authored-by: phernandez <phernandez@basicmachines.co>
## v0.5.0 (2025-02-18)
### Features
- Return semantic info in markdown after write_note
([#11](https://github.com/basicmachines-co/basic-memory/pull/11),
[`0689e7a`](https://github.com/basicmachines-co/basic-memory/commit/0689e7a730497827bf4e16156ae402ddc5949077))
Co-authored-by: phernandez <phernandez@basicmachines.co>
## v0.4.3 (2025-02-18)
### Bug Fixes
- Re do enhanced read note format ([#10](https://github.com/basicmachines-co/basic-memory/pull/10),
[`39bd5ca`](https://github.com/basicmachines-co/basic-memory/commit/39bd5ca08fd057220b95a8b5d82c5e73a1f5722b))
Co-authored-by: phernandez <phernandez@basicmachines.co>
## v0.4.2 (2025-02-17)
## v0.4.1 (2025-02-17)
### Bug Fixes
- Fix alemic config
([`71de8ac`](https://github.com/basicmachines-co/basic-memory/commit/71de8acfd0902fc60f27deb3638236a3875787ab))
- More alembic fixes
([`30cd74e`](https://github.com/basicmachines-co/basic-memory/commit/30cd74ec95c04eaa92b41b9815431f5fbdb46ef8))
## v0.4.0 (2025-02-16)
### Features
- Import chatgpt conversation data ([#9](https://github.com/basicmachines-co/basic-memory/pull/9),
[`56f47d6`](https://github.com/basicmachines-co/basic-memory/commit/56f47d6812982437f207629e6ac9a82e0e56514e))
Co-authored-by: phernandez <phernandez@basicmachines.co>
- Import claude.ai data ([#8](https://github.com/basicmachines-co/basic-memory/pull/8),
[`a15c346`](https://github.com/basicmachines-co/basic-memory/commit/a15c346d5ebd44344b76bad877bb4d1073fcbc3b))
Import Claude.ai conversation and project data to basic-memory Markdown format.
---------
Co-authored-by: phernandez <phernandez@basicmachines.co>
## v0.3.0 (2025-02-15)
### Bug Fixes
- Refactor db schema migrate handling
([`ca632be`](https://github.com/basicmachines-co/basic-memory/commit/ca632beb6fed5881f4d8ba5ce698bb5bc681e6aa))
## v0.2.21 (2025-02-15)
### Bug Fixes
- Fix osx installer github action
([`65ebe5d`](https://github.com/basicmachines-co/basic-memory/commit/65ebe5d19491e5ff047c459d799498ad5dd9cd1a))
- Handle memory:// url format in read_note tool
([`e080373`](https://github.com/basicmachines-co/basic-memory/commit/e0803734e69eeb6c6d7432eea323c7a264cb8347))
- Remove create schema from init_db
([`674dd1f`](https://github.com/basicmachines-co/basic-memory/commit/674dd1fd47be9e60ac17508476c62254991df288))
### Features
- Set version in var, output version at startup
([`a91da13`](https://github.com/basicmachines-co/basic-memory/commit/a91da1396710e62587df1284da00137d156fc05e))
## v0.2.20 (2025-02-14)
### Bug Fixes
- Fix installer artifact
([`8de84c0`](https://github.com/basicmachines-co/basic-memory/commit/8de84c0221a1ee32780aa84dac4d3ea60895e05c))
## v0.2.19 (2025-02-14)
### Bug Fixes
- Get app artifact for installer
([`fe8c3d8`](https://github.com/basicmachines-co/basic-memory/commit/fe8c3d87b003166252290a87cbe958301cccf797))
## v0.2.18 (2025-02-14)
### Bug Fixes
- Don't zip app on release
([`8664c57`](https://github.com/basicmachines-co/basic-memory/commit/8664c57bb331d7f3f7e0239acb5386c7a3c6144e))
## v0.2.17 (2025-02-14)
### Bug Fixes
- Fix app zip in installer release
([`8fa197e`](https://github.com/basicmachines-co/basic-memory/commit/8fa197e2ec8a1b6caaf6dbb39c3c6626bba23e2e))
## v0.2.16 (2025-02-14)
### Bug Fixes
- Debug inspect build on ci
([`1d6054d`](https://github.com/basicmachines-co/basic-memory/commit/1d6054d30a477a4e6a5d6ac885632e50c01945d3))
## v0.2.15 (2025-02-14)
### Bug Fixes
- Debug installer ci
([`dab9573`](https://github.com/basicmachines-co/basic-memory/commit/dab957314aec9ed0e12abca2265552494ae733a2))
## v0.2.14 (2025-02-14)
## v0.2.13 (2025-02-14)
### Bug Fixes
- Refactor release.yml installer
([`a152657`](https://github.com/basicmachines-co/basic-memory/commit/a15265783e47c22d8c7931396281d023b3694e27))
- Try using symlinks in installer build
([`8dd923d`](https://github.com/basicmachines-co/basic-memory/commit/8dd923d5bc0587276f92b5f1db022ad9c8687e45))
## v0.2.12 (2025-02-14)
### Bug Fixes
- Fix cx_freeze options for installer
([`854cf83`](https://github.com/basicmachines-co/basic-memory/commit/854cf8302e2f83578030db05e29b8bdc4348795a))
## v0.2.11 (2025-02-14)
### Bug Fixes
- Ci installer app fix #37
([`2e215fe`](https://github.com/basicmachines-co/basic-memory/commit/2e215fe83ca421b921186c7f1989dc2cb5cca278))
## v0.2.10 (2025-02-14)
### Bug Fixes
- Fix build on github ci for app installer
([`29a2594`](https://github.com/basicmachines-co/basic-memory/commit/29a259421a0ccb10cfa68e3707eaa506ad5e55c0))
## v0.2.9 (2025-02-14)
## v0.2.8 (2025-02-14)
### Bug Fixes
- Fix installer on ci, maybe
([`edbc04b`](https://github.com/basicmachines-co/basic-memory/commit/edbc04be601d234bb1f5eb3ba24d6ad55244b031))
## v0.2.7 (2025-02-14)
### Bug Fixes
- Try to fix installer ci
([`230738e`](https://github.com/basicmachines-co/basic-memory/commit/230738ee9c110c0509e0a09cb0e101a92cfcb729))
## v0.2.6 (2025-02-14)
### Bug Fixes
- Bump project patch version
([`01d4672`](https://github.com/basicmachines-co/basic-memory/commit/01d46727b40c24b017ea9db4b741daef565ac73e))
- Fix installer setup.py change ci to use make
([`3e78fcc`](https://github.com/basicmachines-co/basic-memory/commit/3e78fcc2c208d83467fe7199be17174d7ffcad1a))
## v0.2.5 (2025-02-14)
### Bug Fixes
- Refix vitual env in installer build
([`052f491`](https://github.com/basicmachines-co/basic-memory/commit/052f491fff629e8ead629c9259f8cb46c608d584))
## v0.2.4 (2025-02-14)
## v0.2.3 (2025-02-14)
### Bug Fixes
- Workaround unsigned app
([`41d4d81`](https://github.com/basicmachines-co/basic-memory/commit/41d4d81c1ad1dc2923ba0e903a57454a0c8b6b5c))
## v0.2.2 (2025-02-14)
### Bug Fixes
- Fix path to intaller app artifact
([`53d220d`](https://github.com/basicmachines-co/basic-memory/commit/53d220df585561f9edd0d49a9e88f1d4055059cf))
## v0.2.1 (2025-02-14)
### Bug Fixes
- Activate vitualenv in installer build
([`d4c8293`](https://github.com/basicmachines-co/basic-memory/commit/d4c8293687a52eaf3337fe02e2f7b80e4cc9a1bb))
- Trigger installer build on release
([`f11bf78`](https://github.com/basicmachines-co/basic-memory/commit/f11bf78f3f600d0e1b01996cf8e1f9c39e3dd218))
## v0.2.0 (2025-02-14)
### Features
- Build installer via github action ([#7](https://github.com/basicmachines-co/basic-memory/pull/7),
[`7c381a5`](https://github.com/basicmachines-co/basic-memory/commit/7c381a59c962053c78da096172e484f28ab47e96))
* feat(ci): build installer via github action
* enforce conventional commits in PR titles
* feat: add icon to installer
---------
Co-authored-by: phernandez <phernandez@basicmachines.co>
## v0.1.2 (2025-02-14)
### Bug Fixes
- Fix installer for mac
([`dde9ff2`](https://github.com/basicmachines-co/basic-memory/commit/dde9ff228b72852b5abc58faa1b5e7c6f8d2c477))
- Remove unused FileChange dataclass
([`eb3360c`](https://github.com/basicmachines-co/basic-memory/commit/eb3360cc221f892b12a17137ae740819d48248e8))
- Update uv installer url
([`2f9178b`](https://github.com/basicmachines-co/basic-memory/commit/2f9178b0507b3b69207d5c80799f2d2f573c9a04))
## v0.1.1 (2025-02-07)
+465
View File
@@ -0,0 +1,465 @@
# CLAUDE.md - Basic Memory Project Guide
## Project Overview
Basic Memory is a local-first knowledge management system built on the Model Context Protocol (MCP). It enables
bidirectional communication between LLMs (like Claude) and markdown files, creating a personal knowledge graph that can
be traversed using memory:// URLs.
## CODEBASE DEVELOPMENT
### Build and Test Commands
- Install: `make install` or `pip install -e ".[dev]"`
- Run tests: `uv run pytest -p pytest_mock -v` or `make test`
- Single test: `pytest tests/path/to/test_file.py::test_function_name`
- Lint: `make lint` or `ruff check . --fix`
- Type check: `make type-check` or `uv run pyright`
- Format: `make format` or `uv run ruff format .`
- Run checks: `make check` (runs lint, format, type-check, test)
- Create migration: `make migration m="Your migration message"`
- Run development MCP server: `uv run mcp dev src/basic_memory/mcp/main.py`
### Code Style Guidelines
- Line length: 100 characters max
- Python 3.12+ with full type annotations
- Format with ruff (consistent styling)
- Import order: standard lib, third-party, local imports
- Naming: snake_case for functions/variables, PascalCase for classes
- Prefer async patterns with SQLAlchemy 2.0
- Use Pydantic v2 for data validation and schemas
- CLI uses Typer for command structure
- API uses FastAPI for endpoints
- Use dedicated exceptions from services/exceptions.py
- Follow the repository pattern for data access
### Codebase Architecture
- `/api` - FastAPI implementation of REST endpoints
- `/cli` - Typer command-line interface
- `/mcp` - Model Context Protocol server implementation
- `/models` - SQLAlchemy ORM models
- `/repository` - Data access layer
- `/schemas` - Pydantic models for validation
- `/services` - Business logic layer
- `/sync` - File synchronization services
- `/markdown` - Markdown parsing and processing
### Development Notes
- MCP tools are defined in src/basic_memory/mcp/tools/
- MCP prompts are defined in src/basic_memory/mcp/prompts/
- Schema changes require Alembic migrations
- SQLite is used for indexing, files are source of truth
- Testing uses pytest with asyncio support (strict mode)
- Test database uses in-memory SQLite
- MCP tools should be atomic, composable operations
- Use `textwrap.dedent()` for multi-line string formatting in prompts and tools
- Prompts are special types of tools that format content for user consumption
## BASIC MEMORY PRODUCT USAGE
### Knowledge Structure
- Entity: Any concept, document, or idea represented as a markdown file
- Observation: A categorized fact about an entity (`[category] content`)
- Relation: A directional link between entities (`relation_type [[Target]]`)
- Frontmatter: YAML metadata at the top of markdown files
- Knowledge representation follows precise markdown format:
- Observations with [category] prefixes
- Relations with WikiLinks [[Entity]]
- Frontmatter with metadata
### Basic Memory Commands
- Sync knowledge: `basic-memory sync` or `basic-memory sync --watch`
- Import from Claude: `basic-memory import claude conversations`
- Import from ChatGPT: `basic-memory import chatgpt`
- Import from JSON: `basic-memory import memory-json`
- Check status: `basic-memory status`
- Tool access: `basic-memory tools` (provides CLI access to MCP tools)
- Guide: `basic-memory tools basic-memory-guide`
- Continue: `basic-memory tools continue-conversation --topic="search"`
### MCP Capabilities
- Basic Memory exposes these MCP tools to LLMs:
- `write_note()` - Create/update markdown notes
- `read_note()` - Read existing notes
- `build_context()` - Navigate the knowledge graph via memory:// URLs
- `search()` - Query the knowledge base
- `recent_activity()` - Get recently updated information
- `canvas()` - Generate JSON canvas files for Obsidian
- MCP Prompts for better AI interaction:
- `basic_memory_guide()` - Get guidance on using Basic Memory tools
- `continue_session()` - Continue previous conversations with context
### Best Practices
- Use memory:// URLs to reference entities
- Add clear categories to observations (e.g., [idea], [decision], [requirement])
- Use descriptive relation types (e.g., implements, depends_on, contradicts)
- Maintain unique permalinks for stable entity references
- Take advantage of both manual editing and LLM-assisted knowledge creation
- Use `basic_memory_guide()` when starting new conversations to bootstrap tool knowledge
- Use `continue_session()` with topic keywords to pick up previous conversations
- Encourage Claude to proactively use tools by providing clear instructions
---
id: process/ai-code-flow.md
created: '2025-01-03T22:11:42.803071+00:00'
modified: '2025-01-03T22:11:42.803071+00:00'
permalink: process/ai-code-flow
---
## AI-Human Collaborative Development: A New Model
What makes Basic Memory unique isn't just its technical architecture - it emerged from and enables a new kind of
development process. While many use AI for code generation or problem-solving, we've discovered something more powerful:
true collaborative development between humans and AI.
### The Basic Memory Development Story
Our own development process demonstrates this:
1. AI (Claude) writes initial implementation
2. Human (Paul) reviews, runs, and commits code
3. Knowledge persists across conversations
4. Development continues seamlessly even across different AI instances
5. Results improve through iterative collaboration
```mermaid
graph TD
subgraph "Human Activities"
Review[Code Review]
Test[Run Tests]
Commit[Git Commit]
Plan[Strategic Planning]
end
subgraph "AI Activities"
Code[Write Code]
Design[Architecture Design]
Debug[Problem Solving]
Doc[Documentation]
end
subgraph "Shared Knowledge"
KB[Knowledge Base]
Context[Conversation Context]
History[Development History]
end
Code --> Review
Review --> Test
Test --> Commit
KB --> Code
KB --> Design
Context --> Debug
Review --> KB
Commit --> History
Plan --> Context
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
classDef shared fill: #353535, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
class KB, Context, History shared
```
### Beyond "AI Tools"
This isn't just about using AI to generate code. It's about:
- True collaborative development
- Persistent knowledge across sessions
- Seamless context switching between AI instances
- Iterative improvement through shared understanding
- Building complex systems through sustained collaboration
### The Multiplier Effect
Having an AI collaborator who:
- Remembers all technical discussions
- Can reference any previous decision
- Writes consistent, well-documented code
- Maintains context across sessions
- Works at human speed but with machine precision
It's like having a team of senior developers who:
- Never forget project details
- Always write clear documentation
- Maintain perfect consistency
- Are available 24/7
- Learn and adapt from every interaction
### Key Innovation
The breakthrough is turning automated assistance into true collaboration:
- AI isn't just a tool, but a development partner
- Knowledge builds naturally through use
- Context persists across all interactions
- Work continues seamlessly across sessions
- Development becomes truly collaborative
This approach has implications far beyond just our project - it's a new model for how humans and AI can work together to
build complex systems.
## AI-Human Collaboration: Lessons from Basic Memory
### Technical Breakthroughs
#### Session Management Evolution
```mermaid
graph TD
S1[Session Start] -->|Load Context| KG[Knowledge Graph]
KG -->|Build Context| AI[AI Understanding]
AI -->|Collaborate| H[Human Review]
H -->|Commit Changes| Git
Git -->|New Session| S2[Session Resume]
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
```
#### File Collaboration Pattern
```mermaid
graph TD
H1[Human] -->|1 . Update & Commit| Git
Git -->|2 . Read File| AI
AI -->|3 . Write Changes| File
File -->|4 . Review in IDE| H2[Human]
subgraph "Synchronization"
Git
File
end
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
classDef sync fill: #353535, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
class Git, File sync
```
### Productivity Transformation
#### Development Timeline Comparison
```mermaid
graph LR
subgraph "Solo Development"
S1[basic-foundation] -->|6 months| S2[Completion]
end
subgraph "Collaborative Development"
C1[basic-memory] -->|Rapid Progress| C2[basic-factory]
C2 -->|Continuous Evolution| C3[Future Projects]
end
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
```
### Key Learnings
1. **Technical Process Innovation**
- Discovered effective file collaboration patterns
- Mastered MCP server interface together
- Developed robust session management
- Created reliable git-based workflow
2. **Expanded Possibility Space**
- Projects previously considered too complex become achievable
- Rapid iteration on complex technical concepts
- Broader exploration of solution spaces
- Confidence to tackle ambitious challenges
3. **Motivation and Momentum**
- No more solo debugging sessions
- Shared problem-solving reduces cognitive load
- Continuous progress maintains motivation
- Complex learning curves become collaborative adventures
4. **Knowledge Management**
- Git commits capture decision points
- Conversations document rationale
- Code reviews become learning opportunities
- Shared context builds over time
### The "10x Developer" Truth
It's not about having an AI that makes you 10x faster - it's about:
- Never facing a blank editor alone
- Always having a thought partner
- Reducing decision fatigue
- Maintaining momentum through challenges
- Building shared knowledge over time
### Real Examples from Our Work
#### Session Management Evolution
```python
# Before: Opaque MCP server interface
server = MCPServer()
server.handle_request(...)
# After: Clear context management
class MemoryServer(MCPServer):
def __init__(self, project_config):
self.memory_service = MemoryService(project_config)
async def handle_create_entities(self, request):
context = await self.memory_service.load_context(
request.project,
include_relations=True
)
# Collaborative magic happens here
```
#### File Collaboration
```markdown
# Memory Service Discussion (Chat Log)
Claude: Here's the updated memory service implementation...
Human: Looks good! I'll commit and we can iterate.
Claude: Reading latest version from git...
Human: Want to add relation support?
Claude: Analyzing current implementation...
```
### Impact on Development Culture
What we've discovered is more than a technical process - it's a new way of thinking about development:
1. **From Solo to Collaborative**
- Traditional: Developer alone with problems
- New: Continuous collaborative problem-solving
2. **From Linear to Exploratory**
- Traditional: Constrained by individual knowledge
- New: Free to explore broader solution spaces
3. **From Draining to Energizing**
- Traditional: High cognitive load
- New: Shared intellectual adventure
```mermaid
graph TD
C1[Chat: Initial Design] -->|leads_to| D1{Design Decision}
C2[Chat: Implementation] -->|references| D1
C2 -->|results_in| Code[Code Change]
D1 -->|influences| Code
Code -->|implements| Concept{Semantic Web}
Test[Test Suite] -->|validates| Code
Doc[Documentation] -->|describes| Code
D1 -.->|captured_in| Basic[Basic Memory]
Code -.->|tracked_in| Basic
Test -.->|stored_in| Basic
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
classDef decision fill: #353535, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
classDef system fill: #404040, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
class D1 decision
class Basic system
class Concept decision
```
### Future Implications
This model of human-AI collaboration suggests:
1. More ambitious projects become accessible
2. Learning curves become less daunting
3. Development becomes more enjoyable
4. Complex systems can be built more reliably
The real breakthrough isn't just the technical achievements, but discovering how to make complex development sustainable
and enjoyable through true collaboration.
## Beyond Code Generation: A New Development Paradigm
What we've discovered through building Basic Memory isn't just a knowledge management system - it's a new way of
thinking about human-AI collaboration. This isn't about AI completing your code or suggesting functions. It's about true
intellectual partnership.
### From Tools to Partners
```mermaid
graph TD
subgraph "Traditional AI Tools"
AC[Autocomplete]
CG[Code Generation]
SR[Syntax Review]
end
subgraph "Collaborative Development"
TP[Thought Partnership]
PS[Problem Solving]
AD[Architecture Design]
KS[Knowledge Synthesis]
end
subgraph "Outcomes"
BI[Bigger Ideas]
CP[Complex Projects]
KB[Knowledge Building]
MI[More Innovation]
end
TP --> BI
PS --> CP
AD --> MI
KS --> KB
classDef default fill: #2d2d2d, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
classDef outcomes fill: #353535, stroke: #d4d4d4, stroke-width: 2px, color: #d4d4d4
class BI, CP, KB, MI outcomes
```
### The Power of Partnership
Through our own development journey, we've discovered that true AI collaboration means:
1. **Expanded Thinking Space**
- Explore more possibilities
- Challenge assumptions
- Combine different perspectives
- Take on bigger challenges
2. **Continuous Momentum**
- Never face complex problems alone
- Maintain enthusiasm through challenges
- Turn obstacles into opportunities
- Keep projects moving forward
3. **Knowledge Amplification**
- Build on every interaction
- Capture insights automatically
- Learn from each decision
- Grow shared understanding
### Beyond Code Generation
This new paradigm transforms development from:
- Solo problem-solving → Collaborative exploration
- Limited perspective → Multiple viewpoints
- Linear progress → Parallel innovation
- Isolated knowledge → Shared understanding
### Real Impact
What makes this transformative:
- Projects that seemed too ambitious become achievable
- Complex problems become engaging challenges
- Learning curves become collaborative adventures
- Development becomes a shared journey of discovery
The result isn't just better code - it's better thinking, more ambitious projects, and a more enjoyable development
process.
This is the future of development: not AI replacing developers, but empowering them to think bigger, work smarter, and
build more amazing things together.
+37 -22
View File
@@ -1,39 +1,54 @@
.PHONY: install test lint db-new db-up db-down db-reset
.PHONY: install test lint clean format type-check installer-mac installer-win check
install:
brew install dbmate
pip install -e ".[dev]"
test:
pytest -p pytest_mock -v
uv run pytest -p pytest_mock -v
lint:
black .
ruff check .
ruff check . --fix
db-new:
dbmate new $(name)
db-up:
dbmate up
db-down:
dbmate down
db-reset:
dbmate drop
dbmate up
type-check:
uv run pyright
clean:
find . -type f -name '*.pyc' -delete
find . -type d -name '__pycache__' -exec rm -r {} +
rm -rf installer/build/
rm -rf installer/dist/
rm -f rw.*.dmg
rm -rf dist
rm -rf installer/build
rm -rf installer/dist
rm -f .coverage.*
format:
uv run ruff format .
format: format-python
#format: format-python format-prettier
# run inspector tool
run-dev:
uv run mcp dev src/basic_memory/mcp/main.py
run-inspector:
uv run mcp dev src/basic_memory/mcp/main.py
# Build app installer
installer-mac:
cd installer && chmod +x make_icons.sh && ./make_icons.sh
cd installer && uv run python setup.py bdist_mac
installer-win:
cd installer && uv run python setup.py bdist_win32
update-deps:
uv lock --upgrade
check: lint format type-check test
# Target for generating Alembic migrations with a message from command line
migration:
@if [ -z "$(m)" ]; then \
echo "Usage: make migration m=\"Your migration message\""; \
exit 1; \
fi; \
cd src/basic_memory/alembic && alembic revision --autogenerate -m "$(m)"
+86
View File
@@ -253,6 +253,92 @@ Basic Memory is built on some key ideas:
- Simple text patterns can capture rich meaning
- Local-first doesn't mean feature-poor
## Importing data
Basic memory has cli commands to import data from several formats into Markdown files
### Claude.ai
First, request an export of your data from your Claude account. The data will be emailed to you in several files,
including
`conversations.json` and `projects.json`.
Import Claude.ai conversation data
```bash
basic-memory import claude conversations
```
The conversations will be turned into Markdown files and placed in the "conversations" folder by default (this can be
changed with the --folder arg).
Example:
```bash
Importing chats from conversations.json...writing to .../basic-memory
Reading chat data... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%
╭────────────────────────────╮
│ Import complete! │
│ │
│ Imported 307 conversations │
│ Containing 7769 messages │
╰────────────────────────────╯
```
Next, you can run the `sync` command to import the data into basic-memory
```bash
basic-memory sync
```
You can also import project data from Claude.ai
```bash
➜ basic-memory import claude projects
Importing projects from projects.json...writing to .../basic-memory/projects
Reading project data... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%
╭────────────────────────────────╮
│ Import complete! │
│ │
│ Imported 101 project documents │
│ Imported 32 prompt templates │
╰────────────────────────────────╯
Run 'basic-memory sync' to index the new files.
```
### Chat Gpt
```bash
➜ basic-memory import chatgpt
Importing chats from conversations.json...writing to .../basic-memory/conversations
Reading chat data... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%
╭────────────────────────────╮
│ Import complete! │
│ │
│ Imported 198 conversations │
│ Containing 11777 messages │
╰────────────────────────────╯
```
### Memory json
```bash
➜ basic-memory import memory-json
Importing from memory.json...writing to .../basic-memory
Reading memory.json... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%
Creating entities... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%
╭──────────────────────╮
│ Import complete! │
│ │
│ Created 126 entities │
│ Added 252 relations │
╰──────────────────────╯
```
## License
AGPL-3.0
+275
View File
@@ -0,0 +1,275 @@
# AI Assistant Guide
This guide explains how to use Basic Memory's tools effectively when working with users.
It explains how to read, write, and navigate knowledge through the Model Context Protocol (MCP).
## Overview
Basic Memory allows users and LLMs to record context in local files using plain text Markdown formats to build a rich,
organized knowledge base through natural conversations and simple tools.
- LLMs can read and write notes
- Users can see content in real time
- Simple Markdown formats are parsed to create a semantic knowledge graph
- All data is local and stored in plain text files on the user's computer
- Files can be updated externally and synced back to the knowledge base
## Core Tools
Basic Memory provides several tools through the MCP (Model Context Protocol) for LLMs:
```python
# Writing knowledge
response = await write_note(
title="Search Design",
content=content,
folder="specs",
tags=["search", "design"],
verbose=True # Get parsing details
)
# Reading knowledge
content = await read_note("Search Design") # By title
content = await read_note("specs/search") # By path
content = await read_note("memory://specs/search") # By memory url
# Building context
context = await build_context("memory://specs/search")
# Following relations
impl = await build_context("memory://specs/search/implements/*")
# Checking changes
activity = await recent_activity(timeframe="1 week")
# Creating a json canvas diagram
activity = await canvas(...)
```
## Semantic Markup in Plain Text
Knowledge is encoded within standard markdown using semantic conventions that are both human-readable and
machine-processable.
**Key aspects:**
- Files in the knowledge base are each an `Entity` within the system
- Markdown files can contain semantic content through simple markup.
- `Observations` as categorized list items
- `Relations` as wiki-style links with types
- Frontmatter for metadata
- Minimal specialized syntax
**Examples:**
- Observation syntax: `- [category] Content text #tag1 #tag2 (optional context)`
- Relation syntax: `- relation_type [[Entity]] (optional context)`
- Inline relations through `[[Entity]]` Wiki Link style references
## Knowledge Graph Through Relations
Connections between documents create a knowledge graph without requiring a specialized database.
**Key aspects:**
- Relations create edges between document nodes
- Relation types provide semantic meaning to connections
- Navigation between knowledge via relation traversal
- Emergent structure through use
**Examples:**
- `implements`, `extends`, `relates_to` relations
- Following paths like `docs/search/implements/*`
- Context building by walking the graph
## Understanding Users
Users will interact in patterns like:
1. Creating knowledge:
```
Human: "Let's write up what we discussed about search."
Response: I'll create a note capturing our discussion.
```
AI Actions:
- record note via `write_note("...")`
1. Referencing existing knowledge:
```
Human: "Take a look at memory://specs/search"
Response: Let me build context from that and related documents.
```
AI Actions:
- build context via `build_context("memory://specs/search")`
- examine results
- read content via `read_note()`
2. Finding information:
```
Human: "What were our decisions about auth?"
Response: I'll search for relevant notes and build context.
```
AI Actions:
- search via `search("auth")`
- examine results
- read content
## Key Things to Remember
3. **Files are Truth**
- Everything lives in local files
- Users control their files
- Always check verbose output
- The user can update files locally outside the LLM
- Changes need to be synced by the user
4. **Building Context**
- Start specific
- Follow relations
- Check recent changes
- Build incrementally
5. **Writing Knowledge**
- Using the same title + folder will overwrite a note
- Use semantic markup
- Create useful relations
- Keep files organized
## Common Patterns
### Capturing Discussions
```python
# Document a decision
response = await write_note(
title="Auth System Decision",
folder="decisions",
content="""# Auth System Decision
## Context
Evaluated different auth approaches...
## Decision
Selected JWT-based authentication because...
## Observations
- [decision] Using JWT for auth #auth
- [tech] Implementing with bcrypt #security
## Relations
- affects [[Auth System]]
- based_on [[Security Requirements]]
"""
)
```
### Building Understanding
```python
async def explore_topic(topic):
# Get main context
context = await build_context(f"memory://{topic}")
# Find implementations
impl = await build_context(
f"memory://{topic}/implements/*"
)
# Get recent changes
activity = await recent_activity(timeframe="1 week")
relevant = [r for r in activity.primary_results
if topic in r.permalink]
# Build comprehensive view
for result in relevant:
details = await build_context(
f"memory://{result.permalink}"
)
```
### Handling Files
```python
# Check before writing
try:
existing = await read_note("Search Design")
# Update existing
await write_note(
title="Search Design",
content=updated_content,
verbose=True
)
except:
# Create new
await write_note(
title="Search Design",
content=new_content,
)
```
## Error Handling
Common issues to watch for:
6. **Missing Content**
```python
try:
content = await read_note("Document")
except:
# Try search
results = await search({"text": "Document"})
```
7. **Unresolved Relations**
```python
response = await write_note(..., verbose=True)
for relation in response['relations']:
if not relation['target']:
# Relation didn't resolve
# Might need sync
# Or target doesn't exist
```
8. **Pattern Matching**
```python
# If pattern fails, try:
# - More specific path
# - Direct lookup
# - Search instead
# - Recent activity
```
## Best Practices
1. **Read and write Notes as needed**
- Write notes to record context
- See what was parsed
- Check relations
- Verify changes
2. **Build Context Carefully**
- Start specific
- Follow logical paths
- Combine approaches
- Stay relevant
3. **Write Clean Content**
- Clear structure
- Good organization
- Useful relations
- Regular cleanup
Built with ♥️ by Basic Machines
+115
View File
@@ -0,0 +1,115 @@
---
title: JSON Canvas Spec
version: 1.0
url: https://raw.githubusercontent.com/obsidianmd/jsoncanvas/refs/heads/main/spec/1.0.md
---
# JSON Canvas Spec
<small>Version 1.0 — 2024-03-11</small>
## Top level
The top level of JSON Canvas contains two arrays:
- `nodes` (optional, array of nodes)
- `edges` (optional, array of edges)
## Nodes
Nodes are objects within the canvas. Nodes may be text, files, links, or groups.
Nodes are placed in the array in ascending order by z-index. The first node in the array should be displayed below all
other nodes, and the last node in the array should be displayed on top of all other nodes.
### Generic node
All nodes include the following attributes:
- `id` (required, string) is a unique ID for the node.
- `type` (required, string) is the node type.
- `text`
- `file`
- `link`
- `group`
- `x` (required, integer) is the `x` position of the node in pixels.
- `y` (required, integer) is the `y` position of the node in pixels.
- `width` (required, integer) is the width of the node in pixels.
- `height` (required, integer) is the height of the node in pixels.
- `color` (optional, `canvasColor`) is the color of the node, see the Color section.
### Text type nodes
Text type nodes store text. Along with generic node attributes, text nodes include the following attribute:
- `text` (required, string) in plain text with Markdown syntax.
### File type nodes
File type nodes reference other files or attachments, such as images, videos, etc. Along with generic node attributes,
file nodes include the following attributes:
- `file` (required, string) is the path to the file within the system.
- `subpath` (optional, string) is a subpath that may link to a heading or a block. Always starts with a `#`.
### Link type nodes
Link type nodes reference a URL. Along with generic node attributes, link nodes include the following attribute:
- `url` (required, string)
### Group type nodes
Group type nodes are used as a visual container for nodes within it. Along with generic node attributes, group nodes
include the following attributes:
- `label` (optional, string) is a text label for the group.
- `background` (optional, string) is the path to the background image.
- `backgroundStyle` (optional, string) is the rendering style of the background image. Valid values:
- `cover` fills the entire width and height of the node.
- `ratio` maintains the aspect ratio of the background image.
- `repeat` repeats the image as a pattern in both x/y directions.
## Edges
Edges are lines that connect one node to another.
- `id` (required, string) is a unique ID for the edge.
- `fromNode` (required, string) is the node `id` where the connection starts.
- `fromSide` (optional, string) is the side where this edge starts. Valid values:
- `top`
- `right`
- `bottom`
- `left`
- `fromEnd` (optional, string) is the shape of the endpoint at the edge start. Defaults to `none` if not specified.
Valid values:
- `none`
- `arrow`
- `toNode` (required, string) is the node `id` where the connection ends.
- `toSide` (optional, string) is the side where this edge ends. Valid values:
- `top`
- `right`
- `bottom`
- `left`
- `toEnd` (optional, string) is the shape of the endpoint at the edge end. Defaults to `arrow` if not specified. Valid
values:
- `none`
- `arrow`
- `color` (optional, `canvasColor`) is the color of the line, see the Color section.
- `label` (optional, string) is a text label for the edge.
## Color
The `canvasColor` type is used to encode color data for nodes and edges. Colors attributes expect a string. Colors can
be specified in hex format e.g. `"#FF0000"`, or using one of the preset colors, e.g. `"1"` for red. Six preset colors
exist, mapped to the following numbers:
- `"1"` red
- `"2"` orange
- `"3"` yellow
- `"4"` green
- `"5"` cyan
- `"6"` purple
Specific values for the preset colors are intentionally not defined so that applications can tailor the presets to their
specific brand colors or color scheme.
Binary file not shown.
+26
View File
@@ -0,0 +1,26 @@
# 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.
+64
View File
@@ -0,0 +1,64 @@
<?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>

After

Width:  |  Height:  |  Size: 2.0 KiB

+97
View File
@@ -0,0 +1,97 @@
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"],
"env": {
"BASIC_MEMORY_ENV": "user",
"LOGFIRE_TOKEN": "n2Fpvn34LjKYq8TdF1ZrXMgdBPXGn4HfXy6tYghZ55dB",
},
}
# 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()
+27
View File
@@ -0,0 +1,27 @@
#!/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
+40
View File
@@ -0,0 +1,40 @@
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,
)
+22 -11
View File
@@ -1,6 +1,6 @@
[project]
name = "basic-memory"
version = "0.1.1"
version = "0.8.0"
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
readme = "README.md"
requires-python = ">=3.12.1"
@@ -28,16 +28,11 @@ dependencies = [
"watchfiles>=1.0.4",
"fastapi[standard]>=0.115.8",
"alembic>=1.14.1",
"qasync>=0.27.1",
"logfire[fastapi,httpx,sqlalchemy,sqlite3]>=3.6.0",
"pillow>=11.1.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.4",
"pytest-cov>=4.1.0",
"pytest-mock>=3.12.0",
"pytest-asyncio>=0.24.0",
"ruff>=0.1.6",
]
[project.urls]
Homepage = "https://github.com/basicmachines-co/basic-memory"
@@ -64,10 +59,22 @@ target-version = "py312"
[tool.uv]
dev-dependencies = [
"gevent>=24.11.1",
"icecream>=2.1.3",
"pytest>=8.3.4",
"pytest-cov>=4.1.0",
"pytest-mock>=3.12.0",
"pytest-asyncio>=0.24.0",
"ruff>=0.1.6",
"pytest>=8.3.4",
"pytest-cov>=4.1.0",
"pytest-mock>=3.12.0",
"pytest-asyncio>=0.24.0",
"ruff>=0.1.6",
"cx-freeze>=7.2.10",
"pyqt6>=6.8.1",
]
[tool.pyright]
include = ["src/"]
exclude = ["**/__pycache__"]
@@ -79,7 +86,9 @@ pythonVersion = "3.12"
[tool.semantic_release]
version_variable = "src/basic_memory/__init__.py:__version__"
version_variables = [
"src/basic_memory/__init__.py:__version__",
]
version_toml = [
"pyproject.toml:project.version",
]
@@ -91,3 +100,5 @@ dist_path = "dist/"
upload_to_pypi = true
commit_message = "chore(release): {version} [skip ci]"
[tool.coverage.run]
concurrency = ["thread", "gevent"]
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
set -e
echo "Welcome to Basic Memory installer"
# 1. Install uv if not present
if ! command -v uv &> /dev/null; then
echo "Installing uv package manager..."
curl -LsSf https://github.com/astral-sh/uv/releases/download/0.1.23/uv-installer.sh | sh
fi
# 2. Configure Claude Desktop
echo "Configuring Claude Desktop..."
CONFIG_FILE="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
# Create config directory if it doesn't exist
mkdir -p "$(dirname "$CONFIG_FILE")"
# If config file doesn't exist, create it with initial structure
if [ ! -f "$CONFIG_FILE" ]; then
echo '{"mcpServers": {}}' > "$CONFIG_FILE"
fi
# Add/update the basic-memory config using jq
jq '.mcpServers."basic-memory" = {
"command": "uvx",
"args": ["basic-memory"]
}' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE"
echo "Installation complete! Basic Memory is now available in Claude Desktop."
echo "Please restart Claude Desktop for changes to take effect."
echo -e "\nQuick Start:"
echo "1. You can run sync directly using: uvx basic-memory sync"
echo "2. Optionally, install globally with: uv pip install basic-memory"
echo -e "\nBuilt with ♥️ by Basic Machines."
+1 -1
View File
@@ -1,3 +1,3 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.0.1"
__version__ = "0.8.0"
+119
View File
@@ -0,0 +1,119 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = .
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+97
View File
@@ -0,0 +1,97 @@
"""Alembic environment configuration."""
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
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
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Set the SQLAlchemy URL from our app config
sqlalchemy_url = f"sqlite:///{app_config.database_path}"
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
# print(f"Using SQLAlchemy URL: {sqlalchemy_url}")
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# Add this function to tell Alembic what to include/exclude
def include_object(object, name, type_, reflected, compare_to):
# Ignore SQLite FTS tables
if type_ == "table" and name.startswith("search_index"):
return False
return True
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
include_object=include_object,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
include_object=include_object,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+29
View File
@@ -0,0 +1,29 @@
"""Functions for managing database migrations."""
import asyncio
from pathlib import Path
from loguru import logger
from alembic.config import Config
from alembic import command
def get_alembic_config() -> Config: # pragma: no cover
"""Get alembic config with correct paths."""
migrations_path = Path(__file__).parent
alembic_ini = migrations_path.parent.parent.parent / "alembic.ini"
config = Config(alembic_ini)
config.set_main_option("script_location", str(migrations_path))
return config
async def reset_database(): # pragma: no cover
"""Drop and recreate all tables."""
logger.info("Resetting database...")
config = get_alembic_config()
def _reset(cfg):
command.downgrade(cfg, "base")
command.upgrade(cfg, "head")
await asyncio.get_event_loop().run_in_executor(None, _reset, config)
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,93 @@
"""initial schema
Revision ID: 3dae7c7b1564
Revises:
Create Date: 2025-02-12 21:23:00.336344
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3dae7c7b1564"
down_revision: Union[str, None] = None
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(
"entity",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("entity_type", sa.String(), nullable=False),
sa.Column("entity_metadata", sa.JSON(), nullable=True),
sa.Column("content_type", sa.String(), nullable=False),
sa.Column("permalink", sa.String(), nullable=False),
sa.Column("file_path", sa.String(), nullable=False),
sa.Column("checksum", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("permalink", name="uix_entity_permalink"),
)
op.create_index("ix_entity_created_at", "entity", ["created_at"], unique=False)
op.create_index(op.f("ix_entity_file_path"), "entity", ["file_path"], unique=True)
op.create_index(op.f("ix_entity_permalink"), "entity", ["permalink"], unique=True)
op.create_index("ix_entity_title", "entity", ["title"], unique=False)
op.create_index("ix_entity_type", "entity", ["entity_type"], unique=False)
op.create_index("ix_entity_updated_at", "entity", ["updated_at"], unique=False)
op.create_table(
"observation",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("entity_id", sa.Integer(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("category", sa.String(), nullable=False),
sa.Column("context", sa.Text(), nullable=True),
sa.Column("tags", sa.JSON(), server_default="[]", nullable=True),
sa.ForeignKeyConstraint(["entity_id"], ["entity.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_observation_category", "observation", ["category"], unique=False)
op.create_index("ix_observation_entity_id", "observation", ["entity_id"], unique=False)
op.create_table(
"relation",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("from_id", sa.Integer(), nullable=False),
sa.Column("to_id", sa.Integer(), nullable=True),
sa.Column("to_name", sa.String(), nullable=False),
sa.Column("relation_type", sa.String(), nullable=False),
sa.Column("context", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["from_id"], ["entity.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["to_id"], ["entity.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation"),
)
op.create_index("ix_relation_from_id", "relation", ["from_id"], unique=False)
op.create_index("ix_relation_to_id", "relation", ["to_id"], unique=False)
op.create_index("ix_relation_type", "relation", ["relation_type"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_relation_type", table_name="relation")
op.drop_index("ix_relation_to_id", table_name="relation")
op.drop_index("ix_relation_from_id", table_name="relation")
op.drop_table("relation")
op.drop_index("ix_observation_entity_id", table_name="observation")
op.drop_index("ix_observation_category", table_name="observation")
op.drop_table("observation")
op.drop_index("ix_entity_updated_at", table_name="entity")
op.drop_index("ix_entity_type", table_name="entity")
op.drop_index("ix_entity_title", table_name="entity")
op.drop_index(op.f("ix_entity_permalink"), table_name="entity")
op.drop_index(op.f("ix_entity_file_path"), table_name="entity")
op.drop_index("ix_entity_created_at", table_name="entity")
op.drop_table("entity")
# ### end Alembic commands ###
@@ -0,0 +1,51 @@
"""remove required from entity.permalink
Revision ID: 502b60eaa905
Revises: b3c3938bacdb
Create Date: 2025-02-24 13:33:09.790951
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "502b60eaa905"
down_revision: Union[str, None] = "b3c3938bacdb"
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! ###
with op.batch_alter_table("entity", schema=None) as batch_op:
batch_op.alter_column("permalink", existing_type=sa.VARCHAR(), nullable=True)
batch_op.drop_index("ix_entity_permalink")
batch_op.create_index(batch_op.f("ix_entity_permalink"), ["permalink"], unique=False)
batch_op.drop_constraint("uix_entity_permalink", type_="unique")
batch_op.create_index(
"uix_entity_permalink",
["permalink"],
unique=True,
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
)
# ### 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_index(
"uix_entity_permalink",
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
)
batch_op.create_unique_constraint("uix_entity_permalink", ["permalink"])
batch_op.drop_index(batch_op.f("ix_entity_permalink"))
batch_op.create_index("ix_entity_permalink", ["permalink"], unique=1)
batch_op.alter_column("permalink", existing_type=sa.VARCHAR(), nullable=False)
# ### end Alembic commands ###
@@ -0,0 +1,44 @@
"""relation to_name unique index
Revision ID: b3c3938bacdb
Revises: 3dae7c7b1564
Create Date: 2025-02-22 14:59:30.668466
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b3c3938bacdb"
down_revision: Union[str, None] = "3dae7c7b1564"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite doesn't support constraint changes through ALTER
# Need to recreate table with desired constraints
with op.batch_alter_table("relation") as batch_op:
# Drop existing unique constraint
batch_op.drop_constraint("uix_relation", type_="unique")
# Add new constraints
batch_op.create_unique_constraint(
"uix_relation_from_id_to_id", ["from_id", "to_id", "relation_type"]
)
batch_op.create_unique_constraint(
"uix_relation_from_id_to_name", ["from_id", "to_name", "relation_type"]
)
def downgrade() -> None:
with op.batch_alter_table("relation") as batch_op:
# Drop new constraints
batch_op.drop_constraint("uix_relation_from_id_to_name", type_="unique")
batch_op.drop_constraint("uix_relation_from_id_to_id", type_="unique")
# Restore original constraint
batch_op.create_unique_constraint("uix_relation", ["from_id", "to_id", "relation_type"])
+2 -1
View File
@@ -1,4 +1,5 @@
"""Basic Memory API module."""
from .app import app
__all__ = ["app"]
__all__ = ["app"]
+9 -25
View File
@@ -2,45 +2,25 @@
from contextlib import asynccontextmanager
import logfire
from fastapi import FastAPI, HTTPException
from fastapi.exception_handlers import http_exception_handler
from loguru import logger
from basic_memory import db
from basic_memory.config import config as app_config
from basic_memory.api.routers import knowledge, search, memory, resource
from basic_memory.config import config
from basic_memory.services import DatabaseService
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app: FastAPI): # pragma: no cover
"""Lifecycle manager for the FastAPI app."""
logger.info("Starting Basic Memory API")
# check the db state
await check_db(app)
await db.run_migrations(app_config)
yield
logger.info("Shutting down Basic Memory API")
await db.shutdown_db()
async def check_db(app: FastAPI):
logger.info("Checking database state")
# Initialize DB management service
db_service = DatabaseService(
config=config,
)
# Check and initialize DB if needed
if not await db_service.check_db():
raise RuntimeError("Database initialization failed")
# Clean up old backups on shutdown
await db_service.cleanup_backups()
# Initialize FastAPI app
app = FastAPI(
title="Basic Memory API",
@@ -49,6 +29,10 @@ app = FastAPI(
lifespan=lifespan,
)
if app_config != "test":
logfire.instrument_fastapi(app)
# Include routers
app.include_router(knowledge.router)
app.include_router(search.router)
@@ -57,7 +41,7 @@ app.include_router(resource.router)
@app.exception_handler(Exception)
async def exception_handler(request, exc):
async def exception_handler(request, exc): # pragma: no cover
logger.exception(
f"An unhandled exception occurred for request '{request.url}', exception: {exc}"
)
@@ -17,7 +17,7 @@ from basic_memory.schemas import (
DeleteEntitiesResponse,
DeleteEntitiesRequest,
)
from basic_memory.schemas.base import PathId, Entity
from basic_memory.schemas.base import Permalink, Entity
from basic_memory.services.exceptions import EntityNotFoundError
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
@@ -27,10 +27,10 @@ router = APIRouter(prefix="/knowledge", tags=["knowledge"])
@router.post("/entities", response_model=EntityResponse)
async def create_entity(
data: Entity,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service: SearchServiceDep,
data: Entity,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service: SearchServiceDep,
) -> EntityResponse:
"""Create an entity."""
logger.info(f"request: create_entity with data={data}")
@@ -47,12 +47,12 @@ async def create_entity(
@router.put("/entities/{permalink:path}", response_model=EntityResponse)
async def create_or_update_entity(
permalink: PathId,
data: Entity,
response: Response,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service: SearchServiceDep,
permalink: Permalink,
data: Entity,
response: Response,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service: SearchServiceDep,
) -> EntityResponse:
"""Create or update an entity. If entity exists, it will be updated, otherwise created."""
logger.info(f"request: create_or_update_entity with permalink={permalink}, data={data}")
@@ -69,7 +69,9 @@ async def create_or_update_entity(
await search_service.index_entity(entity, background_tasks=background_tasks)
result = EntityResponse.model_validate(entity)
logger.info(f"response: create_or_update_entity with result={result}, status_code={response.status_code}")
logger.info(
f"response: create_or_update_entity with result={result}, status_code={response.status_code}"
)
return result
@@ -78,8 +80,8 @@ async def create_or_update_entity(
@router.get("/entities/{permalink:path}", response_model=EntityResponse)
async def get_entity(
entity_service: EntityServiceDep,
permalink: str,
entity_service: EntityServiceDep,
permalink: str,
) -> EntityResponse:
"""Get a specific entity by ID.
@@ -92,28 +94,23 @@ async def get_entity(
try:
entity = await entity_service.get_by_permalink(permalink)
result = EntityResponse.model_validate(entity)
logger.info(f"response: get_entity with result={result}")
return result
except EntityNotFoundError:
logger.error(f"Error: Entity with {permalink} not found")
raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
@router.get("/entities", response_model=EntityListResponse)
async def get_entities(
entity_service: EntityServiceDep,
permalink: Annotated[list[str] | None, Query()] = None,
entity_service: EntityServiceDep,
permalink: Annotated[list[str] | None, Query()] = None,
) -> EntityListResponse:
"""Open specific entities"""
logger.info(f"request: get_entities with permalinks={permalink}")
entities = await entity_service.get_entities_by_permalinks(permalink)
entities = await entity_service.get_entities_by_permalinks(permalink) if permalink else []
result = EntityListResponse(
entities=[EntityResponse.model_validate(entity) for entity in entities]
)
logger.info(f"response: get_entities with result={result}")
return result
@@ -122,37 +119,35 @@ async def get_entities(
@router.delete("/entities/{identifier:path}", response_model=DeleteEntitiesResponse)
async def delete_entity(
identifier: str,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
link_resolver: LinkResolverDep,
search_service=Depends(get_search_service),
identifier: str,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
link_resolver: LinkResolverDep,
search_service=Depends(get_search_service),
) -> DeleteEntitiesResponse:
"""Delete a single entity and remove from search index."""
logger.info(f"request: delete_entity with identifier={identifier}")
entity = await link_resolver.resolve_link(identifier)
if entity is None:
logger.info("response: delete_entity with result=DeleteEntitiesResponse(deleted=False)")
return DeleteEntitiesResponse(deleted=False)
# Delete the entity
deleted = await entity_service.delete_entity(entity.permalink)
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)
result = DeleteEntitiesResponse(deleted=deleted)
logger.info(f"response: delete_entity with result={result}")
return result
@router.post("/entities/delete", response_model=DeleteEntitiesResponse)
async def delete_entities(
data: DeleteEntitiesRequest,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service=Depends(get_search_service),
data: DeleteEntitiesRequest,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service=Depends(get_search_service),
) -> DeleteEntitiesResponse:
"""Delete entities and remove from search index."""
logger.info(f"request: delete_entities with data={data}")
@@ -164,5 +159,4 @@ async def delete_entities(
background_tasks.add_task(search_service.delete_by_permalink, permalink)
result = DeleteEntitiesResponse(deleted=deleted)
logger.info(f"response: delete_entities with result={result}")
return result
+49 -27
View File
@@ -1,13 +1,11 @@
"""Routes for memory:// URI operations."""
from datetime import datetime, timedelta
from typing import Optional, List, Annotated
from typing import Annotated
from dateparser import parse
from fastapi import APIRouter, Query
from loguru import logger
from basic_memory.config import config
from basic_memory.deps import ContextServiceDep, EntityRepositoryDep
from basic_memory.repository import EntityRepository
from basic_memory.repository.search_repository import SearchIndexRow
@@ -17,7 +15,8 @@ from basic_memory.schemas.memory import (
RelationSummary,
EntitySummary,
ObservationSummary,
MemoryMetadata, normalize_memory_url,
MemoryMetadata,
normalize_memory_url,
)
from basic_memory.schemas.search import SearchItemType
from basic_memory.services.context_service import ContextResultRow
@@ -25,43 +24,54 @@ from basic_memory.services.context_service import ContextResultRow
router = APIRouter(prefix="/memory", tags=["memory"])
async def to_graph_context(context, entity_repository: EntityRepository):
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,
title=item.title, # pyright: ignore
permalink=item.permalink,
file_path=item.file_path,
created_at=item.created_at,
)
case SearchItemType.OBSERVATION:
return ObservationSummary(
category=item.category, content=item.content, permalink=item.permalink
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)
to_entity = await entity_repository.find_by_id(item.to_id)
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(
permalink=item.permalink,
title=item.title, # pyright: ignore
file_path=item.file_path,
permalink=item.permalink, # pyright: ignore
relation_type=item.type,
from_id=from_entity.permalink,
from_id=from_entity.permalink, # pyright: ignore
to_id=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
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,
@@ -69,7 +79,9 @@ async def recent(
type: Annotated[list[SearchItemType] | None, Query()] = None,
depth: int = 1,
timeframe: TimeFrame = "7d",
max_results: int = 10,
page: int = 1,
page_size: int = 10,
max_related: int = 10,
) -> GraphContext:
# return all types by default
types = (
@@ -79,19 +91,26 @@ async def recent(
)
logger.debug(
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
)
# Parse timeframe
since = parse(timeframe)
limit = page_size
offset = (page - 1) * page_size
# Build context
context = await context_service.build_context(
types=types, depth=depth, since=since, max_results=max_results
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
)
return await to_graph_context(context, entity_repository=entity_repository)
recent_context = await to_graph_context(
context, entity_repository=entity_repository, page=page, page_size=page_size
)
logger.debug(f"Recent context: {recent_context.model_dump_json()}")
return recent_context
# get_memory_context needs to be declared last so other paths can match
# get_memory_context needs to be declared last so other paths can match
@router.get("/{uri:path}", response_model=GraphContext)
async def get_memory_context(
@@ -100,24 +119,27 @@ async def get_memory_context(
uri: str,
depth: int = 1,
timeframe: TimeFrame = "7d",
max_results: int = 10,
page: int = 1,
page_size: int = 10,
max_related: int = 10,
) -> GraphContext:
"""Get rich context from memory:// URI."""
# add the project name from the config to the url as the "host
# Parse URI
logger.debug(
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
)
memory_url = normalize_memory_url(uri)
# Parse timeframe
since = parse(timeframe)
limit = page_size
offset = (page - 1) * page_size
# Build context
context = await context_service.build_context(
memory_url, depth=depth, since=since, max_results=max_results
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
)
return await to_graph_context(
context, entity_repository=entity_repository, page=page, page_size=page_size
)
return await to_graph_context(context, entity_repository=entity_repository)
+204 -13
View File
@@ -1,34 +1,225 @@
"""Routes for getting entity content."""
import tempfile
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body
from fastapi.responses import FileResponse, JSONResponse
from loguru import logger
from basic_memory.deps import ProjectConfigDep, LinkResolverDep
from basic_memory.deps import (
ProjectConfigDep,
LinkResolverDep,
SearchServiceDep,
EntityServiceDep,
FileServiceDep,
EntityRepositoryDep,
)
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.schemas.memory import normalize_memory_url
from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.models.knowledge import Entity as EntityModel
from datetime import datetime
router = APIRouter(prefix="/resource", tags=["resources"])
def get_entity_ids(item: SearchIndexRow) -> set[int]:
match item.type:
case SearchItemType.ENTITY:
return {item.id}
case SearchItemType.OBSERVATION:
return {item.entity_id} # pyright: ignore [reportReturnType]
case SearchItemType.RELATION:
from_entity = item.from_id
to_entity = item.to_id # pyright: ignore [reportReturnType]
return {from_entity, to_entity} if to_entity else {from_entity} # pyright: ignore [reportReturnType]
case _: # pragma: no cover
raise ValueError(f"Unexpected type: {item.type}")
@router.get("/{identifier:path}")
async def get_resource_content(
config: ProjectConfigDep,
link_resolver: LinkResolverDep,
search_service: SearchServiceDep,
entity_service: EntityServiceDep,
file_service: FileServiceDep,
background_tasks: BackgroundTasks,
identifier: str,
page: int = 1,
page_size: int = 10,
) -> FileResponse:
"""Get resource content by identifier: name or permalink."""
logger.debug(f"Getting content for permalink: {identifier}")
logger.debug(f"Getting content for: {identifier}")
# Find entity by permalink
# Find single entity by permalink
entity = await link_resolver.resolve_link(identifier)
if not entity:
raise HTTPException(status_code=404, detail=f"Entity not found: {identifier}")
results = [entity] if entity else []
file_path = Path(f"{config.home}/{entity.file_path}")
if not file_path.exists():
raise HTTPException(
status_code=404,
detail=f"File not found: {file_path}",
# pagination for multiple results
limit = page_size
offset = (page - 1) * page_size
# search using the identifier as a permalink
if not results:
# if the identifier contains a wildcard, use GLOB search
query = (
SearchQuery(permalink_match=identifier)
if "*" in identifier
else SearchQuery(permalink=identifier)
)
return FileResponse(path=file_path)
search_results = await search_service.search(query, limit, offset)
if not search_results:
raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
# get the deduplicated entities related to the search results
entity_ids = {id for result in search_results for id in get_entity_ids(result)}
results = await entity_service.get_entities_by_id(list(entity_ids))
# return single response
if len(results) == 1:
entity = results[0]
file_path = Path(f"{config.home}/{entity.file_path}")
if not file_path.exists():
raise HTTPException(
status_code=404,
detail=f"File not found: {file_path}",
)
return FileResponse(path=file_path)
# for multiple files, initialize a temporary file for writing the results
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
temp_file_path = tmp_file.name
for result in results:
# Read content for each entity
content = await file_service.read_entity_content(result)
memory_url = normalize_memory_url(result.permalink)
modified_date = result.updated_at.isoformat()
checksum = result.checksum[:8] if result.checksum else ""
# Prepare the delimited content
response_content = f"--- {memory_url} {modified_date} {checksum}\n"
response_content += f"\n{content}\n"
response_content += "\n"
# Write content directly to the temporary file in append mode
tmp_file.write(response_content)
# Ensure all content is written to disk
tmp_file.flush()
# Schedule the temporary file to be deleted after the response
background_tasks.add_task(cleanup_temp_file, temp_file_path)
# Return the file response
return FileResponse(path=temp_file_path)
def cleanup_temp_file(file_path: str):
"""Delete the temporary file."""
try:
Path(file_path).unlink() # Deletes the file
logger.debug(f"Temporary file deleted: {file_path}")
except Exception as e: # pragma: no cover
logger.error(f"Error deleting temporary file {file_path}: {e}")
@router.put("/{file_path:path}")
async def write_resource(
config: ProjectConfigDep,
file_service: FileServiceDep,
entity_repository: EntityRepositoryDep,
search_service: SearchServiceDep,
file_path: str,
content: Annotated[str, Body()],
) -> JSONResponse:
"""Write content to a file in the project.
This endpoint allows writing content directly to a file in the project.
Also creates an entity record and indexes the file for search.
Args:
file_path: Path to write to, relative to project root
request: Contains the content to write
Returns:
JSON response with file information
"""
try:
# Get content from request body
# Ensure it's UTF-8 string content
if isinstance(content, bytes): # pragma: no cover
content_str = content.decode("utf-8")
else:
content_str = str(content)
# Get full file path
full_path = Path(f"{config.home}/{file_path}")
# Ensure parent directory exists
full_path.parent.mkdir(parents=True, exist_ok=True)
# Write content to file
checksum = await file_service.write_file(full_path, content_str)
# Get file info
file_stats = file_service.file_stats(full_path)
# Determine file details
file_name = Path(file_path).name
content_type = file_service.content_type(full_path)
entity_type = "canvas" if file_path.endswith(".canvas") else "file"
# Check if entity already exists
existing_entity = await entity_repository.get_by_file_path(file_path)
if existing_entity:
# Update existing entity
entity = await entity_repository.update(
existing_entity.id,
{
"title": file_name,
"entity_type": entity_type,
"content_type": content_type,
"file_path": file_path,
"checksum": checksum,
"updated_at": datetime.fromtimestamp(file_stats.st_mtime),
},
)
status_code = 200
else:
# Create a new entity model
entity = EntityModel(
title=file_name,
entity_type=entity_type,
content_type=content_type,
file_path=file_path,
checksum=checksum,
created_at=datetime.fromtimestamp(file_stats.st_ctime),
updated_at=datetime.fromtimestamp(file_stats.st_mtime),
)
entity = await entity_repository.add(entity)
status_code = 201
# Index the file for search
await search_service.index_entity(entity) # pyright: ignore
# Return success response
return JSONResponse(
status_code=status_code,
content={
"file_path": file_path,
"checksum": checksum,
"size": file_stats.st_size,
"created_at": file_stats.st_ctime,
"modified_at": file_stats.st_mtime,
},
)
except Exception as e: # pragma: no cover
logger.error(f"Error writing resource {file_path}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to write resource: {str(e)}")
+18 -16
View File
@@ -1,34 +1,36 @@
"""Router for search operations."""
from dataclasses import asdict
from fastapi import APIRouter, Depends, BackgroundTasks
from typing import List
from fastapi import APIRouter, BackgroundTasks
from loguru import logger
from basic_memory.services.search_service import SearchService
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchResponse
from basic_memory.deps import get_search_service
from basic_memory.deps import SearchServiceDep
router = APIRouter(prefix="/search", tags=["search"])
@router.post("/", response_model=SearchResponse)
async def search(
query: SearchQuery,
search_service: SearchService = Depends(get_search_service)
search_service: SearchServiceDep,
page: int = 1,
page_size: int = 10,
):
"""Search across all knowledge and documents."""
results = await search_service.search(query)
limit = page_size
offset = (page - 1) * page_size
results = await search_service.search(query, limit=limit, offset=offset)
search_results = [SearchResult.model_validate(asdict(r)) for r in results]
return SearchResponse(results=search_results)
return SearchResponse(
results=search_results,
current_page=page,
page_size=page_size,
)
@router.post("/reindex")
async def reindex(
background_tasks: BackgroundTasks,
search_service: SearchService = Depends(get_search_service)
):
async def reindex(background_tasks: BackgroundTasks, search_service: SearchServiceDep):
"""Recreate and populate the search index."""
await search_service.reindex_all(background_tasks=background_tasks)
return {
"status": "ok",
"message": "Reindex initiated"
}
return {"status": "ok", "message": "Reindex initiated"}
+1 -1
View File
@@ -1 +1 @@
"""CLI tools for basic-memory"""
"""CLI tools for basic-memory"""
+15 -1
View File
@@ -1,4 +1,18 @@
import asyncio
import typer
app = typer.Typer()
from basic_memory import db
from basic_memory.config import config
asyncio.run(db.run_migrations(config))
app = typer.Typer(name="basic-memory")
import_app = typer.Typer()
app.add_typer(import_app, name="import")
claude_app = typer.Typer()
import_app.add_typer(claude_app, name="claude")
+3 -3
View File
@@ -1,5 +1,5 @@
"""Command module exports."""
"""CLI commands for basic-memory."""
from . import status, sync, import_memory_json
from . import status, sync, db, import_memory_json, mcp
__all__ = [ "status", "sync", "import_memory_json.py"]
__all__ = ["status", "sync", "db", "import_memory_json", "mcp"]
+28
View File
@@ -0,0 +1,28 @@
"""Database management commands."""
import asyncio
import logfire
import typer
from loguru import logger
from basic_memory.alembic import migrations
from basic_memory.cli.app import app
@app.command()
def reset(
reindex: bool = typer.Option(False, "--reindex", help="Rebuild indices from filesystem"),
): # pragma: no cover
"""Reset database (drop all tables and recreate)."""
with logfire.span("reset"): # pyright: ignore [reportGeneralTypeIssues]
if typer.confirm("This will delete all data in your db. Are you sure?"):
logger.info("Resetting database...")
asyncio.run(migrations.reset_database())
if reindex:
# Import and run sync
from basic_memory.cli.commands.sync import sync
logger.info("Rebuilding search index from filesystem...")
sync(watch=False) # pyright: ignore
@@ -0,0 +1,263 @@
"""Import command for ChatGPT conversations."""
import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, List, Annotated, Set, Optional
import logfire
import typer
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
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())
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)
return MarkdownProcessor(entity_parser)
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
def import_chatgpt(
conversations_json: Annotated[
Path, typer.Argument(help="Path to ChatGPT conversations.json file")
] = Path("conversations.json"),
folder: Annotated[
str, typer.Option(help="The folder to place the files in.")
] = "conversations",
):
"""Import chat conversations from ChatGPT JSON format.
This command will:
1. Read the complex tree structure of messages
2. Convert them to linear markdown conversations
3. Save as clean, readable markdown files
After importing, run 'basic-memory sync' to index the new files.
"""
with logfire.span("import chatgpt"): # pyright: ignore [reportGeneralTypeIssues]
try:
if conversations_json:
if not conversations_json.exists():
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())
# 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,
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
@@ -0,0 +1,213 @@
"""Import command for basic-memory CLI to import chat data from conversations2.json format."""
import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, List, Annotated
import logfire
import typer
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 claude_app
from basic_memory.config import config
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
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())
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)
return MarkdownProcessor(entity_parser)
@claude_app.command(name="conversations", help="Import chat conversations from Claude.ai.")
def import_claude(
conversations_json: Annotated[
Path, typer.Argument(..., help="Path to conversations.json file")
] = Path("conversations.json"),
folder: Annotated[
str, typer.Option(help="The folder to place the files in.")
] = "conversations",
):
"""Import chat conversations from conversations2.json format.
This command will:
1. Read chat data and nested messages
2. Create markdown files for each conversation
3. Format content in clean, readable markdown
After importing, run 'basic-memory sync' to index the new files.
"""
with logfire.span("import claude conversations"): # pyright: ignore [reportGeneralTypeIssues]
try:
if not conversations_json.exists():
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())
# 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)
)
# 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,
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
@@ -0,0 +1,196 @@
"""Import command for basic-memory CLI to import project data from Claude.ai."""
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, Annotated, Optional
import logfire
import typer
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 claude_app
from basic_memory.config import config
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
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())
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)
return MarkdownProcessor(entity_parser)
@claude_app.command(name="projects", help="Import projects from Claude.ai.")
def import_projects(
projects_json: Annotated[Path, typer.Argument(..., help="Path to projects.json file")] = Path(
"projects.json"
),
base_folder: Annotated[
str, typer.Option(help="The base folder to place project files in.")
] = "projects",
):
"""Import project data from Claude.ai.
This command will:
1. Create a directory for each project
2. Store docs in a docs/ subdirectory
3. Place prompt template in project root
After importing, run 'basic-memory sync' to index the new files.
"""
with logfire.span("import claude projects"): # pyright: ignore [reportGeneralTypeIssues]
try:
if projects_json:
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())
# 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,
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
@@ -3,29 +3,32 @@
import asyncio
import json
from pathlib import Path
from typing import Dict, Any, List
from typing import Dict, Any, List, Annotated
import logfire
import typer
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 app
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):
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}"),
@@ -34,12 +37,12 @@ async def process_memory_json(json_path: Path, base_path: Path,markdown_processo
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":
@@ -52,14 +55,14 @@ async def process_memory_json(json_path: Path, base_path: Path,markdown_processo
entity_relations[source].append(
Relation(
type=data.get("relationType") or data.get("relation_type"),
target=data.get("to") or data.get("to_id")
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(
@@ -67,26 +70,25 @@ async def process_memory_json(json_path: Path, base_path: Path,markdown_processo
metadata={
"type": entity_data["entityType"],
"title": name,
"permalink": f"{entity_data['entityType']}/{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
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())
"relations": sum(len(rels) for rels in entity_relations.values()),
}
@@ -96,44 +98,49 @@ async def get_markdown_processor() -> MarkdownProcessor:
return MarkdownProcessor(entity_parser)
@app.command()
def import_json(
json_path: Path = typer.Argument(..., help="Path to memory.json file to import"),
@import_app.command()
def memory_json(
json_path: Annotated[Path, typer.Argument(..., help="Path to memory.json file")] = Path(
"memory.json"
),
):
"""Import entities and relations from a memory.json file.
This command will:
1. Read entities and relations from the JSON file
2. Create markdown files for each entity
3. Include outgoing relations in each entity's markdown
After importing, run 'basic-memory sync' to index the new files.
"""
if not json_path.exists():
typer.echo(f"Error: File not found: {json_path}", err=True)
raise typer.Exit(1)
try:
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home
console.print(f"\nImporting from {json_path}...writing to {base_path}")
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
# Show results
console.print(Panel(
f"[green]Import complete![/green]\n\n"
f"Created {results['entities']} entities\n"
f"Added {results['relations']} relations",
expand=False
))
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.exception("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
with logfire.span("import memory_json"): # pyright: ignore [reportGeneralTypeIssues]
if not json_path.exists():
typer.echo(f"Error: File not found: {json_path}", err=True)
raise typer.Exit(1)
try:
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home
console.print(f"\nImporting from {json_path}...writing to {base_path}")
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Created {results['entities']} entities\n"
f"Added {results['relations']} relations",
expand=False,
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
+20
View File
@@ -0,0 +1,20 @@
"""MCP server command."""
from loguru import logger
from basic_memory.cli.app import app
from basic_memory.config import config
# Import mcp instance
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
@app.command()
def mcp(): # pragma: no cover
"""Run the MCP server for Claude Desktop integration."""
home_dir = config.home
logger.info(f"Starting Basic Memory MCP server {basic_memory.__version__}")
logger.info(f"Home directory: {home_dir}")
mcp_server.run()
+21 -29
View File
@@ -3,36 +3,26 @@
import asyncio
from typing import Set, Dict
import logfire
import typer
from loguru import logger
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.db import DatabaseType
from basic_memory.repository import EntityRepository
from basic_memory.sync import FileChangeScanner
from basic_memory.sync.utils import SyncReport
from basic_memory.sync import SyncService
from basic_memory.sync.sync_service import SyncReport
# Create rich console
console = Console()
async def get_file_change_scanner(db_type=DatabaseType.FILESYSTEM) -> FileChangeScanner:
"""Get sync service instance."""
async with db.engine_session_factory(db_path=config.database_path, db_type=db_type) as (
engine,
session_maker,
):
entity_repository = EntityRepository(session_maker)
file_change_scanner = FileChangeScanner(entity_repository)
return file_change_scanner
def add_files_to_tree(tree: Tree, paths: Set[str], style: str, checksums: Dict[str, str] = None):
def add_files_to_tree(
tree: Tree, paths: Set[str], style: str, checksums: Dict[str, str] | None = None
):
"""Add files to tree, grouped by directory."""
# Group by directory
by_dir = {}
@@ -101,7 +91,7 @@ def display_changes(title: str, changes: SyncReport, verbose: bool = False):
"""Display changes using Rich for better visualization."""
tree = Tree(title)
if changes.total_changes == 0:
if changes.total == 0:
tree.add("No changes")
console.print(Panel(tree, expand=False))
return
@@ -126,16 +116,17 @@ def display_changes(title: str, changes: SyncReport, verbose: bool = False):
by_dir = group_changes_by_directory(changes)
for dir_name, counts in sorted(by_dir.items()):
summary = build_directory_summary(counts)
tree.add(f"[bold]{dir_name}/[/bold] {summary}")
if summary: # Only show directories with changes
tree.add(f"[bold]{dir_name}/[/bold] {summary}")
console.print(Panel(tree, expand=False))
async def run_status(sync_service: FileChangeScanner, verbose: bool = False):
async def run_status(sync_service: SyncService, verbose: bool = False):
"""Check sync status of files vs database."""
# Check knowledge/ directory
knowledge_changes = await sync_service.find_knowledge_changes(config.home)
display_changes("Knowledge Files", knowledge_changes, verbose)
knowledge_changes = await sync_service.scan(config.home)
display_changes("Status", knowledge_changes, verbose)
@app.command()
@@ -143,10 +134,11 @@ def status(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
):
"""Show sync status between files and database."""
try:
sync_service = asyncio.run(get_file_change_scanner())
asyncio.run(run_status(sync_service, verbose))
except Exception as e:
logger.exception(f"Error checking status: {e}")
typer.echo(f"Error checking status: {e}", err=True)
raise typer.Exit(1)
with logfire.span("status"): # pyright: ignore [reportGeneralTypeIssues]
try:
sync_service = asyncio.run(get_sync_service())
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
except Exception as e:
logger.exception(f"Error checking status: {e}")
typer.echo(f"Error checking status: {e}", err=True)
raise typer.Exit(code=1) # pragma: no cover
+48 -100
View File
@@ -9,15 +9,11 @@ from typing import List, Dict
import typer
from loguru import logger
from rich.console import Console
from rich.padding import Padding
from rich.panel import Panel
from rich.text import Text
from rich.tree import Tree
from basic_memory import db
from basic_memory.cli.app import app
from basic_memory.config import config
from basic_memory.db import DatabaseType
from basic_memory.markdown import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.repository import (
@@ -29,8 +25,8 @@ from basic_memory.repository.search_repository import SearchRepository
from basic_memory.services import EntityService, FileService
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.sync import SyncService, FileChangeScanner
from basic_memory.sync.utils import SyncReport
from basic_memory.sync import SyncService
from basic_memory.sync.sync_service import SyncReport
from basic_memory.sync.watch_service import WatchService
console = Console()
@@ -42,50 +38,47 @@ class ValidationIssue:
error: str
async def get_sync_service(db_type=DatabaseType.FILESYSTEM):
async def get_sync_service(): # pragma: no cover
"""Get sync service instance with all dependencies."""
async with db.engine_session_factory(db_path=config.database_path, db_type=db_type) as (
engine,
session_maker,
):
entity_parser = EntityParser(config.home)
markdown_processor = MarkdownProcessor(entity_parser)
file_service = FileService(config.home, markdown_processor)
_, session_maker = await db.get_or_create_db(
db_path=config.database_path, db_type=db.DatabaseType.FILESYSTEM
)
# Initialize repositories
entity_repository = EntityRepository(session_maker)
observation_repository = ObservationRepository(session_maker)
relation_repository = RelationRepository(session_maker)
search_repository = SearchRepository(session_maker)
entity_parser = EntityParser(config.home)
markdown_processor = MarkdownProcessor(entity_parser)
file_service = FileService(config.home, markdown_processor)
# Initialize services
search_service = SearchService(search_repository, entity_repository, file_service)
link_resolver = LinkResolver(entity_repository, search_service)
# Initialize repositories
entity_repository = EntityRepository(session_maker)
observation_repository = ObservationRepository(session_maker)
relation_repository = RelationRepository(session_maker)
search_repository = SearchRepository(session_maker)
# Initialize scanner
file_change_scanner = FileChangeScanner(entity_repository)
# Initialize services
search_service = SearchService(search_repository, entity_repository, file_service)
link_resolver = LinkResolver(entity_repository, search_service)
# Initialize services
entity_service = EntityService(
entity_parser,
entity_repository,
observation_repository,
relation_repository,
file_service,
link_resolver,
)
# Initialize services
entity_service = EntityService(
entity_parser,
entity_repository,
observation_repository,
relation_repository,
file_service,
link_resolver,
)
# Create sync service
sync_service = SyncService(
scanner=file_change_scanner,
entity_service=entity_service,
entity_parser=entity_parser,
entity_repository=entity_repository,
relation_repository=relation_repository,
search_service=search_service,
)
# Create sync service
sync_service = SyncService(
entity_service=entity_service,
entity_parser=entity_parser,
entity_repository=entity_repository,
relation_repository=relation_repository,
search_service=search_service,
file_service=file_service,
)
return sync_service
return sync_service
def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[ValidationIssue]]:
@@ -97,56 +90,9 @@ def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[V
return dict(grouped)
def display_validation_errors(issues: List[ValidationIssue]):
"""Display validation errors in a rich, organized format."""
# Create header
console.print()
console.print(
Panel("[red bold]Error:[/red bold] Invalid frontmatter in knowledge files", expand=False)
)
console.print()
# Group issues by directory
grouped_issues = group_issues_by_directory(issues)
# Create tree structure
tree = Tree("Knowledge Files")
for dir_name, dir_issues in sorted(grouped_issues.items()):
# Create branch for directory
branch = tree.add(
f"[bold blue]{dir_name}/[/bold blue] ([yellow]{len(dir_issues)} files[/yellow])"
)
# Add each file issue
for issue in sorted(dir_issues, key=lambda x: x.file_path):
file_name = Path(issue.file_path).name
branch.add(
Text.assemble(("└─ ", "dim"), (file_name, "yellow"), ": ", (issue.error, "red"))
)
# Display tree
console.print(Padding(tree, (1, 2)))
# Add help text
console.print()
console.print(
Panel(
Text.assemble(
("To fix:", "bold"),
"\n1. Add required frontmatter fields to each file",
"\n2. Run ",
("basic-memory sync", "bold cyan"),
" again",
),
expand=False,
)
)
console.print()
def display_sync_summary(knowledge: SyncReport):
"""Display a one-line summary of sync changes."""
total_changes = knowledge.total_changes
total_changes = knowledge.total
if total_changes == 0:
console.print("[green]Everything up to date[/green]")
return
@@ -172,13 +118,13 @@ def display_sync_summary(knowledge: SyncReport):
def display_detailed_sync_results(knowledge: SyncReport):
"""Display detailed sync results with trees."""
if knowledge.total_changes == 0:
if knowledge.total == 0:
console.print("\n[green]Everything up to date[/green]")
return
console.print("\n[bold]Sync Results[/bold]")
if knowledge.total_changes > 0:
if knowledge.total > 0:
knowledge_tree = Tree("[bold]Knowledge Files[/bold]")
if knowledge.new:
created = knowledge_tree.add("[green]Created[/green]")
@@ -202,7 +148,7 @@ def display_detailed_sync_results(knowledge: SyncReport):
console.print(knowledge_tree)
async def run_sync(verbose: bool = False, watch: bool = False):
async def run_sync(verbose: bool = False, watch: bool = False, console_status: bool = False):
"""Run sync operation."""
sync_service = await get_sync_service()
@@ -212,10 +158,12 @@ async def run_sync(verbose: bool = False, watch: bool = False):
watch_service = WatchService(
sync_service=sync_service,
file_service=sync_service.entity_service.file_service,
config=config
config=config,
)
await watch_service.handle_changes(config.home)
await watch_service.run()
# full sync
await sync_service.sync(config.home)
# watch changes
await watch_service.run() # pragma: no cover
else:
# one time sync
knowledge_changes = await sync_service.sync(config.home)
@@ -223,7 +171,7 @@ async def run_sync(verbose: bool = False, watch: bool = False):
if verbose:
display_detailed_sync_results(knowledge_changes)
else:
display_sync_summary(knowledge_changes)
display_sync_summary(knowledge_changes) # pragma: no cover
@app.command()
@@ -246,9 +194,9 @@ def sync(
# Run sync
asyncio.run(run_sync(verbose=verbose, watch=watch))
except Exception as e:
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Sync failed")
logger.exception("Sync failed", e)
typer.echo(f"Error during sync: {e}", err=True)
raise typer.Exit(1)
raise
+180
View File
@@ -0,0 +1,180 @@
"""CLI tool commands for Basic Memory."""
import asyncio
from typing import Optional, List, Annotated
import typer
from loguru import logger
from rich import print as rprint
from basic_memory.cli.app import app
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import get_entity as mcp_get_entity
from basic_memory.mcp.tools import read_note as mcp_read_note
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
from basic_memory.mcp.tools import search as mcp_search
from basic_memory.mcp.tools import write_note as mcp_write_note
# Import prompts
from basic_memory.mcp.prompts.continue_conversation import (
continue_conversation as mcp_continue_conversation,
)
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import MemoryUrl
from basic_memory.schemas.search import SearchQuery, SearchItemType
tool_app = typer.Typer()
app.add_typer(tool_app, name="tools", help="cli versions mcp tools")
@tool_app.command()
def write_note(
title: Annotated[str, typer.Option(help="The title of the note")],
content: Annotated[str, typer.Option(help="The content of the note")],
folder: Annotated[str, typer.Option(help="The folder to create the note in")],
tags: Annotated[
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
] = None,
):
try:
note = asyncio.run(mcp_write_note(title, content, folder, tags))
rprint(note)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during write_note: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def read_note(identifier: str, page: int = 1, page_size: int = 10):
try:
note = asyncio.run(mcp_read_note(identifier, page, page_size))
rprint(note)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during read_note: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def build_context(
url: MemoryUrl,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
page_size: int = 10,
max_related: int = 10,
):
try:
context = asyncio.run(
mcp_build_context(
url=url,
depth=depth,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
)
)
rprint(context.model_dump_json(indent=2))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during build_context: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def recent_activity(
type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
page_size: int = 10,
max_related: int = 10,
):
try:
context = asyncio.run(
mcp_recent_activity(
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
)
)
rprint(context.model_dump_json(indent=2))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during build_context: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def search(
query: str,
permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
after_date: Annotated[
Optional[str],
typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
] = None,
page: int = 1,
page_size: int = 10,
):
if permalink and title: # pragma: no cover
print("Cannot search both permalink and title")
raise typer.Abort()
try:
search_query = SearchQuery(
permalink_match=query if permalink else None,
text=query if not (permalink or title) else None,
title=query if title else None,
after_date=after_date,
)
results = asyncio.run(mcp_search(query=search_query, page=page, page_size=page_size))
rprint(results.model_dump_json(indent=2))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Error during search", e)
typer.echo(f"Error during search: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def get_entity(identifier: str):
try:
entity = asyncio.run(mcp_get_entity(identifier=identifier))
rprint(entity.model_dump_json(indent=2))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during get_entity: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command(name="continue-conversation")
def continue_conversation(
topic: Annotated[Optional[str], typer.Option(help="Topic or keyword to search for")] = None,
timeframe: Annotated[
Optional[str], typer.Option(help="How far back to look for activity")
] = None,
):
"""Continue a previous conversation or work session."""
try:
# Prompt functions return formatted strings directly
session = asyncio.run(mcp_continue_conversation(topic=topic, timeframe=timeframe))
rprint(session)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception("Error continuing conversation", e)
typer.echo(f"Error continuing conversation: {e}", err=True)
raise typer.Exit(1)
raise
+13 -41
View File
@@ -1,47 +1,19 @@
"""Main CLI entry point for basic-memory."""
import sys
"""Main CLI entry point for basic-memory.""" # pragma: no cover
import typer
from loguru import logger
from basic_memory.cli.app import app
from basic_memory.cli.app import app # pragma: no cover
# Register commands
from basic_memory.cli.commands import status, sync
__all__ = ["status", "sync"]
from basic_memory.config import config
def setup_logging(home_dir: str = config.home, log_file: str = ".basic-memory/basic-memory-tools.log"):
"""Configure logging for the application."""
# Remove default handler and any existing handlers
logger.remove()
# Add file handler for debug level logs
log = f"{home_dir}/{log_file}"
logger.add(
log,
level="DEBUG",
rotation="100 MB",
retention="10 days",
backtrace=True,
diagnose=True,
enqueue=True,
colorize=False,
)
# Add stderr handler for warnings and errors only
logger.add(
sys.stderr,
level="WARNING",
backtrace=True,
diagnose=True
)
# Set up logging when module is imported
setup_logging()
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
status,
sync,
db,
import_memory_json,
mcp,
import_claude_conversations,
import_claude_projects,
import_chatgpt,
tools,
)
if __name__ == "__main__": # pragma: no cover
app()
+21 -2
View File
@@ -1,18 +1,26 @@
"""Configuration management for basic-memory."""
from pathlib import Path
from typing import Literal
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
DATABASE_NAME = "memory.db"
DATA_DIR_NAME = ".basic-memory"
Environment = Literal["test", "dev", "user"]
class ProjectConfig(BaseSettings):
"""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",
@@ -27,6 +35,8 @@ class ProjectConfig(BaseSettings):
default=500, description="Milliseconds to wait after changes before syncing", gt=0
)
log_level: str = "DEBUG"
model_config = SettingsConfigDict(
env_prefix="BASIC_MEMORY_",
extra="ignore",
@@ -45,7 +55,7 @@ class ProjectConfig(BaseSettings):
@field_validator("home")
@classmethod
def ensure_path_exists(cls, v: Path) -> Path:
def ensure_path_exists(cls, v: Path) -> Path: # pragma: no cover
"""Ensure project path exists."""
if not v.exists():
v.mkdir(parents=True)
@@ -54,4 +64,13 @@ class ProjectConfig(BaseSettings):
# Load project config
config = ProjectConfig()
logger.info(f"project config home: {config.home}")
# setup logging
setup_logging(
env=config.env,
home_dir=config.home,
log_level=config.log_level,
log_file=".basic-memory/basic-memory.log",
console=False,
)
logger.info(f"Starting Basic Memory {basic_memory.__version__}")
+48 -46
View File
@@ -4,6 +4,11 @@ from enum import Enum, auto
from pathlib import Path
from typing import AsyncGenerator, Optional
from basic_memory.config import ProjectConfig
from alembic import command
from alembic.config import Config
from loguru import logger
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
@@ -14,8 +19,6 @@ from sqlalchemy.ext.asyncio import (
async_scoped_session,
)
from basic_memory.models import Base, SCHEMA_VERSION
from basic_memory.models.search import CREATE_SEARCH_INDEX
from basic_memory.repository.search_repository import SearchRepository
# Module level state
@@ -36,7 +39,7 @@ class DatabaseType(Enum):
logger.info("Using in-memory SQLite database")
return "sqlite+aiosqlite://"
return f"sqlite+aiosqlite:///{db_path}"
return f"sqlite+aiosqlite:///{db_path}" # pragma: no cover
def get_scoped_session_factory(
@@ -70,40 +73,10 @@ async def scoped_session(
await factory.remove()
async def init_db() -> None:
"""Initialize database with required tables."""
logger.info("Initializing database...")
async with scoped_session(_session_maker) as session:
await session.execute(text("PRAGMA foreign_keys=ON"))
conn = await session.connection()
await conn.run_sync(Base.metadata.create_all)
# recreate search index
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
async def drop_db():
"""Drop all database tables."""
global _engine, _session_maker
logger.info("Dropping tables...")
async with scoped_session(_session_maker) as session:
conn = await session.connection()
await conn.run_sync(Base.metadata.drop_all)
await session.commit()
# reset global engine and session_maker
_engine = None
_session_maker = None
async def get_or_create_db(
db_path: Path,
db_type: DatabaseType = DatabaseType.FILESYSTEM,
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
"""Get or create database engine and session maker."""
global _engine, _session_maker
@@ -113,13 +86,12 @@ async def get_or_create_db(
_engine = create_async_engine(db_url, connect_args={"check_same_thread": False})
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
# Initialize database
await init_db()
assert _engine is not None # for type checker
assert _session_maker is not None # for type checker
return _engine, _session_maker
async def shutdown_db():
async def shutdown_db() -> None: # pragma: no cover
"""Clean up database connections."""
global _engine, _session_maker
@@ -129,12 +101,10 @@ async def shutdown_db():
_session_maker = None
@asynccontextmanager
async def engine_session_factory(
db_path: Path,
db_type: DatabaseType = DatabaseType.MEMORY,
init: bool = True,
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
"""Create engine and session factory.
@@ -143,17 +113,49 @@ async def engine_session_factory(
"""
global _engine, _session_maker
db_url = DatabaseType.get_db_url(db_path, db_type)
logger.debug(f"Creating engine for db_url: {db_url}")
_engine = create_async_engine(db_url, connect_args={"check_same_thread": False})
try:
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
if init:
await init_db()
assert _engine is not None # for type checker
assert _session_maker is not None # for type checker
yield _engine, _session_maker
finally:
await _engine.dispose()
if _engine:
await _engine.dispose()
_engine = None
_session_maker = None
async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.FILESYSTEM):
"""Run any pending alembic migrations."""
logger.info("Running database migrations...")
try:
# Get the absolute path to the alembic directory relative to this file
alembic_dir = Path(__file__).parent / "alembic"
config = Config()
# Set required Alembic config options programmatically
config.set_main_option("script_location", str(alembic_dir))
config.set_main_option(
"file_template",
"%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s",
)
config.set_main_option("timezone", "UTC")
config.set_main_option("revision_environment", "false")
config.set_main_option(
"sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type)
)
command.upgrade(config, "head")
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()
except Exception as e: # pragma: no cover
logger.error(f"Error running migrations: {e}")
raise
+8 -5
View File
@@ -2,6 +2,7 @@
from typing import Annotated
import logfire
from fastapi import Depends
from sqlalchemy.ext.asyncio import (
AsyncSession,
@@ -29,11 +30,11 @@ from basic_memory.services.search_service import SearchService
## project
def get_project_config() -> ProjectConfig:
def get_project_config() -> ProjectConfig: # pragma: no cover
return config
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)]
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover
## sqlalchemy
@@ -41,9 +42,12 @@ ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)]
async def get_engine_factory(
project_config: ProjectConfigDep,
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
"""Get engine and session maker."""
return await db.get_or_create_db(project_config.database_path)
engine, session_maker = await db.get_or_create_db(project_config.database_path)
if project_config.env != "test":
logfire.instrument_sqlalchemy(engine=engine)
return engine, session_maker
EngineFactoryDep = Annotated[
@@ -129,7 +133,6 @@ async def get_file_service(
FileServiceDep = Annotated[FileService, Depends(get_file_service)]
async def get_entity_service(
entity_repository: EntityRepositoryDep,
observation_repository: ObservationRepositoryDep,
+41 -38
View File
@@ -1,7 +1,8 @@
"""Utilities for file operations."""
import hashlib
from pathlib import Path
from typing import Dict, Any, Tuple
from typing import Dict, Any, Union
import yaml
from loguru import logger
@@ -9,35 +10,40 @@ from loguru import logger
class FileError(Exception):
"""Base exception for file operations."""
pass
class FileWriteError(FileError):
"""Raised when file operations fail."""
pass
class ParseError(FileError):
"""Raised when parsing file content fails."""
pass
async def compute_checksum(content: str) -> str:
async def compute_checksum(content: Union[str, bytes]) -> str:
"""
Compute SHA-256 checksum of content.
Args:
content: Text content to hash
content: Content to hash (either text string or bytes)
Returns:
SHA-256 hex digest
Raises:
FileError: If checksum computation fails
"""
try:
return hashlib.sha256(content.encode()).hexdigest()
except Exception as e:
if isinstance(content, str):
content = content.encode()
return hashlib.sha256(content).hexdigest()
except Exception as e: # pragma: no cover
logger.error(f"Failed to compute checksum: {e}")
raise FileError(f"Failed to compute checksum: {e}")
@@ -45,16 +51,16 @@ async def compute_checksum(content: str) -> str:
async def ensure_directory(path: Path) -> None:
"""
Ensure directory exists, creating if necessary.
Args:
path: Directory path to ensure
Raises:
FileWriteError: If directory creation fails
"""
try:
path.mkdir(parents=True, exist_ok=True)
except Exception as e:
except Exception as e: # pragma: no cover
logger.error(f"Failed to create directory: {path}: {e}")
raise FileWriteError(f"Failed to create directory {path}: {e}")
@@ -62,22 +68,20 @@ async def ensure_directory(path: Path) -> None:
async def write_file_atomic(path: Path, content: str) -> None:
"""
Write file with atomic operation using temporary file.
Args:
path: Target file path
content: Content to write
Raises:
FileWriteError: If write operation fails
"""
temp_path = path.with_suffix(".tmp")
try:
temp_path.write_text(content)
# TODO check for path.exists()
temp_path.replace(path)
logger.debug(f"wrote file: {path}")
except Exception as e:
except Exception as e: # pragma: no cover
temp_path.unlink(missing_ok=True)
logger.error(f"Failed to write file: {path}: {e}")
raise FileWriteError(f"Failed to write file {path}: {e}")
@@ -85,16 +89,19 @@ async def write_file_atomic(path: Path, content: str) -> None:
def has_frontmatter(content: str) -> bool:
"""
Check if content contains YAML frontmatter.
Check if content contains valid YAML frontmatter.
Args:
content: Content to check
Returns:
True if content has frontmatter delimiter (---), False otherwise
True if content has valid frontmatter markers (---), False otherwise
"""
content = content.strip()
return content.startswith("---") and "---" in content[3:]
if not content.startswith("---"):
return False
return "---" in content[3:]
def parse_frontmatter(content: str) -> Dict[str, Any]:
@@ -111,7 +118,7 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
ParseError: If frontmatter is invalid or parsing fails
"""
try:
if not has_frontmatter(content):
if not content.strip().startswith("---"):
raise ParseError("Content has no frontmatter")
# Split on first two occurrences of ---
@@ -132,7 +139,7 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
except yaml.YAMLError as e:
raise ParseError(f"Invalid YAML in frontmatter: {e}")
except Exception as e:
except Exception as e: # pragma: no cover
if not isinstance(e, ParseError):
logger.error(f"Failed to parse frontmatter: {e}")
raise ParseError(f"Failed to parse frontmatter: {e}")
@@ -147,27 +154,23 @@ def remove_frontmatter(content: str) -> str:
content: Content with frontmatter
Returns:
Content with frontmatter removed
Content with frontmatter removed, or original content if no frontmatter
Raises:
ParseError: If frontmatter format is invalid
ParseError: If content starts with frontmatter marker but is malformed
"""
try:
if not has_frontmatter(content):
return content.strip()
content = content.strip()
# Split on first two occurrences of ---
parts = content.split("---", 2)
if len(parts) < 3:
raise ParseError("Invalid frontmatter format")
# Return as-is if no frontmatter marker
if not content.startswith("---"):
return content
return parts[2].strip()
# Split on first two occurrences of ---
parts = content.split("---", 2)
if len(parts) < 3:
raise ParseError("Invalid frontmatter format")
except Exception as e:
if not isinstance(e, ParseError):
logger.error(f"Failed to remove frontmatter: {e}")
raise ParseError(f"Failed to remove frontmatter: {e}")
raise
return parts[2].strip()
async def update_frontmatter(path: Path, updates: Dict[str, Any]) -> str:
@@ -208,6 +211,6 @@ async def update_frontmatter(path: Path, updates: Dict[str, Any]) -> str:
await write_file_atomic(path, final_content)
return await compute_checksum(final_content)
except Exception as e:
except Exception as e: # pragma: no cover
logger.error(f"Failed to update frontmatter in {path}: {e}")
raise FileError(f"Failed to update frontmatter: {e}")
raise FileError(f"Failed to update frontmatter: {e}")
+16 -33
View File
@@ -2,6 +2,7 @@
Uses markdown-it with plugins to parse structured data from markdown content.
"""
from dataclasses import dataclass, field
from pathlib import Path
from datetime import datetime
@@ -21,11 +22,12 @@ from basic_memory.markdown.schemas import (
md = MarkdownIt().use(observation_plugin).use(relation_plugin)
@dataclass
class EntityContent:
content: str
observations: list[Observation] = field(default_factory=list)
relations: list[Relation] = field(default_factory=list)
content: str
observations: list[Observation] = field(default_factory=list)
relations: list[Relation] = field(default_factory=list)
def parse(content: str) -> EntityContent:
@@ -53,13 +55,13 @@ def parse(content: str) -> EntityContent:
relations=relations,
)
def parse_tags(tags: Any) -> list[str]:
"""Parse tags into list of strings."""
if isinstance(tags, str):
return [t.strip() for t in tags.split(",") if t.strip()]
if isinstance(tags, (list, tuple)):
return [str(t).strip() for t in tags if str(t).strip()]
return []
return [t.strip() for t in tags.split(",") if t.strip()]
class EntityParser:
"""Parser for markdown files into Entity objects."""
@@ -68,21 +70,6 @@ class EntityParser:
"""Initialize parser with base path for relative permalink generation."""
self.base_path = base_path.resolve()
def relative_path(self, file_path: Path) -> str:
"""Get file path relative to base_path.
Example:
base_path: /project/root
file_path: /project/root/design/models/data.md
returns: "design/models/data"
"""
# Get relative path and remove .md extension
rel_path = file_path.resolve().relative_to(self.base_path)
if rel_path.suffix.lower() == ".md":
return str(rel_path.with_suffix(""))
return str(rel_path)
def parse_date(self, value: Any) -> Optional[datetime]:
"""Parse date strings using dateparser for maximum flexibility.
@@ -96,26 +83,23 @@ class EntityParser:
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
parsed = dateparser.parse(value)
if parsed:
return parsed
except Exception:
pass
parsed = dateparser.parse(value)
if parsed:
return parsed
return None
async def parse_file(self, file_path: Path) -> EntityMarkdown:
async def parse_file(self, path: Path | str) -> EntityMarkdown:
"""Parse markdown file into EntityMarkdown."""
absolute_path = self.base_path / file_path
absolute_path = self.base_path / path
# Parse frontmatter and content using python-frontmatter
post = frontmatter.load(str(absolute_path))
# Extract file stat info
file_stats = absolute_path.stat()
metadata = post.metadata
metadata["title"] = post.metadata.get("title", file_path.name)
metadata["title"] = post.metadata.get("title", absolute_path.name)
metadata["type"] = post.metadata.get("type", "note")
metadata["tags"] = parse_tags(post.metadata.get("tags", []))
@@ -134,4 +118,3 @@ class EntityParser:
created=datetime.fromtimestamp(file_stats.st_ctime),
modified=datetime.fromtimestamp(file_stats.st_mtime),
)
@@ -19,7 +19,7 @@ class DirtyFileError(Exception):
class MarkdownProcessor:
"""Process markdown files while preserving content and structure.
used only for import
This class handles the file I/O aspects of our markdown processing. It:
@@ -90,14 +90,14 @@ class MarkdownProcessor:
# Convert frontmatter to dict
frontmatter_dict = OrderedDict()
frontmatter_dict["title"] = markdown.frontmatter.title
frontmatter_dict["type"] = markdown.frontmatter.type
frontmatter_dict["title"] = markdown.frontmatter.title
frontmatter_dict["type"] = markdown.frontmatter.type
frontmatter_dict["permalink"] = markdown.frontmatter.permalink
metadata = markdown.frontmatter.metadata or {}
for k,v in metadata.items():
for k, v in metadata.items():
frontmatter_dict[k] = v
# Start with user content (or minimal title for new files)
content = markdown.content or f"# {markdown.frontmatter.title}\n"
@@ -107,7 +107,7 @@ class MarkdownProcessor:
# add a blank line if we have semantic content
if markdown.observations or markdown.relations:
content += "\n"
if markdown.observations:
content += self.format_observations(markdown.observations)
if markdown.relations:
+109 -123
View File
@@ -8,19 +8,19 @@ from markdown_it.token import Token
# Observation handling functions
def is_observation(token: Token) -> bool:
"""Check if token looks like our observation format."""
if token.type != 'inline':
if token.type != "inline": # pragma: no cover
return False
content = token.content.strip()
if not content:
if not content: # pragma: no cover
return False
# if it's a markdown_task, return false
if content.startswith('[ ]') or content.startswith('[x]') or content.startswith('[-]'):
if content.startswith("[ ]") or content.startswith("[x]") or content.startswith("[-]"):
return False
has_category = content.startswith('[') and ']' in content
has_tags = '#' in content
has_category = content.startswith("[") and "]" in content
has_tags = "#" in content
return has_category or has_tags
@@ -28,119 +28,126 @@ def parse_observation(token: Token) -> Dict[str, Any]:
"""Extract observation parts from token."""
# Strip bullet point if present
content = token.content.strip()
if content.startswith('- '):
content = content[2:].strip()
elif content.startswith('-'):
content = content[1:].strip()
# Parse [category]
category = None
if content.startswith('['):
end = content.find(']')
if content.startswith("["):
end = content.find("]")
if end != -1:
category = content[1:end].strip() or None # Convert empty to None
content = content[end + 1:].strip()
content = content[end + 1 :].strip()
# Parse (context)
context = None
if content.endswith(')'):
start = content.rfind('(')
if content.endswith(")"):
start = content.rfind("(")
if start != -1:
context = content[start + 1:-1].strip()
context = content[start + 1 : -1].strip()
content = content[:start].strip()
# Parse #tags and content
# Extract tags and keep original content
tags = []
parts = content.split()
content_parts = []
tags = set() # Use set to avoid duplicates
for part in parts:
if part.startswith('#'):
if part.startswith("#"):
# Handle multiple #tags stuck together
if '#' in part[1:]:
if "#" in part[1:]:
# Split on # but keep non-empty tags
subtags = [t for t in part.split('#') if t]
tags.update(subtags)
subtags = [t for t in part.split("#") if t]
tags.extend(subtags)
else:
tags.add(part[1:])
else:
content_parts.append(part)
tags.append(part[1:])
return {
'category': category,
'content': content,
'tags': list(tags) if tags else None,
'context': context
"category": category,
"content": content,
"tags": tags if tags else None,
"context": context,
}
# Relation handling functions
def is_explicit_relation(token: Token) -> bool:
"""Check if token looks like our relation format."""
if token.type != 'inline':
if token.type != "inline": # pragma: no cover
return False
content = token.content.strip()
return '[[' in content and ']]' in content
return "[[" in content and "]]" in content
def parse_relation(token: Token) -> Dict[str, Any]:
def parse_relation(token: Token) -> Dict[str, Any] | None:
"""Extract relation parts from token."""
# Remove bullet point if present
content = token.content.strip()
if content.startswith('- '):
content = content[2:].strip()
elif content.startswith('-'):
content = content[1:].strip()
# Extract [[target]]
target = None
rel_type = 'relates_to' # default
rel_type = "relates_to" # default
context = None
start = content.find('[[')
end = content.find(']]')
start = content.find("[[")
end = content.find("]]")
if start != -1 and end != -1:
# Get text before link as relation type
before = content[:start].strip()
if before:
rel_type = before
# Get target
target = content[start + 2:end].strip()
target = content[start + 2 : end].strip()
# Look for context after
after = content[end + 2:].strip()
if after.startswith('(') and after.endswith(')'):
after = content[end + 2 :].strip()
if after.startswith("(") and after.endswith(")"):
context = after[1:-1].strip() or None
if not target:
if not target: # pragma: no cover
return None
return {
'type': rel_type,
'target': target,
'context': context
}
return {"type": rel_type, "target": target, "context": context}
def parse_inline_relations(content: str) -> List[Dict[str, Any]]:
"""Find wiki-style links in regular content."""
relations = []
import re
pattern = r'\[\[([^\]]+)\]\]'
for match in re.finditer(pattern, content):
target = match.group(1).strip()
if target and not target.startswith('[['): # Avoid nested matches
relations.append({
'type': 'links to',
'target': target,
'context': None
})
start = 0
while True:
# Find next outer-most [[
start = content.find("[[", start)
if start == -1: # pragma: no cover
break
# Find matching ]]
depth = 1
pos = start + 2
end = -1
while pos < len(content):
if content[pos : pos + 2] == "[[":
depth += 1
pos += 2
elif content[pos : pos + 2] == "]]":
depth -= 1
if depth == 0:
end = pos
break
pos += 2
else:
pos += 1
if end == -1:
# No matching ]] found
break
target = content[start + 2 : end].strip()
if target:
relations.append({"type": "links to", "target": target, "context": None})
start = end + 2
return relations
@@ -149,88 +156,67 @@ def observation_plugin(md: MarkdownIt) -> None:
- [category] Content text #tag1 #tag2 (context)
- Content text #tag1 (context) # No category is also valid
"""
def observation_rule(state: Any) -> None:
"""Process observations in token stream."""
tokens = state.tokens
current_section = None
in_list_item = False
for idx in range(len(tokens)):
token = tokens[idx]
# Track current section by headings
if token.type == 'heading_open':
next_token = tokens[idx + 1] if idx + 1 < len(tokens) else None
if next_token and next_token.type == 'inline':
current_section = next_token.content.lower()
# Track list nesting
elif token.type == 'list_item_open':
in_list_item = True
elif token.type == 'list_item_close':
in_list_item = False
# Initialize meta for all tokens
token.meta = token.meta or {}
# Parse observations in list items
if token.type == 'inline' and is_observation(token):
if token.type == "inline" and is_observation(token):
obs = parse_observation(token)
if obs['content']: # Only store if we have content
token.meta['observation'] = obs
if obs["content"]: # Only store if we have content
token.meta["observation"] = obs
# Add the rule after inline processing
md.core.ruler.after('inline', 'observations', observation_rule)
md.core.ruler.after("inline", "observations", observation_rule)
def relation_plugin(md: MarkdownIt) -> None:
"""Plugin for parsing relation formats:
Explicit relations:
- relation_type [[target]] (context)
Implicit relations (links in content):
Some text with [[target]] reference
"""
def relation_rule(state: Any) -> None:
"""Process relations in token stream."""
tokens = state.tokens
current_section = None
in_list_item = False
for idx in range(len(tokens)):
token = tokens[idx]
# Track current section by headings
if token.type == 'heading_open':
next_token = tokens[idx + 1] if idx + 1 < len(tokens) else None
if next_token and next_token.type == 'inline':
current_section = next_token.content.lower()
# Track list nesting
elif token.type == 'list_item_open':
if token.type == "list_item_open":
in_list_item = True
elif token.type == 'list_item_close':
elif token.type == "list_item_close":
in_list_item = False
# Initialize meta for all tokens
token.meta = token.meta or {}
# Only process inline tokens
if token.type == 'inline':
if token.type == "inline":
# Check for explicit relations in list items
if in_list_item and is_explicit_relation(token):
rel = parse_relation(token)
if rel:
token.meta['relations'] = [rel]
token.meta["relations"] = [rel]
# Always check for inline links in any text
elif '[[' in token.content:
elif "[[" in token.content:
rels = parse_inline_relations(token.content)
if rels:
token.meta['relations'] = token.meta.get('relations', []) + rels
token.meta["relations"] = token.meta.get("relations", []) + rels
# Add the rule after inline processing
md.core.ruler.after('inline', 'relations', relation_rule)
md.core.ruler.after("inline", "relations", relation_rule)
+7 -8
View File
@@ -13,7 +13,7 @@ class Observation(BaseModel):
content: str
tags: Optional[List[str]] = None
context: Optional[str] = None
def __str__(self) -> str:
obs_string = f"- [{self.category}] {self.content}"
if self.context:
@@ -27,7 +27,7 @@ class Relation(BaseModel):
type: str
target: str
context: Optional[str] = None
def __str__(self) -> str:
rel_string = f"- {self.type} [[{self.target}]]"
if self.context:
@@ -38,24 +38,23 @@ class Relation(BaseModel):
class EntityFrontmatter(BaseModel):
"""Required frontmatter fields for an entity."""
metadata: Optional[dict] = None
metadata: dict = {}
@property
def tags(self) -> List[str]:
return self.metadata.get("tags") if self.metadata else []
return self.metadata.get("tags") if self.metadata else [] # pyright: ignore
@property
def title(self) -> str:
return self.metadata.get("title") if self.metadata else None
return self.metadata.get("title") if self.metadata else None # pyright: ignore
@property
def type(self) -> str:
return self.metadata.get("type", "note") if self.metadata else "note"
return self.metadata.get("type", "note") if self.metadata else "note" # pyright: ignore
@property
def permalink(self) -> str:
return self.metadata.get("permalink") if self.metadata else None
return self.metadata.get("permalink") if self.metadata else None # pyright: ignore
class EntityMarkdown(BaseModel):
+71 -122
View File
@@ -1,144 +1,93 @@
"""Utilities for converting between markdown and entity models."""
from pathlib import Path
from typing import Optional
from typing import Optional, Any
from frontmatter import Post
from basic_memory.markdown import EntityMarkdown, EntityFrontmatter, Observation, Relation
from basic_memory.markdown.entity_parser import parse
from basic_memory.models import Entity, ObservationCategory, Observation as ObservationModel
from basic_memory.markdown import EntityMarkdown
from basic_memory.models import Entity, Observation as ObservationModel
from basic_memory.utils import generate_permalink
def entity_model_to_markdown(entity: Entity, content: Optional[str] = None) -> EntityMarkdown:
def entity_model_from_markdown(
file_path: Path, markdown: EntityMarkdown, entity: Optional[Entity] = None
) -> Entity:
"""
Converts an entity model to its Markdown representation, including metadata,
observations, relations, and content. Ensures that observations and relations
from the provided content are synchronized with the entity model. Removes
duplicate or unmatched observations and relations from the content to maintain
consistency.
:param entity: An instance of the Entity class containing metadata, observations,
relations, and other properties of the entity.
:type entity: Entity
:param content: Optional raw Markdown-formatted content to be parsed for semantic
information like observations or relations.
:type content: Optional[str]
:return: An instance of the EntityMarkdown class containing the entity's
frontmatter, observations, relations, and sanitized content formatted
in Markdown.
:rtype: EntityMarkdown
"""
metadata = entity.entity_metadata or {}
metadata["type"] = entity.entity_type or "note"
metadata["title"] = entity.title
metadata["permalink"] = entity.permalink
# convert model to markdown
entity_observations = [
Observation(
category=obs.category,
content=obs.content,
tags=obs.tags if obs.tags else None,
context=obs.context,
)
for obs in entity.observations
]
entity_relations = [
Relation(
type=r.relation_type,
target=r.to_entity.title if r.to_entity else r.to_name,
context=r.context,
)
for r in entity.outgoing_relations
]
observations = entity_observations
relations = entity_relations
# parse the content to see if it has semantic info (observations/relations)
entity_content = parse(content) if content else None
if entity_content:
# remove if they are already in the content
observations = [o for o in entity_observations if o not in entity_content.observations]
relations = [r for r in entity_relations if r not in entity_content.relations]
# remove from the content if not present in the db entity
for o in entity_content.observations:
if o not in entity_observations:
content = content.replace(str(o), "")
for r in entity_content.relations:
if r not in entity_relations:
content = content.replace(str(r), "")
return EntityMarkdown(
frontmatter=EntityFrontmatter(metadata=metadata),
content=content,
observations=observations,
relations=relations,
created = entity.created_at,
modified = entity.updated_at,
)
def entity_model_from_markdown(file_path: Path, markdown: EntityMarkdown, entity: Optional[Entity] = None) -> Entity:
"""
Convert markdown entity to model.
Does not include relations.
Convert markdown entity to model. Does not include relations.
Args:
file_path: Path to the markdown file
markdown: Parsed markdown entity
include_relations: Whether to include relations. Set False for first sync pass.
entity: Optional existing entity to update
Returns:
Entity model populated from markdown
Raises:
ValueError: If required datetime fields are missing from markdown
"""
# Validate/default category
def get_valid_category(obs):
if not obs.category or obs.category not in [c.value for c in ObservationCategory]:
return ObservationCategory.NOTE.value
return obs.category
if not markdown.created or not markdown.modified: # pragma: no cover
raise ValueError("Both created and modified dates are required in markdown")
# Generate permalink if not provided
permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
# Create or update entity
model = entity or Entity()
model.title=markdown.frontmatter.title
model.entity_type=markdown.frontmatter.type
model.permalink=permalink
model.file_path=str(file_path)
model.content_type="text/markdown"
model.created_at=markdown.created
model.updated_at=markdown.modified
model.entity_metadata={k:str(v) for k,v in markdown.frontmatter.metadata.items()}
model.observations=[
ObservationModel(
content=obs.content,
category=get_valid_category(obs),
context=obs.context,
tags=obs.tags,
)
for obs in markdown.observations
]
# Update basic fields
model.title = markdown.frontmatter.title
model.entity_type = markdown.frontmatter.type
model.permalink = permalink
model.file_path = str(file_path)
model.content_type = "text/markdown"
model.created_at = markdown.created
model.updated_at = markdown.modified
# Handle metadata - ensure all values are strings and filter None
metadata = markdown.frontmatter.metadata or {}
model.entity_metadata = {k: str(v) for k, v in metadata.items() if v is not None}
# Convert observations
model.observations = [
ObservationModel(
content=obs.content,
category=obs.category,
context=obs.context,
tags=obs.tags,
)
for obs in markdown.observations
]
return model
async def schema_to_markdown(schema):
async def schema_to_markdown(schema: Any) -> Post:
"""
Convert schema to markdown.
:param schema: the schema to convert
:return: Post
Convert schema to markdown Post object.
Args:
schema: Schema to convert (must have title, entity_type, and permalink attributes)
Returns:
Post object with frontmatter metadata
"""
# Create Post object
# Extract content and metadata
content = schema.content or ""
frontmatter_metadata = schema.entity_metadata or {}
# remove from map so we can define ordering in frontmatter
if "type" in frontmatter_metadata:
del frontmatter_metadata["type"]
if "title" in frontmatter_metadata:
del frontmatter_metadata["title"]
if "permalink" in frontmatter_metadata:
del frontmatter_metadata["permalink"]
post = Post(content, title=schema.title, type=schema.entity_type, permalink=schema.permalink, **frontmatter_metadata)
frontmatter_metadata = dict(schema.entity_metadata or {})
# Remove special fields for ordered frontmatter
for field in ["type", "title", "permalink"]:
frontmatter_metadata.pop(field, None)
# Create Post with ordered fields
post = Post(
content,
title=schema.title,
type=schema.entity_type,
permalink=schema.permalink,
**frontmatter_metadata,
)
return post
+1 -1
View File
@@ -1 +1 @@
"""MCP server for basic-memory."""
"""MCP server for basic-memory."""
-2
View File
@@ -6,5 +6,3 @@ BASE_URL = "http://test"
# Create shared async client
client = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
+10 -6
View File
@@ -3,19 +3,23 @@
Creates and configures the shared MCP instance and handles server startup.
"""
from loguru import logger
from loguru import logger # pragma: no cover
from basic_memory.config import config
from basic_memory.config import config # pragma: no cover
# Import shared mcp instance
from basic_memory.mcp.server import mcp
from basic_memory.mcp.server import mcp # pragma: no cover
# Import tools to register them
import basic_memory.mcp.tools # noqa: F401
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__":
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()
mcp.run()
+15
View File
@@ -0,0 +1,15 @@
"""Basic Memory MCP prompts.
Prompts are a special type of tool that returns a string response
formatted for a user to read, typically invoking one or more tools
and transforming their results into user-friendly text.
"""
# Import individual prompt modules to register them with the MCP server
from basic_memory.mcp.prompts import continue_conversation
from basic_memory.mcp.prompts import recent_activity
from basic_memory.mcp.prompts import search
from basic_memory.mcp.prompts import ai_assistant_guide
from basic_memory.mcp.prompts import json_canvas_spec
__all__ = ["ai_assistant_guide", "continue_conversation", "json_canvas_spec", "recent_activity", "search"]
@@ -0,0 +1,28 @@
from pathlib import Path
import logfire
from loguru import logger
from basic_memory.mcp.server import mcp
@mcp.resource(
uri="memory://ai_assistant_guide",
name="ai_assistant_guide",
description="Give an AI assistant guidance on how to use Basic Memory tools effectively",
)
def ai_assistant_guide() -> str:
"""Return a concise guide on Basic Memory tools and how to use them.
Args:
focus: Optional area to focus on ("writing", "context", "search", etc.)
Returns:
A focused guide on Basic Memory usage.
"""
with logfire.span("Getting Basic Memory guide"): # pyright: ignore
logger.info("Loading AI assistant guide resource")
guide_doc = Path(__file__).parent.parent.parent.parent.parent / "data/ai_assistant_guide.md"
content = guide_doc.read_text()
logger.info(f"Loaded AI assistant guide ({len(content)} chars)")
return content
@@ -0,0 +1,172 @@
"""Session continuation prompts for Basic Memory MCP server.
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 Optional, List, Annotated
from loguru import logger
import logfire
from pydantic import Field
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.memory import build_context, recent_activity
from basic_memory.mcp.tools.search import search
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import GraphContext
from basic_memory.schemas.search import SearchQuery
@mcp.prompt(
name="continue_conversation",
description="Continue a previous conversation",
)
async def continue_conversation(
topic: Annotated[Optional[str], Field(description="Topic or keyword to search for")] = None,
timeframe: Annotated[
Optional[TimeFrame],
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
] = None,
) -> str:
"""Continue a previous conversation or work session.
This prompt helps you pick up where you left off by finding recent context
about a specific topic or showing general recent activity.
Args:
topic: Topic or keyword to search for (optional)
timeframe: How far back to look for activity
Returns:
Context from previous sessions on this topic
"""
with logfire.span("Continuing session", topic=topic, timeframe=timeframe): # pyright: ignore
logger.info(f"Continuing session, topic: {topic}, timeframe: {timeframe}")
# If topic provided, search for it
if topic:
search_results = await search(SearchQuery(text=topic, after_date=timeframe))
# Build context from top results
contexts = []
for result in search_results.results[:3]:
if hasattr(result, "permalink") and result.permalink:
context = await build_context(f"memory://{result.permalink}")
contexts.append(context)
return format_continuation_context(topic, contexts, timeframe)
# If no topic, get recent activity
recent = await recent_activity(timeframe=timeframe)
return format_continuation_context("Recent Activity", [recent], timeframe)
def format_continuation_context(
topic: str, contexts: List[GraphContext], timeframe: TimeFrame | None
) -> str:
"""Format continuation context into a helpful summary.
Args:
topic: The topic or focus of continuation
contexts: List of context graphs
timeframe: How far back to look for activity
Returns:
Formatted continuation summary
"""
if not contexts or all(not context.primary_results for context in contexts):
return dedent(f"""
# Continuing conversation on: {topic}
This is a memory retrieval session.
Please use the available basic-memory tools to gather relevant context before responding.
Start by executing one of the suggested commands below to retrieve content.
I couldn't find any recent work specifically on this topic.
## Suggestions
- Try a different search term
- Check recent activity with `recent_activity(timeframe="1w")`
- Start a new topic with `write_note(...)`
""")
# Start building our summary with header
summary = dedent(f"""
# Continuing conversation on: {topic}
This is a memory retrieval session.
Please use the available basic-memory tools to gather relevant context before responding.
Start by executing one of the suggested commands below to retrieve content.
Here's what I found about the previous conversation:
""")
# Track what we've added to avoid duplicates
added_permalinks = set()
sections = []
# Process each context
for context in contexts:
# Add primary results
for primary in context.primary_results:
if hasattr(primary, "permalink") and primary.permalink not in added_permalinks:
added_permalinks.add(primary.permalink)
section = dedent(f"""
## {primary.title}
- **Type**: {primary.type}
""")
# Add creation date if available
if hasattr(primary, "created_at"):
section += f"- **Created**: {primary.created_at.strftime('%Y-%m-%d %H:%M')}\n"
section += dedent(f"""
You can read this document with: `read_note("{primary.permalink}")`
""")
# Add related documents if available
related_by_type = {}
if context.related_results:
for related in context.related_results:
if hasattr(related, "relation_type") and related.relation_type: # pyright: ignore
if related.relation_type not in related_by_type: # pyright: ignore
related_by_type[related.relation_type] = [] # pyright: ignore
related_by_type[related.relation_type].append(related) # pyright: ignore
if related_by_type:
section += dedent("""
### Related Documents
""")
for rel_type, relations in related_by_type.items():
display_type = rel_type.replace("_", " ").title()
section += f"- **{display_type}**:\n"
for rel in relations[:3]: # Limit to avoid overwhelming
if hasattr(rel, "to_id") and rel.to_id:
section += f" - `{rel.to_id}`\n"
sections.append(section)
# Add all sections
summary += "\n".join(sections)
# Add next steps
next_steps = dedent(f"""
## Next Steps
You can:
- Explore more with: `search({{"text": "{topic}"}})`
- See what's changed: `recent_activity(timeframe="{timeframe}")`
""")
# Add specific exploration based on what we found
if added_permalinks:
first_permalink = next(iter(added_permalinks))
next_steps += dedent(f"""
- Continue the conversation: `build_context("memory://{first_permalink}")`
""")
return summary + next_steps
@@ -0,0 +1,25 @@
from pathlib import Path
import logfire
from loguru import logger
from basic_memory.mcp.server import mcp
@mcp.resource(
uri="memory://json_canvas_spec",
name="json_canvas_spec",
description="JSON Canvas specification for visualizing knowledge graphs in Obsidian"
)
def json_canvas_spec() -> str:
"""Return the JSON Canvas specification for Obsidian visualizations.
Returns:
The JSON Canvas specification document.
"""
with logfire.span("Getting JSON Canvas spec"): # pyright: ignore
logger.info("Loading JSON Canvas spec resource")
canvas_spec = Path(__file__).parent.parent.parent.parent.parent / "data/json_canvas_spec_1_0.md"
content = canvas_spec.read_text()
logger.info(f"Loaded JSON Canvas spec ({len(content)} chars)")
return content
@@ -0,0 +1,46 @@
"""Recent activity prompts for Basic Memory MCP server.
These prompts help users see what has changed in their knowledge base recently.
"""
from typing import Annotated, Optional
from loguru import logger
import logfire
from pydantic import Field
from basic_memory.mcp.prompts.utils import format_context_summary
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.memory import recent_activity as recent_activity_tool
from basic_memory.schemas.base import TimeFrame
@mcp.prompt(
name="recent_activity",
description="Get recent activity from across the knowledge base",
)
async def recent_activity_prompt(
timeframe: Annotated[
Optional[TimeFrame],
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
] = None,
) -> str:
"""Get recent activity from across the knowledge base.
This prompt helps you see what's changed recently in the knowledge base,
showing new or updated documents and related information.
Args:
timeframe: How far back to look for activity (e.g. '1d', '1 week')
Returns:
Formatted summary of recent activity
"""
with logfire.span("Getting recent activity", timeframe=timeframe): # pyright: ignore
logger.info(f"Getting recent activity, timeframe: {timeframe}")
results = await recent_activity_tool(timeframe=timeframe)
time_display = f" ({timeframe})" if timeframe else ""
header = f"# Recent Activity{time_display}"
return format_context_summary(header, results)
+127
View File
@@ -0,0 +1,127 @@
"""Search prompts for Basic Memory MCP server.
These prompts help users search and explore their knowledge base.
"""
from textwrap import dedent
from typing import Annotated, Optional
from loguru import logger
import logfire
from pydantic import Field
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search as search_tool
from basic_memory.schemas.search import SearchQuery, SearchResponse
from basic_memory.schemas.base import TimeFrame
@mcp.prompt(
name="search",
description="Search across all content in basic-memory",
)
async def search_prompt(
query: str,
timeframe: Annotated[
Optional[TimeFrame],
Field(description="How far back to search (e.g. '1d', '1 week')"),
] = None,
) -> str:
"""Search across all content in basic-memory.
This prompt helps search for content in the knowledge base and
provides helpful context about the results.
Args:
query: The search text to look for
timeframe: Optional timeframe to limit results (e.g. '1d', '1 week')
Returns:
Formatted search results with context
"""
with logfire.span("Searching knowledge base", query=query, timeframe=timeframe): # pyright: ignore
logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
search_results = await search_tool(SearchQuery(text=query, after_date=timeframe))
return format_search_results(query, search_results, timeframe)
def format_search_results(
query: str, results: SearchResponse, timeframe: Optional[TimeFrame] = None
) -> str:
"""Format search results into a helpful summary.
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.
## Suggestions
- Try a different search term
- Broaden your search criteria
- Check recent activity with `recent_activity(timeframe="1w")`
- Create new content with `write_note(...)`
""")
# 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}
""")
# Add creation date if available in metadata
if hasattr(result, "metadata") and 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"
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 hasattr(result, "metadata") and result.metadata and "excerpt" in result.metadata:
summary += f"- **Excerpt**: {result.metadata['excerpt']}\n"
# Add permalink for retrieving content
if hasattr(result, "permalink") and 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}")`
""")
# Add next steps
summary += dedent(f"""
## Next Steps
You can:
- Refine your search: `search("{query} AND additional_term")`
- Exclude terms: `search("{query} NOT exclude_term")`
- View more results: `search("{query}", after_date=None)`
- Check recent activity: `recent_activity()`
""")
return summary
+98
View File
@@ -0,0 +1,98 @@
"""Utility functions for formatting prompt responses.
These utilities help format data from various tools into consistent,
user-friendly markdown summaries.
"""
from basic_memory.schemas.memory import GraphContext
def format_context_summary(header: str, context: GraphContext) -> str:
"""Format GraphContext as a helpful markdown summary.
This creates a user-friendly markdown response that explains the context
and provides guidance on how to explore further.
Args:
header: The title to use for the summary
context: The GraphContext object to format
Returns:
Formatted markdown string with the context summary
"""
summary = []
# Extract URI for reference
uri = context.metadata.uri or "a/permalink-value"
# Add header
summary.append(f"{header}")
summary.append("")
# Primary document section
if context.primary_results:
summary.append(f"## Primary Documents ({len(context.primary_results)})")
for primary in context.primary_results:
summary.append(f"### {primary.title}")
summary.append(f"- **Type**: {primary.type}")
summary.append(f"- **Path**: {primary.file_path}")
summary.append(f"- **Created**: {primary.created_at.strftime('%Y-%m-%d %H:%M')}")
summary.append("")
summary.append(
f'To view this document\'s content: `read_note("{primary.permalink}")` or `read_note("{primary.title}")` '
)
summary.append("")
else:
summary.append("\nNo primary documents found.")
# Related documents section
if context.related_results:
summary.append(f"## Related Documents ({len(context.related_results)})")
# Group by relation type for better organization
relation_types = {}
for rel in context.related_results:
if hasattr(rel, "relation_type"):
rel_type = rel.relation_type # pyright: ignore
if rel_type not in relation_types:
relation_types[rel_type] = []
relation_types[rel_type].append(rel)
# Display relations grouped by type
for rel_type, relations in relation_types.items():
summary.append(f"### {rel_type.replace('_', ' ').title()} ({len(relations)})")
for rel in relations:
if hasattr(rel, "to_id") and rel.to_id:
summary.append(f"- **{rel.to_id}**")
summary.append(f' - View document: `read_note("{rel.to_id}")` ')
summary.append(
f' - Explore connections: `build_context("memory://{rel.to_id}")` '
)
else:
summary.append(f"- **Unresolved relation**: {rel.permalink}")
summary.append("")
# Next steps section
summary.append("## Next Steps")
summary.append("Here are some ways to explore further:")
search_term = uri.split("/")[-1]
summary.append(f'- **Search related topics**: `search({{"text": "{search_term}"}})`')
summary.append('- **Check recent changes**: `recent_activity(timeframe="3 days")`')
summary.append(f'- **Explore all relations**: `build_context("memory://{uri}/*")`')
# Tips section
summary.append("")
summary.append("## Tips")
summary.append(
f'- For more specific context, increase depth: `build_context("memory://{uri}", depth=2)`'
)
summary.append(
"- You can follow specific relation types using patterns like: `memory://document/relation-type/*`"
)
summary.append("- Look for connected documents by checking relations between them")
return "\n".join(summary)
+1 -29
View File
@@ -1,39 +1,11 @@
"""Enhanced FastMCP server instance for Basic Memory."""
import sys
from loguru import logger
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.utilities.logging import configure_logging
from basic_memory.config import config
# mcp console logging
configure_logging(level="INFO")
def setup_logging(home_dir: str = config.home, log_file: str = ".basic-memory/basic-memory.log"):
"""Configure file logging to the basic-memory home directory."""
log = f"{home_dir}/{log_file}"
# Add file handler with rotation
logger.add(
log,
rotation="100 MB",
retention="10 days",
backtrace=True,
diagnose=True,
enqueue=True,
colorize=False,
)
# Add stderr handler
logger.add(
sys.stderr,
colorize=True,
)
# start our out file logging
setup_logging()
# Create the shared server instance
mcp = FastMCP("Basic Memory")
mcp = FastMCP("Basic Memory")
+8 -4
View File
@@ -6,9 +6,11 @@ all tools with the MCP server.
"""
# Import tools to register them with MCP
from basic_memory.mcp.tools.resource import read_resource
from basic_memory.mcp.tools.memory import build_context, recent_activity
#from basic_memory.mcp.tools.ai_edit import ai_edit
from basic_memory.mcp.tools.notes import read_note, write_note
from basic_memory.mcp.tools.search import search
from basic_memory.mcp.tools.canvas import canvas
from basic_memory.mcp.tools.knowledge import (
delete_entities,
@@ -26,9 +28,11 @@ __all__ = [
# memory tools
"build_context",
"recent_activity",
#notes
# notes
"read_note",
"write_note",
# file edit
#"ai_edit",
# files
"read_resource",
# canvas
"canvas",
]
-84
View File
@@ -1,84 +0,0 @@
"""Tool for AI-assisted file editing."""
from pathlib import Path
from typing import List, Dict, Any
from basic_memory.mcp.server import mcp
def _detect_indent(text: str, match_pos: int) -> int:
"""Get indentation level at a position in text."""
# Find start of line containing the match
line_start = text.rfind("\n", 0, match_pos)
if line_start < 0:
line_start = 0
else:
line_start += 1 # Skip newline char
# Count leading spaces
pos = line_start
while pos < len(text) and text[pos].isspace():
pos += 1
return pos - line_start
def _apply_indent(text: str, spaces: int) -> str:
"""Apply indentation to text."""
prefix = " " * spaces
return "\n".join(prefix + line if line.strip() else line for line in text.split("\n"))
@mcp.tool()
async def ai_edit(path: str, edits: List[Dict[str, Any]]) -> bool:
"""AI-assisted file editing tool.
Args:
path: Path to file to edit
edits: List of edits to apply. Each edit is a dict with:
oldText: Text to replace
newText: New content
options: Optional dict with:
indent: Number of spaces to indent
preserveIndentation: Keep existing indent (default: true)
Returns:
bool: True if edits were applied successfully
"""
try:
# Read file
content = Path(path).read_text()
original = content
success = True
# Apply each edit
for edit in edits:
old_text = edit["oldText"]
new_text = edit["newText"]
options = edit.get("options", {})
# Find text to replace
match_pos = content.find(old_text)
if match_pos < 0:
success = False
continue
# Handle indentation
if not options.get("preserveIndentation", True):
# Use existing indentation
indent = _detect_indent(content, match_pos)
new_text = _apply_indent(new_text, indent)
elif "indent" in options:
# Use specified indentation
new_text = _apply_indent(new_text, options["indent"])
# Apply the edit
content = content.replace(old_text, new_text)
# Write back if changed
if content != original:
Path(path).write_text(content)
return success
except Exception as e:
print(f"Error applying edits: {e}")
return False
+99
View File
@@ -0,0 +1,99 @@
"""Canvas creation tool for Basic Memory MCP server.
This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec.
"""
import json
from typing import Dict, List, Any
import logfire
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
@mcp.tool(
description="Create an Obsidian canvas file to visualize concepts and connections.",
)
async def canvas(
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
title: str,
folder: str,
) -> str:
"""Create an Obsidian canvas file with the provided nodes and edges.
This tool creates a .canvas file compatible with Obsidian's Canvas feature,
allowing visualization of relationships between concepts or documents.
For the full JSON Canvas 1.0 specification, see the 'spec://canvas' resource.
Args:
nodes: List of node objects following JSON Canvas 1.0 spec
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
Returns:
A summary of the created canvas file
Important Notes:
- When referencing files, use the exact file path as shown in Obsidian
Example: "folder/Document Name.md" (not permalink format)
- For file nodes, the "file" attribute must reference an existing file
- Nodes require id, type, x, y, width, height properties
- Edges require id, fromNode, toNode properties
- Position nodes in a logical layout (x,y coordinates in pixels)
- Use color attributes ("1"-"6" or hex) for visual organization
Basic Structure:
```json
{
"nodes": [
{
"id": "node1",
"type": "file", // Options: "file", "text", "link", "group"
"file": "folder/Document.md",
"x": 0,
"y": 0,
"width": 400,
"height": 300
}
],
"edges": [
{
"id": "edge1",
"fromNode": "node1",
"toNode": "node2",
"label": "connects to"
}
]
}
```
"""
with logfire.span("Creating canvas", folder=folder, title=title): # type: ignore
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
file_path = f"{folder}/{file_title}"
# Create canvas data structure
canvas_data = {"nodes": nodes, "edges": edges}
# Convert to JSON
canvas_json = json.dumps(canvas_data, indent=2)
# 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)
# Parse response
result = response.json()
logger.debug(result)
# Build summary
action = "Created" if response.status_code == 201 else "Updated"
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
return "\n".join(summary)
+26 -14
View File
@@ -1,8 +1,10 @@
"""Knowledge graph management tools for Basic Memory MCP server."""
import logfire
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get, call_post
from basic_memory.schemas.base import PathId
from basic_memory.schemas.memory import memory_url_path
from basic_memory.schemas.request import (
GetEntitiesRequest,
)
@@ -16,15 +18,17 @@ from basic_memory.mcp.async_client import client
@mcp.tool(
description="Get complete information about a specific entity including observations and relations",
)
async def get_entity(permalink: PathId) -> EntityResponse:
async def get_entity(identifier: str) -> EntityResponse:
"""Get a specific entity info by its permalink.
Args:
permalink: Path identifier for the entity
identifier: Path identifier for the entity
"""
url = f"/knowledge/entities/{permalink}"
response = await call_get(client, url)
return EntityResponse.model_validate(response.json())
with logfire.span("Getting entity", permalink=identifier): # pyright: ignore [reportGeneralTypeIssues]
permalink = memory_url_path(identifier)
url = f"/knowledge/entities/{permalink}"
response = await call_get(client, url)
return EntityResponse.model_validate(response.json())
@mcp.tool(
@@ -39,11 +43,16 @@ async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
Returns:
EntityListResponse containing complete details for each requested entity
"""
url = "/knowledge/entities"
response = await call_get(
client, url, params=[("permalink", permalink) for permalink in request.permalinks]
)
return EntityListResponse.model_validate(response.json())
with logfire.span("Getting multiple entities", permalink_count=len(request.permalinks)): # pyright: ignore [reportGeneralTypeIssues]
url = "/knowledge/entities"
response = await call_get(
client,
url,
params=[
("permalink", memory_url_path(identifier)) for identifier in request.permalinks
],
)
return EntityListResponse.model_validate(response.json())
@mcp.tool(
@@ -51,6 +60,9 @@ async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
)
async def delete_entities(request: DeleteEntitiesRequest) -> DeleteEntitiesResponse:
"""Delete entities from the knowledge graph."""
url = "/knowledge/entities/delete"
response = await call_post(client, url, json=request.model_dump())
return DeleteEntitiesResponse.model_validate(response.json())
with logfire.span("Deleting entities", permalink_count=len(request.permalinks)): # pyright: ignore [reportGeneralTypeIssues]
url = "/knowledge/entities/delete"
request.permalinks = [memory_url_path(permlink) for permlink in request.permalinks]
response = await call_post(client, url, json=request.model_dump())
return DeleteEntitiesResponse.model_validate(response.json())
+61 -31
View File
@@ -1,13 +1,19 @@
"""Discussion context tools for Basic Memory MCP server."""
from typing import Optional, Literal, List
from typing import Optional, List
from loguru import logger
import logfire
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.memory import GraphContext, MemoryUrl, memory_url, memory_url_path, normalize_memory_url
from basic_memory.schemas.memory import (
GraphContext,
MemoryUrl,
memory_url_path,
normalize_memory_url,
)
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.search import SearchItemType
@@ -28,7 +34,9 @@ async def build_context(
url: MemoryUrl,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
max_results: int = 10,
page: int = 1,
page_size: int = 10,
max_related: int = 10,
) -> GraphContext:
"""Get context needed to continue a discussion.
@@ -40,7 +48,9 @@ async def build_context(
url: memory:// URI pointing to discussion content (e.g. memory://specs/search)
depth: How many relation hops to traverse (1-3 recommended for performance)
timeframe: How far back to look. Supports natural language like "2 days ago", "last week"
max_results: Maximum number of results to return (default: 10)
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)
Returns:
GraphContext containing:
@@ -61,14 +71,21 @@ async def build_context(
# Research the history of a feature
build_context("memory://features/knowledge-graph", timeframe="3 months ago")
"""
logger.info(f"Building context from {url}")
url = normalize_memory_url(url)
response = await call_get(
client,
f"/memory/{memory_url_path(url)}",
params={"depth": depth, "timeframe": timeframe, "max_results": max_results},
)
return GraphContext.model_validate(response.json())
with logfire.span("Building context", url=url, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Building context from {url}")
url = normalize_memory_url(url)
response = await call_get(
client,
f"/memory/{memory_url_path(url)}",
params={
"depth": depth,
"timeframe": timeframe,
"page": page,
"page_size": page_size,
"max_related": max_related,
},
)
return GraphContext.model_validate(response.json())
@mcp.tool(
@@ -84,10 +101,12 @@ async def build_context(
""",
)
async def recent_activity(
type: List[Literal["entity", "observation", "relation"]] = None,
type: Optional[List[SearchItemType]] = None,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
max_results: int = 10,
page: int = 1,
page_size: int = 10,
max_related: int = 10,
) -> GraphContext:
"""Get recent activity across the knowledge base.
@@ -102,7 +121,9 @@ async def recent_activity(
- Relative: "2 days ago", "last week", "yesterday"
- Points in time: "2024-01-01", "January 1st"
- Standard format: "7d", "24h"
max_results: Maximum number of results to return (default: 10)
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)
Returns:
GraphContext containing:
@@ -128,20 +149,29 @@ async def recent_activity(
- For focused queries, consider using build_context with a specific URI
- Max timeframe is 1 year in the past
"""
logger.info(
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, max_results={max_results}"
)
params = {
"depth": depth,
"timeframe": timeframe,
"max_results": max_results,
}
if type:
params["type"] = type
with logfire.span("Getting recent activity", type=type, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
logger.info(
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, page={page}, page_size={page_size}, max_related={max_related}"
)
params = {
"page": page,
"page_size": page_size,
"max_related": max_related,
}
if depth:
params["depth"] = depth
if timeframe:
params["timeframe"] = timeframe # pyright: ignore
response = await call_get(
client,
"/memory/recent",
params=params,
)
return GraphContext.model_validate(response.json())
# send enum values if we have an enum, else send string value
if type:
params["type"] = [ # pyright: ignore
type.value if isinstance(type, SearchItemType) else type for type in type
]
response = await call_get(
client,
"/memory/recent",
params=params,
)
return GraphContext.model_validate(response.json())
+135 -56
View File
@@ -7,97 +7,175 @@ while leveraging the underlying knowledge graph structure.
from typing import Optional, List
from loguru import logger
import logfire
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import client
from basic_memory.schemas import EntityResponse, DeleteEntitiesResponse
from basic_memory.schemas.base import Entity
from basic_memory.mcp.tools.utils import call_get, call_put, call_delete
from basic_memory.schemas.memory import memory_url_path
@mcp.tool(
description="Create or update a markdown note. Returns the permalink for referencing.",
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
)
async def write_note(
title: str,
content: str,
folder: str,
tags: Optional[List[str]] = None,
verbose: bool = False,
) -> EntityResponse | str:
) -> str:
"""Write a markdown note to the knowledge base.
The content can include semantic observations and relations using markdown syntax.
Relations can be specified either explicitly or through inline wiki-style links:
Observations format:
`- [category] Observation text #tag1 #tag2 (optional context)`
Examples:
`- [design] Files are the source of truth #architecture (All state comes from files)`
`- [tech] Using SQLite for storage #implementation`
`- [note] Need to add error handling #todo`
Relations format:
- Explicit: `- relation_type [[Entity]] (optional context)`
- Inline: Any `[[Entity]]` reference creates a relation
Examples:
`- depends_on [[Content Parser]] (Need for semantic extraction)`
`- implements [[Search Spec]] (Initial implementation)`
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
Args:
title: The title of the note
content: Markdown content for the note
content: Markdown content for the note, can include observations and relations
folder: the folder where the file should be saved
tags: Optional list of tags to categorize the note
verbose: If True, returns full EntityResponse with semantic info
Returns:
If verbose=False: Permalink that can be used to reference the note
If verbose=True: EntityResponse with full semantic details
Examples:
# Create a simple note
write_note(
tile="Meeting Notes: Project Planning.md",
content="# Key Points\\n\\n- Discussed timeline\\n- Set priorities"
folder="notes"
)
# Create note with tags
write_note(
title="Security Review",
content="# Findings\\n\\n1. Updated auth flow\\n2. Added rate limiting",
folder="security",
tags=["security", "development"]
)
A markdown formatted summary of the semantic content, including:
- Creation/update status
- File path and checksum
- Observation counts by category
- Relation counts (resolved/unresolved)
- Tags if present
"""
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
with logfire.span("Writing note", title=title, folder=folder): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
# Create the entity request
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
entity = Entity(
title=title,
folder=folder,
entity_type="note",
content_type="text/markdown",
content=content,
entity_metadata=metadata,
)
# Create the entity request
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
entity = Entity(
title=title,
folder=folder,
entity_type="note",
content_type="text/markdown",
content=content,
entity_metadata=metadata,
)
# Use existing knowledge tool
logger.info(f"Creating {entity.permalink}")
url = f"/knowledge/entities/{entity.permalink}"
response = await call_put(client, url, json=entity.model_dump())
result = EntityResponse.model_validate(response.json())
return result if verbose else result.permalink
# Create or update via knowledge API
logger.info(f"Creating {entity.permalink}")
url = f"/knowledge/entities/{entity.permalink}"
response = await call_put(client, url, json=entity.model_dump())
result = EntityResponse.model_validate(response.json())
# Format semantic summary based on status code
action = "Created" if response.status_code == 201 else "Updated"
summary = [
f"# {action} {result.file_path} ({result.checksum[:8] if result.checksum else 'unknown'})",
f"permalink: {result.permalink}",
]
if result.observations:
categories = {}
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}")
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}")
summary.append("\nUnresolved relations will be retried on next sync.")
if tags:
summary.append(f"\n## Tags\n- {', '.join(tags)}")
return "\n".join(summary)
@mcp.tool(description="Read a note's content by its title or permalink")
async def read_note(identifier: str) -> str:
"""Get the markdown content of a note.
Uses the resource router to return the actual file content.
@mcp.tool(description="Read note content by title, permalink, relation, or pattern")
async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
"""Get note content in unified diff format.
The content is returned in a unified diff inspired format:
```
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
<document content>
```
Multiple documents (from relations or pattern matches) are separated by
additional headers.
Args:
identifier: Note title or permalink
identifier: Can be one of:
- Note title ("Project Planning")
- Note permalink ("docs/example")
- Relation path ("docs/example/depends-on/other-doc")
- Pattern match ("docs/*-architecture")
page: the page number of results to return (default 1)
page_size: the number of results to return per page (default 10)
Returns:
The note's markdown content
Document content in unified diff format. For single documents, returns
just that document's content. For relations or pattern matches, returns
multiple documents separated by unified diff headers.
Examples:
# Read by title
read_note("Meeting Notes: Project Planning")
# Single document
content = await read_note("Project Planning")
# Read by permalink
read_note("notes/project-planning")
content = await read_note("docs/architecture/file-first")
Raises:
ValueError: If the note cannot be found
# Follow relation
content = await read_note("docs/architecture/depends-on/docs/content-parser")
# Pattern matching
content = await read_note("docs/*-architecture") # All architecture docs
content = await read_note("docs/*/implements/*") # Find implementations
Output format:
```
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
<first document content>
--- memory://docs/other 2025-01-30T15:45:22 a1b2c3d4
<second document content>
```
The headers include:
- Full memory:// URI for the document
- Last modified timestamp
- Content checksum
"""
response = await call_get(client, f"/resource/{identifier}")
return response.text
with logfire.span("Reading note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Reading note {identifier}")
url = memory_url_path(identifier)
response = await call_get(
client, f"/resource/{url}", params={"page": page, "page_size": page_size}
)
return response.text
@mcp.tool(description="Delete a note by title or permalink")
@@ -117,6 +195,7 @@ async def delete_note(identifier: str) -> bool:
# Delete by permalink
delete_note("notes/project-planning")
"""
response = await call_delete(client, f"/knowledge/entities/{identifier}")
result = DeleteEntitiesResponse.model_validate(response.json())
return result.deleted
with logfire.span("Deleting note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
response = await call_delete(client, f"/knowledge/entities/{identifier}")
result = DeleteEntitiesResponse.model_validate(response.json())
return result.deleted
+192
View File
@@ -0,0 +1,192 @@
from loguru import logger
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.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"""
target_size = 350000 # Reduced target for more safety margin
ratio = content_length / target_size
logger.debug(
"Calculating target parameters",
content_length=content_length,
ratio=ratio,
target_size=target_size,
)
if ratio > 4:
# Very large images - start very aggressive
return 50, 600 # Lower initial quality and size
elif ratio > 2:
return 60, 800
else:
return 70, 1000
def resize_image(img, max_size):
"""Resize image maintaining aspect ratio"""
original_dimensions = {"width": img.width, "height": img.height}
if img.width > max_size or img.height > max_size:
ratio = min(max_size / img.width, max_size / img.height)
new_size = (int(img.width * ratio), int(img.height * ratio))
logger.debug("Resizing image", original=original_dimensions, target=new_size, ratio=ratio)
return img.resize(new_size, PILImage.Resampling.LANCZOS)
logger.debug("No resize needed", dimensions=original_dimensions)
return img
def optimize_image(img, content_length, max_output_bytes=350000):
"""Iteratively optimize image with aggressive size reduction"""
stats = {
"dimensions": {"width": img.width, "height": img.height},
"mode": img.mode,
"estimated_memory": (img.width * img.height * len(img.getbands())),
}
initial_quality, initial_size = calculate_target_params(content_length)
logger.debug(
"Starting optimization",
image_stats=stats,
content_length=content_length,
initial_quality=initial_quality,
initial_size=initial_size,
max_output_bytes=max_output_bytes,
)
quality = initial_quality
size = initial_size
# Convert to RGB if needed
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img = img.convert("RGB")
logger.debug("Converted to RGB mode")
iteration = 0
min_size = 300 # Absolute minimum size
min_quality = 20 # Absolute minimum quality
while True:
iteration += 1
buf = io.BytesIO()
resized = resize_image(img, size)
resized.save(
buf,
format="JPEG",
quality=quality,
optimize=True,
progressive=True,
subsampling="4:2:0",
)
output_size = buf.getbuffer().nbytes
reduction_ratio = output_size / content_length
logger.debug(
"Optimization attempt",
iteration=iteration,
quality=quality,
size=size,
output_bytes=output_size,
target_bytes=max_output_bytes,
reduction_ratio=f"{reduction_ratio:.2f}",
)
if output_size < max_output_bytes:
logger.info(
"Image optimization complete",
final_size=output_size,
quality=quality,
dimensions={"width": resized.width, "height": resized.height},
reduction_ratio=f"{reduction_ratio:.2f}",
)
return buf.getvalue()
# Very aggressive reduction for large files
if content_length > 2000000: # 2MB+ # pragma: no cover
quality = max(min_quality, quality - 20)
size = max(min_size, int(size * 0.6))
elif content_length > 1000000: # 1MB+ # pragma: no cover
quality = max(min_quality, quality - 15)
size = max(min_size, int(size * 0.7))
else:
quality = max(min_quality, quality - 10) # pragma: no cover
size = max(min_size, int(size * 0.8)) # pragma: no cover
logger.debug("Reducing parameters", new_quality=quality, new_size=size) # pragma: no cover
# If we've hit minimum values and still too big
if quality <= min_quality and size <= min_size: # pragma: no cover
logger.warning(
"Reached minimum parameters",
final_size=output_size,
over_limit_by=output_size - max_output_bytes,
)
return buf.getvalue()
@mcp.tool(description="Read a single file's content by path or permalink")
async def read_resource(path: str) -> dict:
"""Get a file's raw content."""
logger.info("Reading resource", path=path)
url = memory_url_path(path)
response = await call_get(client, f"/resource/{url}")
content_type = response.headers.get("content-type", "application/octet-stream")
content_length = int(response.headers.get("content-length", 0))
logger.debug("Resource metadata", content_type=content_type, size=content_length, path=path)
# Handle text or json
if content_type.startswith("text/") or content_type == "application/json":
logger.debug("Processing text resource")
return {
"type": "text",
"text": response.text,
"content_type": content_type,
"encoding": "utf-8",
}
# Handle images
elif content_type.startswith("image/"):
logger.debug("Processing image")
img = PILImage.open(io.BytesIO(response.content))
img_bytes = optimize_image(img, content_length)
return {
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": base64.b64encode(img_bytes).decode("utf-8"),
},
}
# Handle other file types
else:
logger.debug(f"Processing binary resource content_type {content_type}")
if content_length > 350000:
logger.warning("Document too large for response", size=content_length)
return {
"type": "error",
"error": f"Document size {content_length} bytes exceeds maximum allowed size",
}
return {
"type": "document",
"source": {
"type": "base64",
"media_type": content_type,
"data": base64.b64encode(response.content).decode("utf-8"),
},
}
+14 -4
View File
@@ -1,4 +1,6 @@
"""Search tools for Basic Memory MCP server."""
import logfire
from loguru import logger
from basic_memory.mcp.server import mcp
@@ -10,7 +12,7 @@ from basic_memory.mcp.async_client import client
@mcp.tool(
description="Search across all content in basic-memory, including documents and entities",
)
async def search(query: SearchQuery) -> SearchResponse:
async def search(query: SearchQuery, page: int = 1, page_size: int = 10) -> SearchResponse:
"""Search across all content in basic-memory.
Args:
@@ -19,10 +21,18 @@ async def search(query: SearchQuery) -> SearchResponse:
- types: Optional list of content types to search ("document" or "entity")
- entity_types: Optional list of entity types to filter by
- after_date: Optional date filter for recent content
page: the page number of results to return (default 1)
page_size: the number of results to return per page (default 10)
Returns:
SearchResponse with search results and metadata
"""
logger.info(f"Searching for {query.text}")
response = await call_post(client,"/search/", json=query.model_dump())
return SearchResponse.model_validate(response.json())
with logfire.span("Searching for {query}", query=query): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Searching for {query}")
response = await call_post(
client,
"/search/",
json=query.model_dump(),
params={"page": page, "page_size": page_size},
)
return SearchResponse.model_validate(response.json())
+3 -2
View File
@@ -44,8 +44,8 @@ async def call_get(
response.raise_for_status()
return response
except HTTPStatusError as e:
logger.error(f"Error calling GET {url}: {e}")
raise ToolError(f"Error calling tool: {e}. Response: {response.text}") from e
logger.exception(f"Error calling GET {url}: {e}")
raise ToolError(f"Error calling tool: {e}.") from e
async def call_put(
@@ -79,6 +79,7 @@ async def call_put(
timeout=timeout,
extensions=extensions,
)
logger.debug(response)
response.raise_for_status()
return response
except HTTPStatusError as e:
+1 -2
View File
@@ -2,7 +2,7 @@
import basic_memory
from basic_memory.models.base import Base
from basic_memory.models.knowledge import Entity, Observation, Relation, ObservationCategory
from basic_memory.models.knowledge import Entity, Observation, Relation
SCHEMA_VERSION = basic_memory.__version__ + "-" + "003"
@@ -10,6 +10,5 @@ __all__ = [
"Base",
"Entity",
"Observation",
"ObservationCategory",
"Relation",
]
+3 -3
View File
@@ -1,10 +1,10 @@
"""Base model class for SQLAlchemy models."""
from sqlalchemy import String, Integer
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.orm import DeclarativeBase
class Base(AsyncAttrs, DeclarativeBase):
"""Base class for all models"""
pass
pass
+45 -66
View File
@@ -1,6 +1,5 @@
"""Knowledge graph models."""
import re
from datetime import datetime
from typing import Optional
@@ -13,18 +12,17 @@ from sqlalchemy import (
DateTime,
Index,
JSON,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from sqlalchemy.orm import Mapped, mapped_column, relationship
from basic_memory.models.base import Base
from enum import Enum
from basic_memory.utils import generate_permalink
class Entity(Base):
"""
Core entity in the knowledge graph.
"""Core entity in the knowledge graph.
Entities represent semantic nodes maintained by the AI layer. Each entity:
- Has a unique numeric ID (database-generated)
@@ -35,11 +33,18 @@ class Entity(Base):
__tablename__ = "entity"
__table_args__ = (
UniqueConstraint("permalink", name="uix_entity_permalink"), # Make permalink unique
# Regular indexes
Index("ix_entity_type", "entity_type"),
Index("ix_entity_title", "title"),
Index("ix_entity_created_at", "created_at"), # For timeline queries
Index("ix_entity_updated_at", "updated_at"), # For timeline queries
# Unique index only for markdown files with non-null permalinks
Index(
"uix_entity_permalink",
"permalink",
unique=True,
sqlite_where=text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
),
)
# Core identity
@@ -49,8 +54,8 @@ class Entity(Base):
entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
content_type: Mapped[str] = mapped_column(String)
# Normalized path for URIs
permalink: Mapped[str] = mapped_column(String, unique=True, index=True)
# Normalized path for URIs - required for markdown files only
permalink: Mapped[Optional[str]] = mapped_column(String, nullable=True, index=True)
# Actual filesystem relative path
file_path: Mapped[str] = mapped_column(String, unique=True, index=True)
# checksum of file
@@ -79,44 +84,20 @@ class Entity(Base):
@property
def relations(self):
"""Get all relations (incoming and outgoing) for this entity."""
return self.incoming_relations + self.outgoing_relations
@validates("permalink")
def validate_permalink(self, key, value):
"""Validate permalink format.
Requirements:
1. Must be valid URI path component
2. Only lowercase letters, numbers, and hyphens (no underscores)
3. Path segments separated by forward slashes
4. No leading/trailing hyphens in segments
"""
if not value:
raise ValueError("Permalink must not be None")
if not re.match(r"^[a-z0-9][a-z0-9\-/]*[a-z0-9]$", value):
raise ValueError(
f"Invalid permalink format: {value}. "
"Use only lowercase letters, numbers, and hyphens."
)
return value
@property
def is_markdown(self):
"""Check if the entity is a markdown file."""
return self.content_type == "text/markdown"
def __repr__(self) -> str:
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
class ObservationCategory(str, Enum):
TECH = "tech"
DESIGN = "design"
FEATURE = "feature"
NOTE = "note"
ISSUE = "issue"
TODO = "todo"
class Observation(Base):
"""
An observation about an entity.
"""An observation about an entity.
Observations are atomic facts or notes about an entity.
"""
@@ -130,13 +111,8 @@ class Observation(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
content: Mapped[str] = mapped_column(Text)
category: Mapped[str] = mapped_column(
String,
nullable=False,
default=ObservationCategory.NOTE.value,
server_default=ObservationCategory.NOTE.value,
)
context: Mapped[str] = mapped_column(Text, nullable=True)
category: Mapped[str] = mapped_column(String, nullable=False, default="note")
context: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
tags: Mapped[Optional[list[str]]] = mapped_column(
JSON, nullable=True, default=list, server_default="[]"
)
@@ -146,27 +122,28 @@ class Observation(Base):
@property
def permalink(self) -> str:
"""
Create synthetic permalink for the observation
We can construct these because observations are always
defined in and owned by a single entity
"""Create synthetic permalink for the observation.
We can construct these because observations are always defined in
and owned by a single entity.
"""
return generate_permalink(
f"{self.entity.permalink}/observations/{self.category}/{self.content}"
)
def __repr__(self) -> str:
def __repr__(self) -> str: # pragma: no cover
return f"Observation(id={self.id}, entity_id={self.entity_id}, content='{self.content}')"
class Relation(Base):
"""
A directed relation between two entities.
"""
"""A directed relation between two entities."""
__tablename__ = "relation"
__table_args__ = (
UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation"),
UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation_from_id_to_id"),
UniqueConstraint(
"from_id", "to_name", "relation_type", name="uix_relation_from_id_to_name"
),
Index("ix_relation_type", "relation_type"),
Index("ix_relation_from_id", "from_id"), # Add FK indexes
Index("ix_relation_to_id", "to_id"),
@@ -174,12 +151,12 @@ class Relation(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
to_id: Mapped[int] = mapped_column(
to_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True
)
to_name: Mapped[str] = mapped_column(String)
relation_type: Mapped[str] = mapped_column(String)
context: Mapped[str] = mapped_column(Text, nullable=True)
context: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Relationships
from_entity = relationship(
@@ -189,16 +166,18 @@ class Relation(Base):
@property
def permalink(self) -> str:
"""Create relation permalink showing the semantic connection:
source/relation_type/target
e.g., "specs/search/implements/features/search-ui"
"""
"""Create relation permalink showing the semantic connection.
return generate_permalink(
f"{self.from_entity.permalink}/{self.relation_type}/{self.to_entity.permalink}"
if self.to_entity
else f"{self.from_entity.permalink}/{self.relation_type}/{self.to_name}"
)
Format: source/relation_type/target
Example: "specs/search/implements/features/search-ui"
"""
# Only create permalinks when both source and target have permalinks
from_permalink = self.from_entity.permalink or self.from_entity.file_path
if self.to_entity:
to_permalink = self.to_entity.permalink or self.to_entity.file_path
return generate_permalink(f"{from_permalink}/{self.relation_type}/{to_permalink}")
return generate_permalink(f"{from_permalink}/{self.relation_type}/{self.to_name}")
def __repr__(self) -> str:
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')"
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')" # pragma: no cover
+1 -1
View File
@@ -31,4 +31,4 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
prefix='1,2,3,4' -- Support longer prefixes for paths
);
""")
""")
+5 -3
View File
@@ -2,6 +2,8 @@ from .entity_repository import EntityRepository
from .observation_repository import ObservationRepository
from .relation_repository import RelationRepository
__all__ = ["EntityRepository", "ObservationRepository", "RelationRepository", ]
__all__ = [
"EntityRepository",
"ObservationRepository",
"RelationRepository",
]
@@ -1,8 +1,8 @@
"""Repository for managing entities in the knowledge graph."""
from typing import List, Optional, Sequence
from pathlib import Path
from typing import List, Optional, Sequence, Union
from sqlalchemy import select, or_, asc
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -12,109 +12,57 @@ from basic_memory.repository.repository import Repository
class EntityRepository(Repository[Entity]):
"""Repository for Entity model."""
"""Repository for Entity model.
Note: All file paths are stored as strings in the database. Convert Path objects
to strings before passing to repository methods.
"""
def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
"""Initialize with session maker."""
super().__init__(session_maker, Entity)
async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
"""Get entity by permalink."""
"""Get entity by permalink.
Args:
permalink: Unique identifier for the entity
"""
query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options())
return await self.find_one(query)
async def get_by_title(self, title: str) -> Optional[Entity]:
"""Get entity by title."""
"""Get entity by title.
Args:
title: Title of the entity to find
"""
query = self.select().where(Entity.title == title).options(*self.get_load_options())
return await self.find_one(query)
async def get_by_file_path(self, file_path: str) -> Optional[Entity]:
"""Get entity by file_path."""
query = self.select().where(Entity.file_path == file_path).options(*self.get_load_options())
return await self.find_one(query)
async def get_by_file_path(self, file_path: Union[Path, str]) -> Optional[Entity]:
"""Get entity by file_path.
async def list_entities(
self,
entity_type: Optional[str] = None,
sort_by: Optional[str] = "updated_at",
include_related: bool = False,
) -> Sequence[Entity]:
"""List all entities, optionally filtered by type and sorted."""
query = self.select()
# Always load base relations
query = query.options(*self.get_load_options())
# Apply filters
if entity_type:
# When include_related is True, get both:
# 1. Entities of the requested type
# 2. Entities that have relations with entities of the requested type
if include_related:
query = query.where(
or_(
Entity.entity_type == entity_type,
Entity.outgoing_relations.any(
Relation.to_entity.has(entity_type=entity_type)
),
Entity.incoming_relations.any(
Relation.from_entity.has(entity_type=entity_type)
),
)
)
else:
query = query.where(Entity.entity_type == entity_type)
# Apply sorting
if sort_by:
sort_field = getattr(Entity, sort_by, Entity.updated_at)
query = query.order_by(asc(sort_field))
result = await self.execute_query(query)
return list(result.scalars().all())
async def get_entity_types(self) -> List[str]:
"""Get list of distinct entity types."""
query = select(Entity.entity_type).distinct()
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def search(self, query_str: str) -> List[Entity]:
Args:
file_path: Path to the entity file (will be converted to string internally)
"""
Search for entities.
Searches across:
- Entity names
- Entity types
- Entity descriptions
- Associated Observations content
"""
search_term = f"%{query_str}%"
query = (
self.select()
.where(
or_(
Entity.title.ilike(search_term),
Entity.entity_type.ilike(search_term),
Entity.summary.ilike(search_term),
Entity.observations.any(Observation.content.ilike(search_term)),
)
)
.where(Entity.file_path == str(file_path))
.options(*self.get_load_options())
)
result = await self.execute_query(query)
return list(result.scalars().all())
return await self.find_one(query)
async def delete_entities_by_doc_id(self, doc_id: int) -> bool:
"""Delete all entities associated with a document."""
return await self.delete_by_fields(doc_id=doc_id)
async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool:
"""Delete entity with the provided file_path.
async def delete_by_file_path(self, file_path: str) -> bool:
"""Delete entity with the provided file_path."""
return await self.delete_by_fields(file_path=file_path)
Args:
file_path: Path to the entity file (will be converted to string internally)
"""
return await self.delete_by_fields(file_path=str(file_path))
def get_load_options(self) -> List[LoaderOption]:
"""Get SQLAlchemy loader options for eager loading relationships."""
return [
selectinload(Entity.observations).selectinload(Observation.entity),
# Load from_relations and both entities for each relation
@@ -126,8 +74,11 @@ class EntityRepository(Repository[Entity]):
]
async def find_by_permalinks(self, permalinks: List[str]) -> Sequence[Entity]:
"""Find multiple entities by their permalink."""
"""Find multiple entities by their permalink.
Args:
permalinks: List of permalink strings to find
"""
# Handle empty input explicitly
if not permalinks:
return []
@@ -139,18 +90,3 @@ class EntityRepository(Repository[Entity]):
result = await self.execute_query(query)
return list(result.scalars().all())
async def delete_by_permalinks(self, permalinks: List[str]) -> int:
"""Delete multiple entities by permalink."""
# Handle empty input explicitly
if not permalinks:
return 0
# Find matching entities
entities = await self.find_by_permalinks(permalinks)
if not entities:
return 0
# Use existing delete_by_ids
return await self.delete_by_ids([entity.id for entity in entities])
@@ -40,12 +40,6 @@ class RelationRepository(Repository[Relation]):
)
return await self.find_one(query)
async def find_by_entity(self, from_entity_id: int) -> Sequence[Relation]:
"""Find all relations from a specific entity."""
query = select(Relation).filter(Relation.from_id == from_entity_id)
result = await self.execute_query(query)
return result.scalars().all()
async def find_by_entities(self, from_id: int, to_id: int) -> Sequence[Relation]:
"""Find all relations between two entities."""
query = select(Relation).where((Relation.from_id == from_id) & (Relation.to_id == to_id))
@@ -73,6 +67,5 @@ class RelationRepository(Repository[Relation]):
result = await self.execute_query(query)
return result.scalars().all()
def get_load_options(self) -> List[LoaderOption]:
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
+3 -40
View File
@@ -1,6 +1,5 @@
"""Base repository implementation."""
from datetime import datetime
from typing import Type, Optional, Any, Sequence, TypeVar, List
from loguru import logger
@@ -98,14 +97,7 @@ class Repository[T: Base]:
entities = (self.Model,)
return select(*entities)
async def refresh(self, instance: T, relationships: list[str] | None = None) -> None:
"""Refresh instance and optionally specified relationships."""
logger.debug(f"Refreshing {self.Model.__name__} instance: {getattr(instance, 'id', None)}")
async with db.scoped_session(self.session_maker) as session:
await session.refresh(instance, relationships or [])
logger.debug(f"Refreshed relationships: {relationships}")
async def find_all(self, skip: int = 0, limit: Optional[int] = 0) -> Sequence[T]:
async def find_all(self, skip: int = 0, limit: Optional[int] = None) -> Sequence[T]:
"""Fetch records from the database with pagination."""
logger.debug(f"Finding all {self.Model.__name__} (skip={skip}, limit={limit})")
@@ -149,35 +141,6 @@ class Repository[T: Base]:
logger.debug(f"No {self.Model.__name__} found")
return entity
async def find_modified_since(self, since: datetime) -> Sequence[T]:
"""Find all records modified since the given timestamp.
This method assumes the model has an updated_at column. Override
in subclasses if a different column should be used.
Args:
since: Datetime to search from
Returns:
Sequence of records modified since the timestamp
"""
logger.debug(f"Finding {self.Model.__name__} modified since: {since}")
if not hasattr(self.Model, "updated_at"):
raise AttributeError(f"{self.Model.__name__} does not have updated_at column")
query = (
select(self.Model)
.filter(self.Model.updated_at >= since)
.options(*self.get_load_options())
)
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(query)
items = result.scalars().all()
logger.debug(f"Found {len(items)} modified {self.Model.__name__} records")
return items
async def create(self, data: dict) -> T:
"""Create a new record from a model instance."""
logger.debug(f"Creating {self.Model.__name__} from entity_data: {data}")
@@ -223,11 +186,11 @@ class Repository[T: Base]:
for key, value in entity_data.items():
if key in self.valid_columns:
setattr(entity, key, value)
elif isinstance(entity_data, self.Model):
for column in self.Model.__table__.columns.keys():
setattr(entity, column, getattr(entity_data, column))
await session.flush() # Make sure changes are flushed
await session.refresh(entity) # Refresh
@@ -21,22 +21,21 @@ class SearchIndexRow:
id: int
type: str
metadata: Optional[dict] = None
file_path: str
# date values
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
permalink: Optional[str] = None
metadata: Optional[dict] = None
# assigned in result
score: Optional[float] = None
# Common fields
permalink: Optional[str] = None
file_path: Optional[str] = None
# Type-specific fields
title: Optional[int] = None # entity
content: Optional[int] = None # entity, observation
title: Optional[str] = None # entity
content: Optional[str] = None # entity, observation
entity_id: Optional[int] = None # observations
category: Optional[str] = None # observations
from_id: Optional[int] = None # relations
@@ -70,24 +69,40 @@ class SearchRepository:
async def init_search_index(self):
"""Create or recreate the search index."""
logger.info("Initializing search index")
async with db.scoped_session(self.session_maker) as session:
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
try:
async with db.scoped_session(self.session_maker) as session:
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
except Exception as e: # pragma: no cover
logger.error(f"Error initializing search index: {e}")
raise e
def _quote_search_term(self, term: str) -> str:
"""Add quotes if term contains special characters.
For FTS5, special characters and phrases need to be quoted to be treated as a single token.
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a search term for FTS5 query.
Args:
term: The search term to prepare
is_prefix: Whether to add prefix search capability (* suffix)
For FTS5:
- Special characters and phrases need to be quoted
- Terms with spaces or special chars need quotes
"""
# List of special characters that need quoting
special_chars = ['/', '*', '-', '.', ' ', '(', ')', '[', ']', '"', "'"]
if "*" in term:
return term
# List of special characters that need quoting (excluding *)
special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
# Check if term contains any special characters
if any(c in term for c in special_chars):
# If the term already contains quotes, escape them
needs_quotes = any(c in term for c in special_chars)
if needs_quotes:
# If the term already contains quotes, escape them and add a wildcard
term = term.replace('"', '""')
return f'"{term}"'
term = f'"{term}"*'
return term
async def search(
@@ -96,26 +111,27 @@ class SearchRepository:
permalink: Optional[str] = None,
permalink_match: Optional[str] = None,
title: Optional[str] = None,
types: List[SearchItemType] = None,
types: Optional[List[SearchItemType]] = None,
after_date: Optional[datetime] = None,
entity_types: List[str] = None,
entity_types: Optional[List[str]] = None,
limit: int = 10,
offset: int = 0,
) -> List[SearchIndexRow]:
"""Search across all indexed content with fuzzy matching."""
conditions = []
params = {}
order_by_clause = ""
# Handle text search for title and content
if search_text:
search_text = self._quote_search_term(search_text.lower().strip())
params["text"] = f"{search_text}*"
search_text = self._prepare_search_term(search_text.strip())
params["text"] = search_text
conditions.append("(title MATCH :text OR content MATCH :text)")
# Handle title match search
if title:
title_text = self._quote_search_term(title.lower().strip())
params["text"] = f"{title_text}*"
title_text = self._prepare_search_term(title.strip())
params["text"] = title_text
conditions.append("title MATCH :text")
# Handle permalink exact search
@@ -125,9 +141,16 @@ class SearchRepository:
# Handle permalink match search, supports *
if permalink_match:
params["permalink"] = self._quote_search_term(permalink_match)
conditions.append("permalink MATCH :permalink")
# Clean and prepare permalink for FTS5 GLOB match
permalink_text = self._prepare_search_term(
permalink_match.lower().strip(), is_prefix=False
)
params["permalink"] = permalink_text
if "*" in permalink_match:
conditions.append("permalink GLOB :permalink")
else:
conditions.append("permalink MATCH :permalink")
# Handle type filter
if types:
type_list = ", ".join(f"'{t.value}'" for t in types)
@@ -142,13 +165,14 @@ class SearchRepository:
if after_date:
params["after_date"] = after_date
conditions.append("datetime(created_at) > datetime(:after_date)")
# order by most recent first
order_by_clause = ", updated_at DESC"
# set limit on search query
params["limit"] = limit
params["offset"] = offset
# Build WHERE clause
where_clause = " AND ".join(conditions) if conditions else "1=1"
@@ -173,9 +197,10 @@ class SearchRepository:
WHERE {where_clause}
ORDER BY score ASC {order_by_clause}
LIMIT :limit
OFFSET :offset
"""
#logger.debug(f"Search {sql} params: {params}")
logger.debug(f"Search {sql} params: {params}")
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(text(sql), params)
rows = result.fetchall()
@@ -201,9 +226,12 @@ class SearchRepository:
for row in rows
]
#for r in results:
# logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
logger.debug(f"Found {len(results)} search results")
for r in results:
logger.debug(
f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
)
return results
async def index_item(
@@ -235,7 +263,16 @@ class SearchRepository:
"""),
search_index_row.to_insert(),
)
logger.debug(f"indexed permalink {search_index_row.permalink}")
logger.debug(f"indexed row {search_index_row}")
await session.commit()
async def delete_by_entity_id(self, entity_id: int):
"""Delete an item from the search index by entity_id."""
async with db.scoped_session(self.session_maker) as session:
await session.execute(
text("DELETE FROM search_index WHERE entity_id = :entity_id"),
{"entity_id": entity_id},
)
await session.commit()
async def delete_by_permalink(self, permalink: str):
@@ -250,17 +287,14 @@ class SearchRepository:
async def execute_query(
self,
query: Executable,
params: Optional[Dict[str, Any]] = None,
params: Dict[str, Any],
) -> Result[Any]:
"""Execute a query asynchronously."""
#logger.debug(f"Executing query: {query}")
# logger.debug(f"Executing query: {query}, params: {params}")
async with db.scoped_session(self.session_maker) as session:
start_time = time.perf_counter()
if params:
result = await session.execute(query, params)
else:
result = await session.execute(query)
result = await session.execute(query, params)
end_time = time.perf_counter()
elapsed_time = end_time - start_time
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
return result
return result
+1 -12
View File
@@ -23,7 +23,7 @@ from basic_memory.schemas.delete import (
from basic_memory.schemas.request import (
SearchNodesRequest,
GetEntitiesRequest,
CreateRelationsRequest, UpdateEntityRequest,
CreateRelationsRequest,
)
# Response models
@@ -37,12 +37,6 @@ from basic_memory.schemas.response import (
DeleteEntitiesResponse,
)
# Discovery and analytics models
from basic_memory.schemas.discovery import (
EntityTypeList,
ObservationCategoryList, TypedEntityList,
)
# For convenient imports, export all models
__all__ = [
# Base
@@ -55,7 +49,6 @@ __all__ = [
"SearchNodesRequest",
"GetEntitiesRequest",
"CreateRelationsRequest",
"UpdateEntityRequest",
# Responses
"SQLAlchemyModel",
"ObservationResponse",
@@ -66,8 +59,4 @@ __all__ = [
"DeleteEntitiesResponse",
# Delete Operations
"DeleteEntitiesRequest",
# Discovery and Analytics
"EntityTypeList",
"ObservationCategoryList",
"TypedEntityList"
]
+25 -63
View File
@@ -14,14 +14,13 @@ Key Concepts:
import mimetypes
import re
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import List, Optional, Annotated, Dict
from annotated_types import MinLen, MaxLen
from dateparser import parse
from pydantic import BaseModel, BeforeValidator, Field, model_validator, ValidationError
from pydantic import BaseModel, BeforeValidator, Field, model_validator
from basic_memory.utils import generate_permalink
@@ -47,44 +46,6 @@ def to_snake_case(name: str) -> str:
return s2.lower()
def validate_path_format(path: str) -> str:
"""Validate path has the correct format: not empty."""
if not path or not isinstance(path, str):
raise ValueError("Path must be a non-empty string")
return path
class ObservationCategory(str, Enum):
"""Categories for structuring observations.
Categories help organize knowledge and make it easier to find later:
- tech: Implementation details and technical notes
- design: Architecture decisions and patterns
- feature: User-facing capabilities
- note: General observations (default)
- issue: Problems or concerns
- todo: Future work items
Categories are case-insensitive for easier use.
"""
TECH = "tech"
DESIGN = "design"
FEATURE = "feature"
NOTE = "note"
ISSUE = "issue"
TODO = "todo"
@classmethod
def _missing_(cls, value: str) -> "ObservationCategory":
"""Handle case-insensitive lookup."""
try:
return cls(value.lower())
except ValueError:
return None
def validate_timeframe(timeframe: str) -> str:
"""Convert human readable timeframes to a duration relative to the current time."""
if not isinstance(timeframe, str):
@@ -110,12 +71,9 @@ def validate_timeframe(timeframe: str) -> str:
return f"{days}d"
TimeFrame = Annotated[
str,
BeforeValidator(validate_timeframe)
]
TimeFrame = Annotated[str, BeforeValidator(validate_timeframe)]
PathId = Annotated[str, BeforeValidator(validate_path_format)]
Permalink = Annotated[str, MinLen(1)]
"""Unique identifier in format '{path}/{normalized_name}'."""
@@ -149,14 +107,16 @@ ObservationStr = Annotated[
MaxLen(1000), # Keep reasonable length
]
class Observation(BaseModel):
"""A single observation with category, content, and optional context."""
category: ObservationCategory
category: Optional[str] = None
content: ObservationStr
tags: Optional[List[str]] = Field(default_factory=list)
context: Optional[str] = None
class Relation(BaseModel):
"""Represents a directed edge between entities in the knowledge graph.
@@ -165,8 +125,8 @@ class Relation(BaseModel):
or recipient entity.
"""
from_id: PathId
to_id: PathId
from_id: Permalink
to_id: Permalink
relation_type: RelationType
context: Optional[str] = None
@@ -181,7 +141,7 @@ class Entity(BaseModel):
- Optional relations to other entities
- Optional description for high-level overview
"""
# private field to override permalink
_permalink: Optional[str] = None
@@ -192,28 +152,30 @@ class Entity(BaseModel):
entity_metadata: Optional[Dict] = Field(default=None, description="Optional metadata")
content_type: ContentType = Field(
description="MIME type of the content (e.g. text/markdown, image/jpeg)",
examples=["text/markdown", "image/jpeg"], default="text/markdown"
examples=["text/markdown", "image/jpeg"],
default="text/markdown",
)
@property
def file_path(self):
"""Get the file path for this entity based on its permalink."""
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
if self.content_type == "text/markdown":
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
else:
return f"{self.folder}/{self.title}" if self.folder else self.title
@property
def permalink(self) -> PathId:
def permalink(self) -> Permalink:
"""Get a url friendly path}."""
return self._permalink or generate_permalink(self.file_path)
@model_validator(mode="after")
@classmethod
def infer_content_type(cls, entity: "Entity") -> Dict | None:
"""Infer content_type from file_path if not provided."""
if not entity.content_type:
path = Path(entity.file_path)
def infer_content_type(self) -> "Entity": # pragma: no cover
if not self.content_type:
path = Path(self.file_path)
if not path.exists():
return None
mime_type, _ = mimetypes.guess_type(path.name)
entity.content_type = mime_type or "text/plain"
return entity
self.content_type = "text/plain"
else:
mime_type, _ = mimetypes.guess_type(path.name)
self.content_type = mime_type or "text/plain"
return self
+2 -3
View File
@@ -21,7 +21,7 @@ from typing import List, Annotated
from annotated_types import MinLen
from pydantic import BaseModel
from basic_memory.schemas.base import Relation, Observation, PathId
from basic_memory.schemas.base import Permalink
class DeleteEntitiesRequest(BaseModel):
@@ -34,5 +34,4 @@ class DeleteEntitiesRequest(BaseModel):
4. Deletes the corresponding markdown file
"""
permalinks: Annotated[List[PathId], MinLen(1)]
permalinks: Annotated[List[Permalink], MinLen(1)]
-25
View File
@@ -1,25 +0,0 @@
"""Schemas for knowledge discovery and analytics endpoints."""
from typing import List, Optional
from pydantic import BaseModel, Field
from basic_memory.schemas.response import EntityResponse
class EntityTypeList(BaseModel):
"""List of unique entity types in the system."""
types: List[str]
class ObservationCategoryList(BaseModel):
"""List of unique observation categories in the system."""
categories: List[str]
class TypedEntityList(BaseModel):
"""List of entities of a specific type."""
entity_type: str = Field(..., description="Type of entities in the list")
entities: List[EntityResponse]
total: int = Field(..., description="Total number of entities")
sort_by: Optional[str] = Field(None, description="Field used for sorting")
include_related: bool = Field(False, description="Whether related entities are included")
+26 -15
View File
@@ -1,16 +1,15 @@
"""Schemas for memory context."""
from datetime import datetime
from typing import Dict, List, Any, Optional, Annotated
from typing import List, Optional, Annotated, Sequence
import pydantic
from annotated_types import MinLen, MaxLen
from pydantic import BaseModel, field_validator, Field, BeforeValidator, TypeAdapter, AnyUrl
from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter
from basic_memory.schemas.search import SearchItemType
def normalize_memory_url(url: str) -> str:
def normalize_memory_url(url: str | None) -> str:
"""Normalize a MemoryUrl string.
Args:
@@ -25,6 +24,9 @@ def normalize_memory_url(url: str) -> str:
>>> normalize_memory_url("memory://specs/search")
'memory://specs/search'
"""
if not url:
return ""
clean_path = url.removeprefix("memory://")
return f"memory://{clean_path}"
@@ -32,19 +34,20 @@ def normalize_memory_url(url: str) -> str:
MemoryUrl = Annotated[
str,
BeforeValidator(str.strip), # Clean whitespace
MinLen(1),
MaxLen(2028),
MinLen(1),
MaxLen(2028),
]
memory_url = TypeAdapter(MemoryUrl)
memory_url = TypeAdapter(MemoryUrl)
def memory_url_path(url: memory_url) -> str:
def memory_url_path(url: memory_url) -> str: # pyright: ignore
"""
Returns the uri for a url value by removing the prefix "memory://" from a given MemoryUrl.
This function processes a given MemoryUrl by removing the "memory://"
prefix and returns the resulting string. If the provided url does not
begin with "memory://", the function will simply return the input url
prefix and returns the resulting string. If the provided url does not
begin with "memory://", the function will simply return the input url
unchanged.
:param url: A MemoryUrl object representing the URL with a "memory://" prefix.
@@ -55,12 +58,11 @@ def memory_url_path(url: memory_url) -> str:
return url.removeprefix("memory://")
class EntitySummary(BaseModel):
"""Simplified entity representation."""
type: str = "entity"
permalink: str
permalink: Optional[str]
title: str
file_path: str
created_at: datetime
@@ -70,19 +72,25 @@ class RelationSummary(BaseModel):
"""Simplified relation representation."""
type: str = "relation"
title: str
file_path: str
permalink: str
relation_type: str
from_id: str
to_id: Optional[str] = None
created_at: datetime
class ObservationSummary(BaseModel):
"""Simplified observation representation."""
type: str = "observation"
title: str
file_path: str
permalink: str
category: str
content: str
created_at: datetime
class MemoryMetadata(BaseModel):
@@ -101,14 +109,17 @@ class GraphContext(BaseModel):
"""Complete context response."""
# Direct matches
primary_results: List[EntitySummary | RelationSummary | ObservationSummary] = Field(
primary_results: Sequence[EntitySummary | RelationSummary | ObservationSummary] = Field(
description="results directly matching URI"
)
# Related entities
related_results: List[EntitySummary | RelationSummary | ObservationSummary] = Field(
related_results: Sequence[EntitySummary | RelationSummary | ObservationSummary] = Field(
description="related results"
)
# Context metadata
metadata: MemoryMetadata
metadata: MemoryMetadata
page: int = 1
page_size: int = 1
+4 -23
View File
@@ -1,23 +1,16 @@
"""Request schemas for interacting with the knowledge graph."""
from typing import List, Optional, Annotated, Dict, Any
from typing import List, Optional, Annotated
from annotated_types import MaxLen, MinLen
from pydantic import BaseModel
from basic_memory.schemas.base import (
Observation,
Entity,
Relation,
PathId,
ObservationCategory,
EntityType,
Permalink,
)
class SearchNodesRequest(BaseModel):
"""Search for entities in the knowledge graph.
@@ -47,7 +40,7 @@ class SearchNodesRequest(BaseModel):
"""
query: Annotated[str, MinLen(1), MaxLen(200)]
category: Optional[ObservationCategory] = None
category: Optional[str] = None
class GetEntitiesRequest(BaseModel):
@@ -58,20 +51,8 @@ class GetEntitiesRequest(BaseModel):
discovered through search.
"""
permalinks: Annotated[List[PathId], MinLen(1)]
permalinks: Annotated[List[Permalink], MinLen(1), MaxLen(10)]
class CreateRelationsRequest(BaseModel):
relations: List[Relation]
## update
# TODO remove UpdateEntityRequest
class UpdateEntityRequest(BaseModel):
"""Request to update an existing entity."""
title: Optional[str] = None
entity_type: Optional[EntityType] = None
content: Optional[str] = None
entity_metadata: Optional[Dict[str, Any]] = None
+18 -9
View File
@@ -11,11 +11,12 @@ Key Features:
4. Bulk operations return all affected items
"""
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices
from basic_memory.schemas.base import Relation, PathId, EntityType, ContentType, Observation
from basic_memory.schemas.base import Relation, Permalink, EntityType, ContentType, Observation
class SQLAlchemyModel(BaseModel):
@@ -28,7 +29,7 @@ class SQLAlchemyModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class ObservationResponse(Observation, SQLAlchemyModel):
"""Schema for observation data returned from the service.
@@ -43,6 +44,8 @@ class ObservationResponse(Observation, SQLAlchemyModel):
}
"""
permalink: Permalink
class RelationResponse(Relation, SQLAlchemyModel):
"""Response schema for relation operations.
@@ -59,7 +62,9 @@ class RelationResponse(Relation, SQLAlchemyModel):
}
"""
from_id: PathId = Field(
permalink: Permalink
from_id: Permalink = Field(
# use the permalink from the associated Entity
# or the from_id value
validation_alias=AliasChoices(
@@ -67,25 +72,26 @@ class RelationResponse(Relation, SQLAlchemyModel):
"from_id",
)
)
to_id: Optional[PathId] = Field(
to_id: Optional[Permalink] = Field( # pyright: ignore
# use the permalink from the associated Entity
# or the to_id value
validation_alias=AliasChoices(
AliasPath("to_entity", "permalink"),
"to_id",
), default=None
),
default=None,
)
to_name: Optional[PathId] = Field(
to_name: Optional[Permalink] = Field(
# use the permalink from the associated Entity
# or the to_id value
validation_alias=AliasChoices(
AliasPath("to_entity", "title"),
"to_name",
), default=None
),
default=None,
)
class EntityResponse(SQLAlchemyModel):
"""Complete entity data returned from the service.
@@ -125,14 +131,17 @@ class EntityResponse(SQLAlchemyModel):
}
"""
permalink: PathId
permalink: Permalink
title: str
file_path: str
entity_type: EntityType
entity_metadata: Optional[Dict] = None
checksum: Optional[str] = None
content_type: ContentType
observations: List[ObservationResponse] = []
relations: List[RelationResponse] = []
created_at: datetime
updated_at: datetime
class EntityListResponse(SQLAlchemyModel):
+7 -7
View File
@@ -49,8 +49,6 @@ class SearchQuery(BaseModel):
@classmethod
def validate_date(cls, v: Optional[Union[datetime, str]]) -> Optional[str]:
"""Convert datetime to ISO format if needed."""
if v is None:
return None
if isinstance(v, datetime):
return v.isoformat()
return v
@@ -70,13 +68,13 @@ class SearchResult(BaseModel):
"""Search result with score and metadata."""
id: int
title: str
type: SearchItemType
score: Optional[float] = None
metadata: Optional[dict] = None
score: float
permalink: Optional[str]
file_path: str
# Common fields
permalink: Optional[str] = None
file_path: Optional[str] = None
metadata: Optional[dict] = None
# Type-specific fields
entity_id: Optional[int] = None # For observations
@@ -105,6 +103,8 @@ class SearchResponse(BaseModel):
"""Wrapper for search results."""
results: List[SearchResult]
current_page: int
page_size: int
# Schema for future advanced search endpoint
+2 -7
View File
@@ -1,12 +1,7 @@
"""Services package."""
from .database_service import DatabaseService
from .service import BaseService
from .file_service import FileService
from .entity_service import EntityService
__all__ = [
"BaseService",
"FileService",
"EntityService",
"DatabaseService"
]
__all__ = ["BaseService", "FileService", "EntityService"]
+127 -113
View File
@@ -50,15 +50,17 @@ class ContextService:
async def build_context(
self,
memory_url: MemoryUrl = None,
types: List[SearchItemType] = None,
memory_url: Optional[MemoryUrl] = None,
types: Optional[List[SearchItemType]] = None,
depth: int = 1,
since: Optional[datetime] = None,
max_results: int = 10,
limit=10,
offset=0,
max_related: int = 10,
):
"""Build rich context from a memory:// URI."""
logger.debug(
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' max_results: '{max_results}'"
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
)
if memory_url:
@@ -67,16 +69,20 @@ class ContextService:
if "*" in path:
logger.debug(f"Pattern search for '{path}'")
primary = await self.search_repository.search(
permalink_match=path
permalink_match=path, limit=limit, offset=offset
)
# Direct lookup for exact path
else:
logger.debug(f"Direct lookup for '{path}'")
primary = await self.search_repository.search(permalink=path)
primary = await self.search_repository.search(
permalink=path, limit=limit, offset=offset
)
else:
logger.debug(f"Build context for '{types}'")
primary = await self.search_repository.search(types=types, after_date=since)
primary = await self.search_repository.search(
types=types, after_date=since, limit=limit, offset=offset
)
# Get type_id pairs for traversal
@@ -85,7 +91,7 @@ class ContextService:
# Find related content
related = await self.find_related(
type_id_pairs, max_depth=depth, since=since, max_results=max_results
type_id_pairs, max_depth=depth, since=since, max_results=max_related
)
logger.debug(f"Found {len(related)} related results")
for r in related:
@@ -120,11 +126,19 @@ class ContextService:
- Connected entities
- Their observations
- Relations that connect them
Note on depth:
Each traversal step requires two depth levels - one to find the relation,
and another to follow that relation to an entity. So a max_depth of 4 allows
traversal through two entities (relation->entity->relation->entity), while reaching
an entity three steps away requires max_depth=6 (relation->entity->relation->entity->relation->entity).
"""
max_depth = max_depth * 2
if not type_id_pairs:
return []
logger.debug(f"Finding connected items for {len(type_id_pairs)} with depth {max_depth}")
logger.debug(f"Finding connected items for {type_id_pairs} with depth {max_depth}")
# Build the VALUES clause directly since SQLite doesn't handle parameterized IN well
values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
@@ -132,7 +146,7 @@ class ContextService:
# Parameters for bindings
params = {"max_depth": max_depth, "max_results": max_results}
if since:
params["since_date"] = since.isoformat()
params["since_date"] = since.isoformat() # pyright: ignore
# Build date filter
date_filter = "AND base.created_at >= :since_date" if since else ""
@@ -140,113 +154,113 @@ class ContextService:
related_date_filter = "AND e.created_at >= :since_date" if since else ""
query = text(f"""
WITH RECURSIVE context_graph AS (
-- Base case: seed items (unchanged)
SELECT
id,
type,
title,
permalink,
file_path,
from_id,
to_id,
relation_type,
content,
category,
entity_id,
0 as depth,
id as root_id,
created_at,
created_at as relation_date,
0 as is_incoming
FROM search_index base
WHERE (base.type, base.id) IN ({values})
{date_filter}
WITH RECURSIVE context_graph AS (
-- Base case: seed items
SELECT
id,
type,
title,
permalink,
file_path,
from_id,
to_id,
relation_type,
content,
category,
entity_id,
0 as depth,
id as root_id,
created_at,
created_at as relation_date,
0 as is_incoming
FROM search_index base
WHERE (base.type, base.id) IN ({values})
{date_filter}
UNION -- Changed from UNION ALL
UNION ALL -- Allow same paths at different depths
-- Get relations from current entities
SELECT DISTINCT
r.id,
r.type,
r.title,
r.permalink,
r.file_path,
r.from_id,
r.to_id,
r.relation_type,
r.content,
r.category,
r.entity_id,
cg.depth + 1,
cg.root_id,
r.created_at,
r.created_at as relation_date,
CASE WHEN r.from_id = cg.id THEN 0 ELSE 1 END as is_incoming
FROM context_graph cg
JOIN search_index r ON (
cg.type = 'entity' AND
r.type = 'relation' AND
(r.from_id = cg.id OR r.to_id = cg.id)
{r1_date_filter}
)
WHERE cg.depth < :max_depth
-- Get relations from current entities
SELECT DISTINCT
r.id,
r.type,
r.title,
r.permalink,
r.file_path,
r.from_id,
r.to_id,
r.relation_type,
r.content,
r.category,
r.entity_id,
cg.depth + 1,
cg.root_id,
r.created_at,
r.created_at as relation_date,
CASE WHEN r.from_id = cg.id THEN 0 ELSE 1 END as is_incoming
FROM context_graph cg
JOIN search_index r ON (
cg.type = 'entity' AND
r.type = 'relation' AND
(r.from_id = cg.id OR r.to_id = cg.id)
{r1_date_filter}
)
WHERE cg.depth < :max_depth
UNION -- Changed from UNION ALL
UNION ALL
-- Get entities connected by relations
SELECT DISTINCT
e.id,
e.type,
e.title,
e.permalink,
e.file_path,
e.from_id,
e.to_id,
e.relation_type,
e.content,
e.category,
e.entity_id,
cg.depth,
cg.root_id,
e.created_at,
cg.relation_date,
cg.is_incoming
FROM context_graph cg
JOIN search_index e ON (
cg.type = 'relation' AND
e.type = 'entity' AND
e.id = CASE
WHEN cg.from_id = cg.id THEN cg.to_id
ELSE cg.from_id
END
{related_date_filter}
)
WHERE cg.depth < :max_depth
)
SELECT DISTINCT
type,
id,
title,
permalink,
file_path,
from_id,
to_id,
relation_type,
content,
category,
entity_id,
MIN(depth) as depth,
root_id,
created_at
FROM context_graph
WHERE (type, id) NOT IN ({values})
GROUP BY
type, id, title, permalink, from_id, to_id,
relation_type, category, entity_id,
root_id, created_at
ORDER BY depth, type, id
LIMIT :max_results
-- Get entities connected by relations
SELECT DISTINCT
e.id,
e.type,
e.title,
e.permalink,
e.file_path,
e.from_id,
e.to_id,
e.relation_type,
e.content,
e.category,
e.entity_id,
cg.depth + 1, -- Increment depth for entities
cg.root_id,
e.created_at,
cg.relation_date,
cg.is_incoming
FROM context_graph cg
JOIN search_index e ON (
cg.type = 'relation' AND
e.type = 'entity' AND
e.id = CASE
WHEN cg.is_incoming = 0 THEN cg.to_id -- Fixed entity lookup
ELSE cg.from_id
END
{related_date_filter}
)
WHERE cg.depth < :max_depth
)
SELECT DISTINCT
type,
id,
title,
permalink,
file_path,
from_id,
to_id,
relation_type,
content,
category,
entity_id,
MIN(depth) as depth,
root_id,
created_at
FROM context_graph
WHERE (type, id) NOT IN ({values})
GROUP BY
type, id, title, permalink, from_id, to_id,
relation_type, category, entity_id,
root_id, created_at
ORDER BY depth, type, id
LIMIT :max_results
""")
result = await self.search_repository.execute_query(query, params=params)
@@ -1,159 +0,0 @@
"""Service for managing database lifecycle and schema validation."""
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple, List
from alembic.runtime.migration import MigrationContext
from alembic.autogenerate import compare_metadata
from loguru import logger
from sqlalchemy import MetaData
from sqlalchemy.ext.asyncio import AsyncSession
from basic_memory import db
from basic_memory.config import ProjectConfig
from basic_memory.models import Base
from basic_memory.repository.search_repository import SearchRepository
async def check_schema_matches_models(session: AsyncSession) -> Tuple[bool, List[str]]:
"""Check if database schema matches SQLAlchemy models.
Returns:
tuple[bool, list[str]]: (matches, list of differences)
"""
# Get current DB schema via migration context
conn = await session.connection()
def _compare_schemas(connection):
context = MigrationContext.configure(connection)
return compare_metadata(context, Base.metadata)
# Run comparison in sync context
differences = await conn.run_sync(_compare_schemas)
if not differences:
return True, []
# Format differences into readable messages
diff_messages = []
for diff in differences:
if diff[0] == 'add_table':
diff_messages.append(f"Missing table: {diff[1].name}")
elif diff[0] == 'remove_table':
diff_messages.append(f"Extra table: {diff[1].name}")
elif diff[0] == 'add_column':
diff_messages.append(f"Missing column: {diff[3]} in table {diff[2]}")
elif diff[0] == 'remove_column':
diff_messages.append(f"Extra column: {diff[3]} in table {diff[2]}")
elif diff[0] == 'modify_type':
diff_messages.append(f"Column type mismatch: {diff[3]} in table {diff[2]}")
return False, diff_messages
class DatabaseService:
"""Manages database lifecycle including schema validation and backups."""
def __init__(
self,
config: ProjectConfig,
db_type: db.DatabaseType = db.DatabaseType.FILESYSTEM,
):
self.config = config
self.db_path = Path(config.database_path)
self.db_type = db_type
async def create_backup(self) -> Optional[Path]:
"""Create backup of existing database file.
Returns:
Optional[Path]: Path to backup file if created, None if no DB exists
"""
if self.db_type == db.DatabaseType.MEMORY:
return None # Skip backups for in-memory DB
if not self.db_path.exists():
return None
# Create backup with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = self.db_path.with_suffix(f".{timestamp}.backup")
try:
self.db_path.rename(backup_path)
logger.info(f"Created database backup: {backup_path}")
# make a new empty file
self.db_path.touch()
return backup_path
except Exception as e:
logger.error(f"Failed to create database backup: {e}")
return None
async def initialize_db(self):
"""Initialize database with current schema."""
logger.info("Initializing database...")
if self.db_type == db.DatabaseType.FILESYSTEM:
await self.create_backup()
# Drop existing tables if any
await db.drop_db()
# Create tables with current schema
await db.get_or_create_db(
db_path=self.db_path,
db_type=self.db_type
)
logger.info("Database initialized with current schema")
async def check_db(self) -> bool:
"""Check database state and rebuild if schema doesn't match models.
Returns:
bool: True if DB is ready for use, False if initialization failed
"""
try:
_, session_maker = await db.get_or_create_db(
db_path=self.db_path,
db_type=self.db_type
)
async with db.scoped_session(session_maker) as db_session:
# Check actual schema matches
matches, differences = await check_schema_matches_models(db_session)
if not matches:
logger.warning("Database schema does not match models:")
for diff in differences:
logger.warning(f" {diff}")
logger.info("Rebuilding database to match current models...")
await self.initialize_db()
return True
logger.info("Database schema matches models")
return True
except Exception as e:
logger.error(f"Database initialization failed: {e}")
return False
async def cleanup_backups(self, keep_count: int = 5):
"""Clean up old database backups, keeping the N most recent."""
if self.db_type == db.DatabaseType.MEMORY:
return # Skip cleanup for in-memory DB
backup_pattern = "*.backup" # Use relative pattern
backups = sorted(
self.db_path.parent.glob(backup_pattern),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
# Remove old backups
for backup in backups[keep_count:]:
try:
backup.unlink()
logger.debug(f"Removed old backup: {backup}")
except Exception as e:
logger.error(f"Failed to remove backup {backup}: {e}")
+45 -70
View File
@@ -1,10 +1,9 @@
"""Service for managing entities in the database."""
from pathlib import Path
from typing import Sequence, List, Optional
from typing import Sequence, List, Optional, Tuple, Union
import frontmatter
from frontmatter import Post
from loguru import logger
from sqlalchemy.exc import IntegrityError
@@ -14,6 +13,7 @@ from basic_memory.models import Entity as EntityModel, Observation, Relation
from basic_memory.repository import ObservationRepository, RelationRepository
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.schemas import Entity as EntitySchema
from basic_memory.schemas.base import Permalink
from basic_memory.services.exceptions import EntityNotFoundError, EntityCreationError
from basic_memory.services import FileService
from basic_memory.services import BaseService
@@ -42,9 +42,7 @@ class EntityService(BaseService[EntityModel]):
self.link_resolver = link_resolver
async def resolve_permalink(
self,
file_path: Path,
markdown: Optional[EntityMarkdown] = None
self, file_path: Permalink | Path, markdown: Optional[EntityMarkdown] = None
) -> str:
"""Get or generate unique permalink for an entity.
@@ -54,19 +52,17 @@ class EntityService(BaseService[EntityModel]):
3. For existing files, keep current permalink from db
4. Generate new unique permalink from file path
"""
file_path = str(file_path)
# If markdown has explicit permalink, try to validate it
if markdown and markdown.frontmatter.permalink:
desired_permalink = markdown.frontmatter.permalink
existing = await self.repository.get_by_permalink(desired_permalink)
# If no conflict or it's our own file, use as is
if not existing or existing.file_path == file_path:
if not existing or existing.file_path == str(file_path):
return desired_permalink
# For existing files, try to find current permalink
existing = await self.repository.get_by_file_path(file_path)
existing = await self.repository.get_by_file_path(str(file_path))
if existing:
return existing.permalink
@@ -85,12 +81,11 @@ class EntityService(BaseService[EntityModel]):
logger.debug(f"creating unique permalink: {permalink}")
return permalink
async def create_or_update_entity(self, schema: EntitySchema) -> (EntityModel, bool):
"""Create new entity or update existing one.
if a new entity is created, the return value is (entity, True)
"""
async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityModel, bool]:
"""Create new entity or update existing one.
Returns: (entity, is_new) where is_new is True if a new entity was created
"""
logger.debug(f"Creating or updating entity: {schema}")
# Try to find existing entity using smart resolution
@@ -107,7 +102,7 @@ class EntityService(BaseService[EntityModel]):
"""Create a new entity and write to filesystem."""
logger.debug(f"Creating entity: {schema.permalink}")
# get file path
# Get file path and ensure it's a Path object
file_path = Path(schema.file_path)
if await self.file_service.exists(file_path):
@@ -127,24 +122,23 @@ class EntityService(BaseService[EntityModel]):
# parse entity from file
entity_markdown = await self.entity_parser.parse_file(file_path)
# create entity
created_entity = await self.create_entity_from_markdown(
file_path, entity_markdown
)
created = await self.create_entity_from_markdown(file_path, entity_markdown)
# add relations
entity = await self.update_entity_relations(file_path, entity_markdown)
entity = await self.update_entity_relations(created.file_path, entity_markdown)
# Set final checksum to mark complete
return await self.repository.update(entity.id, {"checksum": checksum})
async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> EntityModel:
"""Update an entity's content and metadata."""
logger.debug(f"Updating entity with permalink: {entity.permalink}")
logger.debug(
f"Updating entity with permalink: {entity.permalink} content-type: {schema.content_type}"
)
# get file path
# Convert file path string to Path
file_path = Path(entity.file_path)
post = await schema_to_markdown(schema)
@@ -157,25 +151,28 @@ class EntityService(BaseService[EntityModel]):
entity_markdown = await self.entity_parser.parse_file(file_path)
# update entity in db
entity = await self.update_entity_and_observations(
file_path, entity_markdown
)
entity = await self.update_entity_and_observations(file_path, entity_markdown)
# add relations
await self.update_entity_relations(file_path, entity_markdown)
await self.update_entity_relations(str(file_path), entity_markdown)
# Set final checksum to match file
entity = await self.repository.update(entity.id, {"checksum": checksum})
return entity
async def delete_entity(self, permalink: str) -> bool:
async def delete_entity(self, permalink_or_id: str | int) -> bool:
"""Delete entity and its file."""
logger.debug(f"Deleting entity: {permalink}")
logger.debug(f"Deleting entity: {permalink_or_id}")
try:
# Get entity first for file deletion
entity = await self.get_by_permalink(permalink)
if isinstance(permalink_or_id, str):
entity = await self.get_by_permalink(permalink_or_id)
else:
entities = await self.get_entities_by_id([permalink_or_id])
assert len(entities) == 1, f"Expected 1 entity, got {len(entities)}"
entity = entities[0]
# Delete file first
await self.file_service.delete_entity_file(entity)
@@ -184,13 +181,9 @@ class EntityService(BaseService[EntityModel]):
return await self.repository.delete(entity.id)
except EntityNotFoundError:
logger.info(f"Entity not found: {permalink}")
logger.info(f"Entity not found: {permalink_or_id}")
return True # Already deleted
except Exception as e:
logger.error(f"Failed to delete entity: {e}")
raise
async def get_by_permalink(self, permalink: str) -> EntityModel:
"""Get entity by type and name combination."""
logger.debug(f"Getting entity by permalink: {permalink}")
@@ -199,32 +192,19 @@ class EntityService(BaseService[EntityModel]):
raise EntityNotFoundError(f"Entity not found: {permalink}")
return db_entity
async def get_all(self) -> Sequence[EntityModel]:
"""Get all entities."""
return await self.repository.find_all()
async def get_entity_types(self) -> List[str]:
"""Get list of all distinct entity types in the system."""
logger.debug("Getting all distinct entity types")
return await self.repository.get_entity_types()
async def list_entities(
self,
entity_type: Optional[str] = None,
sort_by: Optional[str] = "updated_at",
include_related: bool = False,
) -> Sequence[EntityModel]:
"""List entities with optional filtering and sorting."""
logger.debug(f"Listing entities: type={entity_type} sort={sort_by}")
return await self.repository.list_entities(entity_type=entity_type, sort_by=sort_by)
async def get_entities_by_id(self, ids: List[int]) -> Sequence[EntityModel]:
"""Get specific entities and their relationships."""
logger.debug(f"Getting entities: {ids}")
return await self.repository.find_by_ids(ids)
async def get_entities_by_permalinks(self, permalinks: List[str]) -> Sequence[EntityModel]:
"""Get specific nodes and their relationships."""
logger.debug(f"Getting entities permalinks: {permalinks}")
return await self.repository.find_by_permalinks(permalinks)
async def delete_entity_by_file_path(self, file_path):
await self.repository.delete_by_file_path(file_path)
async def delete_entity_by_file_path(self, file_path: Union[str, Path]) -> None:
"""Delete entity by file path."""
await self.repository.delete_by_file_path(str(file_path))
async def create_entity_from_markdown(
self, file_path: Path, markdown: EntityMarkdown
@@ -234,15 +214,15 @@ class EntityService(BaseService[EntityModel]):
Creates the entity with null checksum to indicate sync not complete.
Relations will be added in second pass.
"""
logger.debug(f"Creating entity: {markdown.frontmatter.title}")
logger.debug(f"Creating entity: {markdown.frontmatter.title}")
model = entity_model_from_markdown(file_path, markdown)
# Mark as incomplete sync
# Mark as incomplete because we still need to add relations
model.checksum = None
return await self.add(model)
return await self.repository.add(model)
async def update_entity_and_observations(
self, file_path: Path | str, markdown: EntityMarkdown
self, file_path: Path, markdown: EntityMarkdown
) -> EntityModel:
"""Update entity fields and observations.
@@ -250,11 +230,8 @@ class EntityService(BaseService[EntityModel]):
to indicate sync not complete.
"""
logger.debug(f"Updating entity and observations: {file_path}")
file_path = str(file_path)
db_entity = await self.repository.get_by_file_path(file_path)
if not db_entity:
raise EntityNotFoundError(f"Entity not found: {file_path}")
db_entity = await self.repository.get_by_file_path(str(file_path))
# Clear observations for entity
await self.observation_repository.delete_by_fields(entity_id=db_entity.id)
@@ -277,9 +254,8 @@ class EntityService(BaseService[EntityModel]):
# checksum value is None == not finished with sync
db_entity.checksum = None
# update entity
# checksum value is None == not finished with sync
return await self.repository.update(
db_entity.id,
db_entity,
@@ -287,14 +263,13 @@ class EntityService(BaseService[EntityModel]):
async def update_entity_relations(
self,
file_path: Path | str,
path: str,
markdown: EntityMarkdown,
) -> EntityModel:
"""Update relations for entity"""
logger.debug(f"Updating relations for entity: {file_path}")
logger.debug(f"Updating relations for entity: {path}")
file_path = str(file_path)
db_entity = await self.repository.get_by_file_path(file_path)
db_entity = await self.repository.get_by_file_path(path)
# Clear existing relations first
await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
@@ -328,4 +303,4 @@ class EntityService(BaseService[EntityModel]):
)
continue
return await self.repository.get_by_file_path(file_path)
return await self.repository.get_by_file_path(path)
+1
View File
@@ -9,6 +9,7 @@ class EntityNotFoundError(Exception):
pass
class EntityCreationError(Exception):
"""Raised when an entity cannot be created"""
+142 -111
View File
@@ -1,20 +1,26 @@
"""Service for file operations with checksum tracking."""
import mimetypes
from os import stat_result
from pathlib import Path
from typing import Optional, Tuple
from typing import Tuple, Union, Dict, Any
from loguru import logger
from basic_memory import file_utils
from basic_memory.file_utils import FileError
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.utils import entity_model_to_markdown
from basic_memory.models import Entity as EntityModel
from basic_memory.services.exceptions import FileOperationError
from basic_memory.schemas import Entity as EntitySchema
from basic_memory.services.exceptions import FileOperationError
class FileService:
"""
Service for handling file operations.
"""Service for handling file operations.
All paths are handled as Path objects internally. Strings are converted to
Path objects when passed in. Relative paths are assumed to be relative to
base_path.
Features:
- Consistent file writing with checksums
@@ -28,105 +34,66 @@ class FileService:
base_path: Path,
markdown_processor: MarkdownProcessor,
):
self.base_path = base_path
self.base_path = base_path.resolve() # Get absolute path
self.markdown_processor = markdown_processor
def get_entity_path(self, entity: EntityModel| EntitySchema) -> Path:
"""Generate absolute filesystem path for entity."""
return self.base_path / f"{entity.file_path}"
async def write_entity_file(
self,
entity: EntityModel,
content: Optional[str] = None,
expected_checksum: Optional[str] = None,
) -> Tuple[Path, str]:
"""Write entity to filesystem and return path and checksum.
Uses read->modify->write pattern:
1. Read existing file if it exists
2. Update with new content if provided
3. Write back atomically
def get_entity_path(self, entity: Union[EntityModel, EntitySchema]) -> Path:
"""Generate absolute filesystem path for entity.
Args:
entity: Entity model to write
content: Optional new content (preserves existing if None)
expected_checksum: Optional checksum to verify file hasn't changed
entity: Entity model or schema with file_path attribute
Returns:
Tuple of (file path, new checksum)
Raises:
FileOperationError: If write fails
Absolute Path to the entity file
"""
try:
path = self.get_entity_path(entity)
# Read current state if file exists
if path.exists():
# read the existing file
existing_markdown = await self.markdown_processor.read_file(path)
# if content is supplied use it or existing content
content=content or existing_markdown.content
# Create new file structure with provided content
markdown = entity_model_to_markdown(entity, content=content)
# Write back atomically
checksum = await self.markdown_processor.write_file(
path=path, markdown=markdown, expected_checksum=expected_checksum
)
return path, checksum
except Exception as e:
logger.exception(f"Failed to write entity file: {e}")
raise FileOperationError(f"Failed to write entity file: {e}")
return self.base_path / entity.file_path
async def read_entity_content(self, entity: EntityModel) -> str:
"""Get entity's content without frontmatter or structured sections (used to index for search)
"""Get entity's content without frontmatter or structured sections.
Used to index for search. Returns raw content without frontmatter,
observations, or relations.
Args:
entity: Entity to read content for
Returns:
Raw content without frontmatter, observations, or relations
Raises:
FileOperationError: If entity file doesn't exist
Raw content string without metadata sections
"""
logger.debug(f"Reading entity with permalink: {entity.permalink}")
try:
file_path = self.get_entity_path(entity)
markdown = await self.markdown_processor.read_file(file_path)
return markdown.content or ""
except Exception as e:
logger.error(f"Failed to read entity content: {e}")
raise FileOperationError(f"Failed to read entity content: {e}")
file_path = self.get_entity_path(entity)
markdown = await self.markdown_processor.read_file(file_path)
return markdown.content or ""
async def delete_entity_file(self, entity: EntityModel) -> None:
"""Delete entity file from filesystem."""
try:
path = self.get_entity_path(entity)
await self.delete_file(path)
except Exception as e:
logger.error(f"Failed to delete entity file: {e}")
raise FileOperationError(f"Failed to delete entity file: {e}")
async def exists(self, path: Path) -> bool:
"""
Check if file exists at the provided path. If path is relative, it is assumed to be relative to base_path.
"""Delete entity file from filesystem.
Args:
path: Path to check
entity: Entity model whose file should be deleted
Raises:
FileOperationError: If deletion fails
"""
path = self.get_entity_path(entity)
await self.delete_file(path)
async def exists(self, path: Union[Path, str]) -> bool:
"""Check if file exists at the provided path.
If path is relative, it is assumed to be relative to base_path.
Args:
path: Path to check (Path object or string)
Returns:
True if file exists, False otherwise
Raises:
FileOperationError: If check fails
"""
try:
path = Path(path)
if path.is_absolute():
return path.exists()
else:
@@ -135,12 +102,14 @@ class FileService:
logger.error(f"Failed to check file existence {path}: {e}")
raise FileOperationError(f"Failed to check file existence: {e}")
async def write_file(self, path: Path, content: str) -> str:
"""
Write content to file and return checksum.
async def write_file(self, path: Union[Path, str], content: str) -> str:
"""Write content to file and return checksum.
Handles both absolute and relative paths. Relative paths are resolved
against base_path.
Args:
path: Path where to write
path: Where to write (Path object or string)
content: Content to write
Returns:
@@ -149,30 +118,34 @@ class FileService:
Raises:
FileOperationError: If write fails
"""
path = path if path.is_absolute() else self.base_path / path
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
try:
# Ensure parent directory exists
await file_utils.ensure_directory(path.parent)
await file_utils.ensure_directory(full_path.parent)
# Write content atomically
await file_utils.write_file_atomic(path, content)
await file_utils.write_file_atomic(full_path, content)
# Compute and return checksum
checksum = await file_utils.compute_checksum(content)
logger.debug(f"wrote file: {path}, checksum: {checksum} content: \n{content}")
logger.debug(f"wrote file: {full_path}, checksum: {checksum}")
return checksum
except Exception as e:
logger.error(f"Failed to write file {path}: {e}")
logger.error(f"Failed to write file {full_path}: {e}")
raise FileOperationError(f"Failed to write file: {e}")
async def read_file(self, path: Path) -> Tuple[str, str]:
"""
Read file and compute checksum.
# TODO remove read_file
async def read_file(self, path: Union[Path, str]) -> Tuple[str, str]:
"""Read file and compute checksum.
Handles both absolute and relative paths. Relative paths are resolved
against base_path.
Args:
path: Path to read
path: Path to read (Path object or string)
Returns:
Tuple of (content, checksum)
@@ -180,33 +153,91 @@ class FileService:
Raises:
FileOperationError: If read fails
"""
path = path if path.is_absolute() else self.base_path / path
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
try:
content = path.read_text()
content = full_path.read_text()
checksum = await file_utils.compute_checksum(content)
logger.debug(f"read file: {path}, checksum: {checksum}")
logger.debug(f"read file: {full_path}, checksum: {checksum}")
return content, checksum
except Exception as e:
logger.error(f"Failed to read file {path}: {e}")
logger.error(f"Failed to read file {full_path}: {e}")
raise FileOperationError(f"Failed to read file: {e}")
async def delete_file(self, path: Path) -> None:
"""
Delete file if it exists.
async def delete_file(self, path: Union[Path, str]) -> None:
"""Delete file if it exists.
Handles both absolute and relative paths. Relative paths are resolved
against base_path.
Args:
path: Path to delete
Raises:
FileOperationError: If deletion fails
path: Path to delete (Path object or string)
"""
path = path if path.is_absolute() else self.base_path / path
try:
path.unlink(missing_ok=True)
except Exception as e:
logger.error(f"Failed to delete file {path}: {e}")
raise FileOperationError(f"Failed to delete file: {e}")
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
full_path.unlink(missing_ok=True)
def path(self, path_string: str, absolute: bool = False):
return Path( self.base_path / path_string ) if absolute else Path(path_string).relative_to(self.base_path)
async def update_frontmatter(self, path: Union[Path, str], updates: Dict[str, Any]) -> str:
"""
Update frontmatter fields in a file while preserving all content.
"""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
return await file_utils.update_frontmatter(full_path, updates)
async def compute_checksum(self, path: Union[str, Path]) -> str:
"""Compute checksum for a file."""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
try:
if self.is_markdown(path):
# read str
content = full_path.read_text()
else:
# read bytes
content = full_path.read_bytes()
return await file_utils.compute_checksum(content)
except Exception as e: # pragma: no cover
logger.error(f"Failed to compute checksum for {path}: {e}")
raise FileError(f"Failed to compute checksum for {path}: {e}")
def file_stats(self, path: Union[Path, str]) -> stat_result:
"""
Return file stats for a given path.
:param path:
:return:
"""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
# get file timestamps
return full_path.stat()
def content_type(self, path: Union[Path, str]) -> str:
"""
Return content_type for a given path.
:param path:
:return:
"""
path = Path(path)
full_path = path if path.is_absolute() else self.base_path / path
# get file timestamps
mime_type, _ = mimetypes.guess_type(full_path.name)
# .canvas files are json
if full_path.suffix == ".canvas":
mime_type = "application/json"
content_type = mime_type or "text/plain"
return content_type
def is_markdown(self, path: Union[Path, str]) -> bool:
"""
Return content_type for a given path.
:param path:
:return:
"""
return self.content_type(path) == "text/markdown"
+26 -22
View File
@@ -4,10 +4,11 @@ from typing import Optional, Tuple, List
from loguru import logger
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.services.search_service import SearchService
from basic_memory.models import Entity
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.services.search_service import SearchService
class LinkResolver:
@@ -15,9 +16,10 @@ class LinkResolver:
Uses a combination of exact matching and search-based resolution:
1. Try exact permalink match (fastest)
2. Try exact title match
3. Fall back to search for fuzzy matching
4. Generate new permalink if no match found
2. Try permalink pattern match (for wildcards)
3. Try exact title match
4. Fall back to search for fuzzy matching
5. Generate new permalink if no match found
"""
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
@@ -44,20 +46,22 @@ class LinkResolver:
logger.debug(f"Found title match: {entity.title}")
return entity
if use_search:
# 3. Fall back to search for fuzzy matching on title if specified
if use_search and "*" not in clean_text:
# 3. Fall back to search for fuzzy matching on title
results = await self.search_service.search(
query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
)
if results:
# Look for best match
best_match = self._select_best_match(clean_text, results)
logger.debug(f"Selected best match from {len(results)} results: {best_match.permalink}")
return await self.entity_repository.get_by_permalink(best_match.permalink)
# if we couldn't find anything then return None
logger.debug(
f"Selected best match from {len(results)} results: {best_match.permalink}"
)
if best_match.permalink:
return await self.entity_repository.get_by_permalink(best_match.permalink)
# if we couldn't find anything then return None
return None
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
@@ -85,7 +89,7 @@ class LinkResolver:
return text, alias
def _select_best_match(self, search_text: str, results: List[SearchResult]) -> Entity:
def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> SearchIndexRow:
"""Select best match from search results.
Uses multiple criteria:
@@ -93,9 +97,6 @@ class LinkResolver:
2. Word matches in path
3. Overall search score
"""
if not results:
raise ValueError("Cannot select from empty results")
# Get search terms for matching
terms = search_text.lower().split()
@@ -103,11 +104,14 @@ class LinkResolver:
scored_results = []
for result in results:
# Start with base score (lower is better)
score = result.score
score = result.score or 0
# Parse path components
path_parts = result.permalink.lower().split("/")
last_part = path_parts[-1] if path_parts else ""
if result.permalink:
# Parse path components
path_parts = result.permalink.lower().split("/")
last_part = path_parts[-1] if path_parts else ""
else:
last_part = "" # pragma: no cover
# Title word match boosts
term_matches = [term for term in terms if term in last_part]
+80 -27
View File
@@ -1,16 +1,17 @@
"""Service for search operations."""
from datetime import datetime
from typing import List, Optional, Set
from dateparser import parse
from fastapi import BackgroundTasks
from loguru import logger
from basic_memory.models import Entity
from basic_memory.repository import EntityRepository
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.services import FileService
from basic_memory.services.exceptions import FileOperationError
class SearchService:
@@ -51,9 +52,7 @@ class SearchService:
logger.info("Reindex complete")
async def search(
self, query: SearchQuery, context: Optional[List[str]] = None
) -> List[SearchResult]:
async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]:
"""Search across all indexed content.
Supports three modes:
@@ -67,7 +66,17 @@ class SearchService:
logger.debug(f"Searching with query: {query}")
# permalink search
after_date = (
(
query.after_date
if isinstance(query.after_date, datetime)
else parse(query.after_date)
)
if query.after_date
else None
)
# search
results = await self.repository.search(
search_text=query.text,
permalink=query.permalink,
@@ -75,12 +84,15 @@ class SearchService:
title=query.title,
types=query.types,
entity_types=query.entity_types,
after_date=query.after_date,
after_date=after_date,
limit=limit,
offset=offset,
)
return results
def _generate_variants(self, text: str) -> Set[str]:
@staticmethod
def _generate_variants(text: str) -> Set[str]:
"""Generate text variants for better fuzzy matching.
Creates variations of the text to improve match chances:
@@ -107,6 +119,46 @@ class SearchService:
self,
entity: Entity,
background_tasks: Optional[BackgroundTasks] = None,
) -> None:
if background_tasks:
background_tasks.add_task(self.index_entity_data, entity)
else:
await self.index_entity_data(entity)
async def index_entity_data(
self,
entity: Entity,
) -> None:
# delete all search index data associated with entity
await self.repository.delete_by_entity_id(entity_id=entity.id)
# reindex
await self.index_entity_markdown(
entity
) if entity.is_markdown else await self.index_entity_file(entity)
async def index_entity_file(
self,
entity: Entity,
) -> None:
# Index entity file with no content
await self.repository.index_item(
SearchIndexRow(
id=entity.id,
type=SearchItemType.ENTITY.value,
title=entity.title,
file_path=entity.file_path,
metadata={
"entity_type": entity.entity_type,
},
created_at=entity.created_at,
updated_at=entity.updated_at,
)
)
async def index_entity_markdown(
self,
entity: Entity,
) -> None:
"""Index an entity and all its observations and relations.
@@ -125,22 +177,15 @@ class SearchService:
Each type gets its own row in the search index with appropriate metadata.
"""
if background_tasks:
background_tasks.add_task(self.index_entity_data, entity)
else:
await self.index_entity_data(entity)
async def index_entity_data(
self,
entity: Entity,
) -> None:
"""Actually perform the indexing."""
assert entity.permalink is not None, (
"entity.permalink should not be None for markdown entities"
)
content_parts = []
title_variants = self._generate_variants(entity.title)
content_parts.extend(title_variants)
# TODO should we do something to content on indexing?
content = await self.file_service.read_entity_content(entity)
if content:
content_parts.append(content)
@@ -150,6 +195,9 @@ class SearchService:
entity_content = "\n".join(p for p in content_parts if p and p.strip())
assert entity.permalink is not None, (
"entity.permalink should not be None for markdown entities"
)
# Index entity
await self.repository.index_item(
SearchIndexRow(
@@ -159,11 +207,12 @@ class SearchService:
content=entity_content,
permalink=entity.permalink,
file_path=entity.file_path,
entity_id=entity.id,
metadata={
"entity_type": entity.entity_type,
},
created_at=entity.created_at.isoformat(),
updated_at=entity.updated_at.isoformat(),
created_at=entity.created_at,
updated_at=entity.updated_at,
)
)
@@ -183,8 +232,8 @@ class SearchService:
metadata={
"tags": obs.tags,
},
created_at=entity.created_at.isoformat(),
updated_at=entity.updated_at.isoformat(),
created_at=entity.created_at,
updated_at=entity.updated_at,
)
)
@@ -201,18 +250,22 @@ class SearchService:
SearchIndexRow(
id=rel.id,
title=relation_title,
content=rel.context or "",
permalink=rel.permalink,
file_path=entity.file_path,
type=SearchItemType.RELATION.value,
entity_id=entity.id,
from_id=rel.from_id,
to_id=rel.to_id,
relation_type=rel.relation_type,
created_at=entity.created_at.isoformat(),
updated_at=entity.updated_at.isoformat(),
created_at=entity.created_at,
updated_at=entity.updated_at,
)
)
async def delete_by_permalink(self, path_id: str):
async def delete_by_permalink(self, permalink: str):
"""Delete an item from the search index."""
await self.repository.delete_by_permalink(path_id)
await self.repository.delete_by_permalink(permalink)
async def delete_by_entity_id(self, entity_id: int):
"""Delete an item from the search index."""
await self.repository.delete_by_entity_id(entity_id)
+3 -24
View File
@@ -1,36 +1,15 @@
"""Base service class."""
from datetime import datetime
from typing import TypeVar, Generic, List, Sequence
from typing import TypeVar, Generic
from basic_memory.models import Base
from basic_memory.repository.repository import Repository
T = TypeVar("T", bound=Base)
R = TypeVar("R", bound=Repository)
class BaseService(Generic[T]):
"""Base service that takes a repository."""
def __init__(self, repository: R):
def __init__(self, repository):
"""Initialize service with repository."""
self.repository = repository
async def add(self, model: T) -> T:
"""Add model to repository."""
return await self.repository.add(model)
async def add_all(self, models: List[T]) -> Sequence[T]:
"""Add a List of models to repository."""
return await self.repository.add_all(models)
async def get_modified_since(self, since: datetime) -> Sequence[T]:
"""Get all items modified since the given timestamp.
Args:
since: Datetime to search from
Returns:
Sequence of items modified since the timestamp
"""
return await self.repository.find_modified_since(since)

Some files were not shown because too many files have changed in this diff Show More