Compare commits

..

429 Commits

Author SHA1 Message Date
semantic-release 29e202d7d2 chore(release): 0.0.0 [skip ci] 2025-02-04 00:20:34 +00:00
Paul Hernandez 94c65d3a62 Merge pull request #1 from basicmachines-co/pre-release
Pre release
2025-02-03 18:18:36 -06:00
phernandez 923b3d98ca get ready for initial release 2025-02-03 18:05:36 -06:00
phernandez aa094bfb34 add live console status to sync watch 2025-02-02 21:53:39 -06:00
phernandez 72210429c4 add console output for watch 2025-02-02 21:02:08 -06:00
phernandez a88fc99663 add file watcher to sync command 2025-02-02 20:07:04 -06:00
phernandez b7b69ecd84 revert changes 2025-02-02 19:22:33 -06:00
phernandez 4afa368a6b finish watch_service.py 2025-02-02 19:08:31 -06:00
phernandez 0af60e7fdc watch service 2025-02-02 16:35:46 -06:00
phernandez 3199ab0217 simplify MemoryUrl to be validated string 2025-02-01 13:42:48 -06:00
phernandez 572187a59a add verbose=True to write_note 2025-01-31 20:41:31 -06:00
phernandez a1108680c0 update notes tool docs 2025-01-31 19:34:53 -06:00
phernandez eafe563cfa add title/folder to EntitySchema 2025-01-31 19:29:16 -06:00
phernandez f0fd1ee942 fix fuzzy search on note create 2025-01-31 16:38:06 -06:00
phernandez dec1cc0827 Remove ai_edit tool import and registration 2025-01-31 14:27:28 -06:00
phernandez d2536f7345 remove dup test code 2025-01-31 14:24:13 -06:00
phernandez 9983271e94 Refactor created/modified handling in markdown entities.
Replace frontmatter properties with top-level attributes for `created` and `modified` to simplify handling and ensure consistent attribute usage. Added a utility function for schema-to-markdown conversion and updated related tests and code to reflect these changes.
2025-01-31 13:59:06 -06:00
phernandez 3e064cbbed add repository update via model 2025-01-30 20:22:22 -06:00
phernandez ab6916609a cleanup entity from markdown 2025-01-30 17:24:22 -05:00
phernandez f9d201960d small refactor to file_service update 2025-01-30 16:55:56 -05:00
phernandez 0a80734564 remove EntitySyncService 2025-01-30 16:41:19 -05:00
phernandez 835061a938 refactor entity_service to use file/markdown 2025-01-30 16:11:30 -05:00
phernandez c2aa947be9 remove observation, relation services 2025-01-27 17:01:34 -06:00
phernandez 8ec7dd6c48 add semantic parsing of content to entity update flow 2025-01-26 22:51:52 -06:00
phernandez deb52a71d9 remove observations from entity create 2025-01-26 22:30:09 -06:00
phernandez e2e979e705 create observations/relations form entity content on create 2025-01-26 21:59:44 -06:00
phernandez 9589a81b51 delete relations/observations from entity content 2025-01-26 15:54:16 -06:00
phernandez 9202b396a1 write semantic content if not present in content 2025-01-26 12:32:20 -06:00
phernandez d31f481405 remove EntityContent from markdown 2025-01-26 09:24:52 -06:00
phernandez 18bd850019 fix updates and save content. remove entity.summary 2025-01-26 00:00:37 -06:00
phernandez 9019ffedb7 add markdown_processor 2025-01-24 17:43:38 -06:00
phernandez 67aa8b78b5 file service content wip 2025-01-23 18:45:57 -06:00
phernandez e39132bc1b fix recent_activity tool type arg 2025-01-22 23:12:59 -06:00
phernandez 9f35f8b8d2 fix unresolved links on sync 2025-01-22 22:37:34 -06:00
phernandez 23922f2915 set permalink in frontmatter, re-index file_path on move during sync 2025-01-22 17:59:46 -06:00
phernandez bd1d91081e use dates from frontmatter on sync 2025-01-22 17:03:21 -06:00
phernandez 220914879b delete relations/observations from search_index on delete entity during sync 2025-01-22 15:30:57 -06:00
phernandez e420d88ed1 add catch all exception handler to fastapi 2025-01-22 14:32:41 -06:00
phernandez 526baead18 add delete_note tool 2025-01-21 20:08:52 -06:00
phernandez 18d4584652 make write_note overwrite existing notes 2025-01-21 19:27:43 -06:00
phernandez ac8db0e7c5 remove project logic from MemoryUrl 2025-01-21 16:24:15 -06:00
phernandez 5ba6d0d1f5 add fuzzy matching to notes 2025-01-20 20:26:16 -06:00
phernandez 224b11332c add notes tools 2025-01-20 19:05:16 -06:00
phernandez 02e6c763cf improve TimeFrame and recent_activity types 2025-01-20 16:17:44 -06:00
phernandez 14f520c272 remove activity router, replace with memory/recent 2025-01-20 15:35:08 -06:00
phernandez f61c5970df add resource api route 2025-01-19 20:07:20 -06:00
phernandez 0b7b77f9e8 fix tests for memory 2025-01-19 16:47:42 -06:00
phernandez 97bcf83976 improve build_context results 2025-01-19 16:18:30 -06:00
phernandez 2cea9bbf98 improve build_context results 2025-01-19 16:09:43 -06:00
phernandez 324025f87d dry up search indexing 2025-01-19 14:44:44 -06:00
phernandez cdf38f7991 build context cte search 2025-01-19 13:57:19 -06:00
phernandez eaa2051a04 debug cte query 2025-01-19 12:51:11 -06:00
phernandez 4b88e37c82 use union all in build_context 2025-01-19 10:27:05 -06:00
phernandez bc6632732e speed up context query 2025-01-19 10:06:14 -06:00
phernandez 7c52a0c872 add max_results to build_context query 2025-01-19 01:03:10 -06:00
phernandez 32606d771d fix tests for context and permalinks 2025-01-18 23:54:28 -06:00
phernandez 9af2f481b2 improve tool imports 2025-01-18 21:21:47 -06:00
phernandez db9da9051a add ai_edit tool 2025-01-18 21:17:34 -06:00
phernandez d65fca0a3c fixes for sync 2025-01-18 20:04:45 -06:00
phernandez 7322bb5350 refactor tools, remove BasicMemoryServer, update mcp sdk 2025-01-18 18:48:22 -06:00
phernandez ae9926f54d add discussion tools 2025-01-18 10:23:01 -06:00
phernandez 0df784c1c9 comment out debug logging 2025-01-17 21:47:31 -06:00
phernandez bbc1bf0d62 finish build_context. remove /related route 2025-01-17 16:36:42 -06:00
phernandez 433e8affa2 finish build_context 2025-01-17 16:35:23 -06:00
phernandez e2a4fa63e5 fix context_service tests 2025-01-16 22:38:19 -06:00
phernandez 3a8a900032 search refactor 2025-01-16 18:36:47 -06:00
phernandez fe252a8578 context builder mostly working 2025-01-16 14:27:22 -06:00
phernandez e8abf42050 add memory router 2025-01-15 18:31:33 -06:00
phernandez 040e22ed56 finish context_service 2025-01-15 18:11:11 -06:00
phernandez a593f50785 simplify search logic 2025-01-15 17:44:38 -06:00
phernandez b529e18584 simplify memory_url 2025-01-15 17:28:22 -06:00
phernandez 344fc70f3a refactor pattern 2025-01-15 17:25:02 -06:00
phernandez cdd6ec7fa8 fix pattern matching tests 2025-01-15 17:09:45 -06:00
phernandez a509ef1e2a add pattern match to search index, index permalink 2025-01-15 16:02:53 -06:00
phernandez 6ffbf6595a context builder wip 2025-01-15 15:26:33 -06:00
phernandez 4b8fb88a6a remove debug logging 2025-01-15 13:42:24 -06:00
phernandez 62725fa2c3 fix tests 2025-01-15 13:40:15 -06:00
phernandez 65cc945b28 fix tests 2025-01-15 12:58:38 -06:00
phernandez 1090bbf582 context builder cte wip 2025-01-14 23:09:37 -06:00
phernandez f8c8034dcc context builder wip 2025-01-14 22:37:55 -06:00
phernandez 3eb7e47abb MemoryUrl schema implementation 2025-01-14 20:09:32 -06:00
phernandez c4a8b85b03 refactor relation indexing 2025-01-14 19:28:05 -06:00
phernandez 73e8c5bee7 add relations and observations to search index 2025-01-14 19:06:00 -06:00
phernandez 9dff24746d index relations and observations 2025-01-14 18:14:24 -06:00
phernandez 2aa965b611 add link-resolution 2025-01-14 00:07:57 -06:00
phernandez dd7a9da3e4 detect file moves in sync 2025-01-13 23:01:39 -06:00
phernandez ef13b3b30a refactor sync to use link_resolver 2025-01-13 22:31:32 -06:00
phernandez aa8c0a7a36 rework entity_sync 2025-01-13 20:15:06 -06:00
phernandez f464c894f1 add link parsing to entity_parser.py 2025-01-13 19:45:48 -06:00
phernandez 85efcf8495 skip tests for unimplemented search features 2025-01-13 13:20:18 -06:00
phernandez 4b1918ff05 fix search tests 2025-01-12 22:48:04 -06:00
phernandez e9e4975be2 fix search tests 2025-01-12 22:34:56 -06:00
phernandez e49f3ab3f4 fix search tests wip 2025-01-12 22:29:51 -06:00
phernandez 44221e3f78 search testing wip 2025-01-12 21:27:18 -06:00
phernandez abe80f74b2 revert search service changes 2025-01-12 19:56:35 -06:00
phernandez 94ffd25b66 search fuzzy matching 2025-01-12 18:47:35 -06:00
phernandez e10804b344 fuzzy searching tests 2025-01-12 18:30:28 -06:00
phernandez bdedf53566 add link_resolver.py 2025-01-12 17:44:26 -06:00
phernandez c5d21276de change entity.path_id to entity.permalink 2025-01-12 16:47:21 -06:00
phernandez 1f374e0133 fix get_entity with content if there is no frontmatter 2025-01-12 16:35:09 -06:00
phernandez 90a301d4ce rename entity.name to entity.title 2025-01-12 13:48:16 -06:00
phernandez 761fb708c6 rename entity.name to entity.title 2025-01-12 12:50:03 -06:00
phernandez d19d3b7583 error on path not found in sync 2025-01-11 22:20:30 -06:00
phernandez ba733196f1 remove knowledge_dir 2025-01-11 13:04:13 -06:00
phernandez 542d4196e3 fix sync 2025-01-11 01:16:00 -06:00
phernandez ea74d84c9b fix tests 2025-01-11 01:01:13 -06:00
phernandez c2ea73342f refactor sync 2025-01-11 00:49:11 -06:00
phernandez 48aa3faa02 fix tests 2025-01-11 00:43:48 -06:00
phernandez eb2b0a46fc fix tests 2025-01-11 00:40:46 -06:00
phernandez 8284bee0f7 fixing tests 2025-01-10 22:25:24 -06:00
phernandez 79380e33ec fix services 2025-01-10 20:22:05 -06:00
phernandez 621154eaec add tags to observation 2025-01-09 23:36:31 -06:00
phernandez 9d2f0c969e fix parser tests 2025-01-09 23:12:29 -06:00
phernandez a0b7e70951 enity parsing tests 2025-01-09 20:49:49 -06:00
phernandez 3982bd5221 add entity_parser.py 2025-01-09 17:45:08 -06:00
phernandez 81c1ceecbe fix file_scanner tests 2025-01-09 15:22:41 -06:00
phernandez b68e7461ef get entity content via tool 2025-01-09 14:53:02 -06:00
phernandez 4cb654a4fb fix sync 2025-01-08 17:40:03 -06:00
phernandez d0e0fa1e1d fix tests 2025-01-07 22:12:15 -06:00
phernandez 286addb221 modify knowledge writer 2025-01-07 21:45:18 -06:00
phernandez 1c6836d83f fix tests 2025-01-07 20:29:59 -06:00
phernandez 6686c331a5 refactor entity.entity_type to be freeform, add summary and content_type 2025-01-07 19:00:08 -06:00
phernandez 5229641c61 re-add get_entity_by_type to discovery_router.py 2025-01-07 18:27:39 -06:00
phernandez c261b821f9 fix all tests 2025-01-07 18:19:41 -06:00
phernandez 545c1a77f3 remove document code 2025-01-07 00:20:11 -06:00
phernandez 5c9665069c fix knowledge tests 2025-01-06 23:51:55 -06:00
phernandez 48811b0a68 knowledge router testing wip 2025-01-06 23:02:03 -06:00
phernandez 69de22cb6b tests for knowledge update 2025-01-06 22:25:10 -06:00
phernandez 9c125aa5db update_entity 2025-01-06 22:16:01 -06:00
phernandez c320f64b53 get tests working 2025-01-06 21:08:19 -06:00
phernandez 55c40bd6d3 knowledge_service tests passing 2025-01-06 20:07:12 -06:00
phernandez ea359d2484 update entity/repostitory/service 2025-01-06 18:57:04 -06:00
phernandez fb330fe745 add search tool info 2025-01-05 18:22:16 -06:00
phernandez fa7e986d3b fix tests, add SearchResult response type for search 2025-01-04 22:22:38 -06:00
phernandez e35dbab156 add search to sync flow 2025-01-04 21:19:42 -06:00
phernandez dbe9cea261 add search indexing to api routes 2025-01-04 20:54:52 -06:00
phernandez 5488ff49d1 add search_service.reindex_all() and api route 2025-01-04 20:14:36 -06:00
phernandez 6b94ec57a4 add index_document to search_service.py 2025-01-04 19:53:50 -06:00
phernandez 0b918ee9e8 move sync dir up to basic_memory.sync 2025-01-04 19:20:54 -06:00
phernandez 5e3c62886e add search routes 2025-01-04 17:16:47 -06:00
phernandez 905ff78aea search service implementation 2025-01-04 16:37:02 -06:00
phernandez 35c21c1891 re-add sync knowledge verification step 2025-01-04 13:24:29 -06:00
phernandez 3d575cc464 update create_document usage info for tools 2025-01-04 11:24:01 -06:00
phernandez b5bdae7101 add summary to sync 2025-01-04 01:13:40 -06:00
phernandez ef43658db1 add info to sync 2025-01-04 01:11:20 -06:00
phernandez 59756d27ee fix deps for sync command 2025-01-04 00:42:33 -06:00
phernandez c59bdbf90e rename incoming/outgoing relations on Entity model 2025-01-04 00:23:02 -06:00
phernandez 7328e925a0 fix test 2025-01-04 00:14:32 -06:00
phernandez eda2a30099 fix yml parsing/writing in document service 2025-01-03 23:59:09 -06:00
phernandez f4c5327889 fix enity file_path in db, writeing reations only to entity source file 2025-01-03 22:26:30 -06:00
phernandez aacbfe0c0a fix knowledge sync file_path in db 2025-01-03 18:30:09 -06:00
phernandez 4feb8aa4e0 sync wip 2025-01-03 18:20:33 -06:00
phernandez ecbe612fdc implement sync cli 2025-01-03 16:41:12 -06:00
phernandez de1cbf29c3 fix imports for service.sync 2025-01-03 16:08:07 -06:00
phernandez 3c20e59aaf sync service tests 2025-01-03 15:34:28 -06:00
phernandez 09e98e794b knowledge sync tests wip 2025-01-03 15:17:52 -06:00
phernandez 8ce25b10ba fix duplicate relations in knowledge sync 2025-01-03 14:22:49 -06:00
phernandez 614a972b0d tests for sync knowledge 2025-01-03 13:34:33 -06:00
phernandez a31a4a79fd add tests for sync documents 2025-01-03 12:30:20 -06:00
phernandez 31cc5022d2 add tests for document sync 2025-01-03 12:18:41 -06:00
phernandez ff0b619d1f break up sync tests between documents/knowledge 2025-01-03 12:08:41 -06:00
phernandez 102e830383 remove used DbProtocol class 2025-01-03 11:37:33 -06:00
phernandez 3202a8cfc1 fix sync tests 2025-01-03 11:32:18 -06:00
phernandez 82603be8fa file-sync v1 2025-01-02 21:42:11 -06:00
phernandez 7d8055685a fix sync service tests 2025-01-02 20:26:33 -06:00
phernandez 4d3f2dc03d entity_sync_service 2025-01-02 18:21:37 -06:00
phernandez a9a3d90558 sync service wip 2025-01-02 16:14:03 -06:00
phernandez 3baaa323c7 refactor file scanner and tests 2025-01-02 15:32:50 -06:00
phernandez 9c7ccb7117 fix status cli command 2025-01-02 15:08:10 -06:00
phernandez 8e32a79842 update tool docs for aI 2025-01-02 14:12:34 -06:00
phernandez e43d9f70aa Enhance tool schemas with AI-focused documentation
- Add clear tool categories for better organization
- Expand tool descriptions to highlight AI collaboration features
- Add detailed docstrings for all function parameters
- Enhance examples with more context and use cases
- Focus on knowledge navigation and context building capabilities
2025-01-02 13:46:08 -06:00
phernandez 4d3f17cc43 fix open_nodes test 2025-01-02 13:41:59 -06:00
phernandez 860b1415ef fix get_schema 2025-01-02 13:29:04 -06:00
phernandez c20bd32fad update tool schema info 2024-12-31 09:40:23 -06:00
phernandez a1481240dc use output_model instead of output_schema 2024-12-31 09:19:46 -06:00
phernandez fae82c4cc7 update tool schema docs 2024-12-31 00:19:33 -06:00
phernandez c4aa545da3 add help tool docs 2024-12-30 23:54:12 -06:00
phernandez 705ca3e4c8 fix tests 2024-12-30 23:42:57 -06:00
phernandez 31e81c9016 fix circular imports 2024-12-30 23:05:41 -06:00
phernandez e50664c0b1 test schema tools wip 2024-12-30 22:52:57 -06:00
phernandez 856f6a9d5b Merge branch 'main' into mcp-tools-usage 2024-12-30 21:06:49 -06:00
phernandez 289fcf7a93 fix tests 2024-12-30 21:01:36 -06:00
phernandez d7ff262bea use enum in activitytype 2024-12-30 20:39:45 -06:00
phernandez 1507875785 add get_recent_activity endpoint 2024-12-30 20:32:55 -06:00
phernandez 7a06b87bd1 activity service recent activity 2024-12-30 19:33:42 -06:00
phernandez 12a21d81b6 add select relations to list_by_type tool 2024-12-30 15:05:35 -06:00
phernandez 8dd2de3e9a add list_by_type tool 2024-12-30 14:44:23 -06:00
phernandez 566a796d68 fix sqlalchemy dep loading in deps.py 2024-12-30 14:08:21 -06:00
phernandez a4283d893f fix get_observation_categories tool tests 2024-12-30 13:34:04 -06:00
phernandez 2bc35f34e3 add get_observation_categories 2024-12-30 13:26:44 -06:00
phernandez 1dfcc0bc4b add get_entity_type to discovery tools 2024-12-30 13:12:47 -06:00
phernandez bcc5f8a165 add get_entity_types endpoint 2024-12-30 13:06:41 -06:00
phernandez b818d7778b get_schema tool wip 2024-12-30 12:41:45 -06:00
phernandez 65dac90128 get tests passing, skip knowledge sync 2024-12-29 10:19:05 -06:00
phernandez cc5d0d103a finish implementing tests for tools 2024-12-28 14:59:20 -06:00
phernandez 0d86bfeb7a sync wip 2024-12-28 14:31:28 -06:00
phernandez aa61ed0d96 fix imports for sync 2024-12-27 20:06:17 -06:00
phernandez c4d6cabf53 add type to entity_service.entity_model 2024-12-27 19:58:30 -06:00
phernandez 99e7673fc8 add knowledge_sync_service tests 2024-12-27 13:19:15 -06:00
phernandez e45e36bb96 fix package order for sync 2024-12-27 12:55:56 -06:00
phernandez de21051ede add services.sync package 2024-12-27 12:53:42 -06:00
phernandez d789cb0345 add services.sync package 2024-12-27 12:52:42 -06:00
phernandez 644328f623 refactor knowledge_service 2024-12-27 12:24:53 -06:00
phernandez 947b5e4b00 refactor knowledge_service files 2024-12-27 11:56:51 -06:00
phernandez a5a45c0370 add edge cases to document_sync_service 2024-12-27 11:50:55 -06:00
phernandez d52713f1bd add document_sync_service 2024-12-27 11:46:53 -06:00
phernandez a248feb244 add tests for status command 2024-12-27 10:25:39 -06:00
phernandez 49394aa380 file_sync_service tests 2024-12-27 10:22:26 -06:00
phernandez fb062bf6f8 status command 2024-12-27 10:16:53 -06:00
phernandez 439e4dc11a fix all tests to use path_id, file_path 2024-12-26 22:17:47 -06:00
phernandez b8493b5017 fix entity_service tests 2024-12-26 21:36:21 -06:00
phernandez cc0116466a fix entity_repository 2024-12-26 21:24:32 -06:00
phernandez 2693a95e33 fix document_repository tests 2024-12-26 21:17:23 -06:00
phernandez b40a047d95 add file_path to entity/document models 2024-12-26 21:06:31 -06:00
phernandez 7c7c127b9c add file_path to models 2024-12-26 20:38:02 -06:00
phernandez 26231d0514 redo file sync 2024-12-26 20:12:24 -06:00
phernandez 0df4b33512 file_sync_service verbose mode 2024-12-26 19:21:20 -06:00
phernandez a527374db3 file_sync_service 2024-12-26 18:35:04 -06:00
phernandez 2d0bbf5b6a add index to entity checksum 2024-12-26 18:10:11 -06:00
phernandez efd0dc9b69 refactor write_document to make checksum not null 2024-12-26 17:44:38 -06:00
phernandez f90f17d7c4 file_sync_service WIP 2024-12-26 17:27:48 -06:00
phernandez 9d833d7394 basic-memory status command WIP 2024-12-26 13:32:47 -06:00
phernandez 260282a42b refactor basic-memory command structure 2024-12-26 11:56:25 -06:00
phernandez fba42ce2f8 refactor .basic-memory project file structure 2024-12-26 11:46:40 -06:00
phernandez ce909e0f23 observation category and tool docs 2024-12-25 17:02:14 -06:00
phernandez 141bcd85b8 delete old mcp tests, add docs to tools 2024-12-25 14:28:35 -06:00
phernandez bb47fbd48b fix all tests for observation category 2024-12-25 14:16:46 -06:00
phernandez fd723164ca add validation tests for observation schema 2024-12-25 12:33:59 -06:00
phernandez f213ad5efa add category to observation schema 2024-12-25 12:03:33 -06:00
phernandez eb6902af39 rename router classes 2024-12-25 10:25:48 -06:00
phernandez 353342a5e2 fix all endpoints for tools 2024-12-25 10:18:12 -06:00
phernandez 59fa354c77 use FilePath for documents 2024-12-25 09:15:14 -06:00
phernandez d430a5bd94 fastmcp tools 2024-12-24 23:32:13 -06:00
phernandez f044de9e1d use path_id instead of entity_id everywhere 2024-12-24 22:25:06 -06:00
phernandez 04a7a3585b server wtf 2024-12-24 21:33:39 -06:00
phernandez 061e01893a more validation fixes 2024-12-24 21:03:00 -06:00
phernandez 90ca41fead fix tests 2024-12-24 20:29:21 -06:00
phernandez d713097333 fix entity_repository tests 2024-12-24 18:29:51 -06:00
phernandez 0bbcf636f6 change /knowledge endpoints to use ids 2024-12-24 18:15:59 -06:00
phernandez bde381c9e2 use path based ids for documents api 2024-12-24 09:58:45 -06:00
phernandez 37c06d5d3c comment out mcp tests 2024-12-23 21:48:05 -06:00
phernandez 1c56b23f75 /documents and /knowledge working (again) 2024-12-23 20:48:22 -06:00
phernandez 3b8038665c fix tests for mcp documents tool calls 2024-12-23 17:44:35 -06:00
phernandez 0a177f17c8 add /documents endpoints to mcp server 2024-12-23 17:26:18 -06:00
phernandez 733dce726a fix tests 2024-12-23 15:39:17 -06:00
phernandez cbbfa6e637 refactor knowledge_service to return Entity objects on updates 2024-12-23 10:11:56 -06:00
phernandez a5bb6f8b0c refactor knowledge_service.create_relations 2024-12-23 10:01:04 -06:00
phernandez 76e3466bcf remove unused response schemas 2024-12-23 09:53:33 -06:00
phernandez 0b5660d5fb Return entities when modifiying observations/relations in /knowledge routes 2024-12-23 09:53:12 -06:00
phernandez 84ddb7f637 Use KnowledgeService in /knowledge routes 2024-12-23 09:37:52 -06:00
phernandez 8059fab837 fix base_project_path when writing files 2024-12-23 09:19:02 -06:00
phernandez 8ce3a9d78d knowledge service tests 2024-12-22 23:44:55 -06:00
phernandez c02d734014 fix tests 2024-12-22 22:12:45 -06:00
phernandez bd3d7a4ef9 Knowledge service test fixtures 2024-12-22 22:11:13 -06:00
phernandez 71704e6e64 flesh out knowledge_service 2024-12-22 22:00:54 -06:00
phernandez 55f7997c23 add knowledge_writer 2024-12-22 21:42:45 -06:00
phernandez 5ed57cb360 add knowledge_service, file_service 2024-12-22 21:20:50 -06:00
phernandez 19ef6933df rename EntityParser to KnowledgeParser 2024-12-22 19:57:37 -06:00
phernandez 6f155c62f8 fix entity markdown parsing 2024-12-22 19:54:29 -06:00
phernandez e97f8ecdcc fix markdown parsing 2024-12-22 19:50:26 -06:00
phernandez 65627085e5 markdown parsing and parsing 2024-12-22 18:34:34 -06:00
phernandez a9c892f03b entity markdown parsing still 2024-12-22 18:27:51 -06:00
phernandez cb2dd9282a entity markdown parsing 2024-12-22 18:16:39 -06:00
phernandez 1781057fa2 fix EntityParser 2024-12-22 17:22:59 -06:00
phernandez e9c3de8497 fix relation tests 2024-12-22 17:17:01 -06:00
phernandez dcaf920f42 fix observation tests 2024-12-22 17:14:56 -06:00
phernandez 1d98df895b fixing markdown parsing tests 2024-12-22 17:06:06 -06:00
phernandez 5a78464a45 fix imports for markdown schemas 2024-12-22 16:32:15 -06:00
phernandez 3d02827d95 consolidate mardown schemas 2024-12-22 16:27:20 -06:00
phernandez 017e155a54 fixing base_parser tests 2024-12-22 16:19:18 -06:00
phernandez 72d0415c10 Mardown parsing WIP 2024-12-22 15:48:13 -06:00
phernandez fcdf31eb51 fixing parser tests 2024-12-22 14:47:38 -06:00
phernandez fb4f0ba30d breaking up parsing logic for reuse 2024-12-22 14:40:12 -06:00
phernandez f62f5677b0 add file_utils.py 2024-12-22 14:07:45 -06:00
phernandez ad2249bb28 get tests working again for entity.id int 2024-12-22 13:23:08 -06:00
phernandez c429eb909e fix knowledge router tests after entity int id 2024-12-22 13:03:06 -06:00
phernandez 73c0a4078e more test fixing for entity int id 2024-12-22 12:52:01 -06:00
phernandez c7e71bf06d fix observation service tests, remove EntityId from schemas.py 2024-12-22 12:32:59 -06:00
phernandez 955fe899ab fix repository tests 2024-12-22 12:17:59 -06:00
phernandez c2d16acb59 fix entity service tests 2024-12-22 12:00:02 -06:00
phernandez ecb46ddceb entity repository tests 2024-12-22 11:10:35 -06:00
phernandez 239753cf77 refactor entity/repo 2024-12-22 10:34:57 -06:00
phernandez 8a0b08253c refactor entity model 2024-12-22 10:13:21 -06:00
phernandez 054cea976c remove unused fileio code 2024-12-22 09:22:02 -06:00
phernandez c97b678dc6 remove get by path for doc service 2024-12-22 09:15:46 -06:00
phernandez 477737b59e fix document service tests to get by id 2024-12-22 00:02:11 -06:00
phernandez fcad7b074d fix delete to use get by id 2024-12-21 23:52:08 -06:00
phernandez 3541c2873d change document api to uset get by id 2024-12-21 23:46:05 -06:00
phernandez 0f88c6bc6b remove permission test 2024-12-21 23:05:53 -06:00
phernandez f4bc0cabd9 fix document api 2024-12-21 22:53:55 -06:00
phernandez 1db97b59a4 fix document router tests 2024-12-21 22:12:33 -06:00
phernandez da54b495d9 document tests 2024-12-21 21:51:47 -06:00
phernandez ed3d03cdb8 refactor response schemas 2024-12-21 20:52:37 -06:00
phernandez 59199b1e01 fix document api test wiring 2024-12-21 20:18:47 -06:00
phernandez 76322b79c0 refactor document pydantic schemas a bit 2024-12-21 20:01:09 -06:00
phernandez ddf2d2fd0e add metadata and errors to /documents api 2024-12-21 19:56:54 -06:00
phernandez c170aed855 test delete document row on failure to write file 2024-12-21 19:39:15 -06:00
phernandez 3a1a309448 document service gets id before write 2024-12-21 19:35:56 -06:00
phernandez 0e654c750e add documents api 2024-12-21 19:06:16 -06:00
phernandez 27242d1a52 Merge branch 'document-store' into document-entity-write 2024-12-21 18:50:06 -06:00
phernandez 0faf70d2e9 remove unused files 2024-12-21 18:04:36 -06:00
phernandez 1ba233fb46 100% markdown test coverage 2024-12-21 17:55:37 -06:00
phernandez df1efb4550 fix observation context edge cases 2024-12-21 17:34:49 -06:00
phernandez 99be6d36e6 observation edge cases 2024-12-21 17:24:47 -06:00
phernandez 94e55096bb fix markdown tests 2024-12-21 17:11:37 -06:00
phernandez 125339c3dc markdown tests 2024-12-21 17:10:00 -06:00
phernandez 4d331a4ac9 fix tests for markdown 2024-12-21 16:49:30 -06:00
phernandez 7dfea0c704 split up entity parsing 2024-12-21 16:36:14 -06:00
phernandez 4f5a1c24e2 more markdown split 2024-12-21 16:30:37 -06:00
phernandez 2943b276bd split up parsing 2024-12-21 16:26:55 -06:00
phernandez abd71cce46 split up parsing logic 2024-12-21 16:10:47 -06:00
phernandez 8b26162a23 refactor markdown parsing models 2024-12-21 15:54:43 -06:00
phernandez 252da2af94 fix some parser.py tests 2024-12-21 15:40:25 -06:00
phernandez 0c8f729769 split up markdown logic 2024-12-21 15:15:17 -06:00
phernandez c9fec7abc7 markdown parsing 2024-12-21 14:59:30 -06:00
phernandez f465e66a8c markdown processing for entity 2024-12-21 10:18:14 -06:00
phernandez 5f55b02f2d add markdown deps 2024-12-21 08:57:54 -06:00
phernandez 3168024e48 fix FK from entity to doc 2024-12-20 20:19:15 -06:00
phernandez bb447de3a5 fix bug in file_sync_service 2024-12-20 18:24:42 -06:00
phernandez de4f28963b commit tests for file_sync_service 2024-12-20 18:01:55 -06:00
phernandez 5e001bbe4c file_sync_service 2024-12-19 22:30:37 -06:00
phernandez a4c1989c3b file_sync_service 2024-12-19 22:20:38 -06:00
phernandez 25cd08f218 further test document service 2024-12-19 21:27:27 -06:00
phernandez 696bd8476d document service tests 2024-12-19 20:53:58 -06:00
phernandez f87676e912 add document model and repo 2024-12-19 20:17:57 -06:00
phernandez e245ed3708 update main with changes 2024-12-19 19:10:29 -06:00
phernandez 3e43abf8f1 refactor mcp server and add docs to schemas 2024-12-17 07:52:29 -06:00
phernandez f61b02a4db fix schemas.py 2024-12-16 21:20:17 -06:00
phernandez 6b684dcf9c sanitize inputs, fix tests 2024-12-16 20:43:01 -06:00
phernandez 86329e0105 sanitize inputs for id/name 2024-12-16 19:35:18 -06:00
phernandez 176ebb3d55 add FKs to db connections 2024-12-16 16:01:08 -06:00
phernandez 1402bc2fbe remove uneeded mcp test 2024-12-16 12:46:14 -06:00
phernandez 926e753745 fix deletes to use post 2024-12-16 12:39:52 -06:00
phernandez c754b3b035 fixes 2024-12-15 21:28:12 -06:00
phernandez 6f7295c6d9 refactor session_maker lifecycle 2024-12-15 20:09:54 -06:00
phernandez ae5d7d52c5 fix tests 2024-12-15 16:17:25 -06:00
phernandez cc6d9d9fc3 delete tests for knowledge api 2024-12-15 16:01:15 -06:00
phernandez 1d4b289fc4 fix imports in test_memory_service.py 2024-12-15 13:58:30 -06:00
phernandez a838c0308c test memory_service.delete operations 2024-12-15 13:49:40 -06:00
phernandez fa4e55f75f deletes for repositories 2024-12-15 11:31:51 -06:00
phernandez b1ec3bca26 delete observation repository 2024-12-15 10:50:25 -06:00
phernandez b03e7229a9 adding final changes 2024-12-15 00:58:43 -06:00
phernandez bc98a2ef13 add some logging 2024-12-14 21:54:15 -06:00
phernandez 052ee403b3 refactor mcp server to use rest client 2024-12-14 21:24:32 -06:00
phernandez f698e8b102 refactor schemas 2024-12-14 20:26:31 -06:00
phernandez 61c07fbce9 removed redundant model_config 2024-12-14 19:32:56 -06:00
phernandez b471e1f8d7 fix schema plural namings 2024-12-14 19:30:01 -06:00
phernandez 635c2851a7 renamed all schemas to be Request/Response 2024-12-14 19:14:17 -06:00
phernandez 2b4bd19da1 cleaned up schemas for api/mcp server 2024-12-14 19:09:56 -06:00
phernandez 9b359b884c rename RelationIn to Relation 2024-12-14 18:48:30 -06:00
phernandez 263e82f36a add all tool endpoints to fastapi 2024-12-14 18:40:49 -06:00
phernandez 022cb0d1ce remove ObservationsIn from schemas.py 2024-12-14 18:24:24 -06:00
phernandez 1312046786 all tests passing 2024-12-14 18:04:42 -06:00
phernandez f075c29a58 knowledge api tests passing 2024-12-14 17:23:10 -06:00
phernandez 29043168fd fix fastapi async context manager depa 2024-12-14 14:06:06 -06:00
phernandez 4e21811847 fix deps for fastapi 2024-12-14 13:47:51 -06:00
phernandez 551a36e92b debugging fastapi tests 2024-12-14 10:35:05 -06:00
phernandez e63e78950a tests passing 2024-12-14 10:00:39 -06:00
phernandez 1ed4c72faa fastapi app for knowledge routes 2024-12-14 09:33:18 -06:00
phernandez 01defe8ef1 update project to for uv workspaces 2024-12-13 22:01:56 -06:00
phernandez 8409199775 migrate entities to folders 2024-12-12 21:22:33 -06:00
phernandez cac8a491df remove utc dates from models 2024-12-12 20:33:33 -06:00
phernandez d56197c288 default created_at for observation, relation 2024-12-12 20:29:11 -06:00
phernandez ff98c8068f refactor observation create 2024-12-12 19:45:25 -06:00
phernandez acd5ea5515 refactor model create 2024-12-12 18:56:39 -06:00
phernandez 143371fd56 write files in dirs 2024-12-12 14:47:16 -06:00
phernandez 91af5bc668 memory_service debug 2024-12-12 08:19:14 -06:00
phernandez a3ad0fa545 entity rename debugging 2024-12-11 21:24:41 -06:00
phernandez 747277a832 debug memory service file writes 2024-12-11 21:08:46 -06:00
phernandez 6ab1d52df8 write entities files to folders 2024-12-11 19:49:34 -06:00
phernandez dbbf5b9263 add test_find_by_type_and_name 2024-12-11 17:55:54 -06:00
phernandez 74ff668a0a fix tests for in memory db 2024-12-11 17:27:00 -06:00
phernandez 94b29a7cb6 refactor entity, db models for required fields 2024-12-11 12:27:31 -06:00
phernandez 61a8cfcf0a use db defaults for created_at 2024-12-10 14:53:51 -06:00
phernandez 90a5414c58 fix entity.description 2024-12-10 14:22:11 -06:00
phernandez aa7663a8c2 add docs and tasks 2024-12-10 13:34:46 -06:00
phernandez 6e2c4a0801 mcp server impl, docs on obsidian idea 2024-12-09 22:46:13 -06:00
phernandez dadd73abd4 mcp service running 2024-12-08 22:16:32 -06:00
phernandez 5fc8baff9f add some logging, save memory.json 2024-12-08 20:00:55 -06:00
phernandez 163d4fac32 memory_service.add_relation 2024-12-08 19:38:54 -06:00
phernandez b2615515df tests working for mcp server 2024-12-08 19:32:00 -06:00
phernandez 352cfa1e18 mcp server tests 2024-12-08 18:27:42 -06:00
phernandez 8382578105 mcp server wip 2024-12-08 16:18:11 -06:00
phernandez 031a009245 split up repository logic 2024-12-08 15:28:32 -06:00
phernandez e20776cd02 mcp service testing 2024-12-08 14:34:28 -06:00
phernandez e0361d3422 MemoryServer wip 2024-12-08 14:14:24 -06:00
phernandez fb21192119 test_mcp_server wip 2024-12-08 12:16:32 -06:00
phernandez 18dd87966b inital mcp impl 2024-12-08 11:56:04 -06:00
phernandez f1ec5f121c save memory.json 2024-12-08 09:54:11 -06:00
phernandez ab64098955 save memory.json 2024-12-08 00:46:18 -06:00
phernandez 49910d5507 all tests passing 2024-12-08 00:39:51 -06:00
phernandez c71dd2cf0d refactor schemas and services 2024-12-07 22:15:22 -06:00
phernandez ac95036d0e add_obserations wip 2024-12-07 20:09:09 -06:00
phernandez 5b0d2803c1 save memory.json 2024-12-07 17:52:20 -06:00
phernandez 122dbd7d31 MemoryService.create_relation implemented 2024-12-07 17:51:52 -06:00
phernandez b285b4619f memory_service.create_relations wip 2024-12-07 16:30:15 -06:00
phernandez f2c1e93900 memory_service.create_entities 2024-12-07 15:22:14 -06:00
phernandez 931a5f31c5 refactoring MemoryService, adding stored memory.json 2024-12-07 09:54:49 -06:00
phernandez 8133fb6fa2 memory_service first draft 2024-12-06 19:10:58 -06:00
phernandez a0023bb69d remove tests/.coverage 2024-12-05 20:55:22 -06:00
phernandez cac7ac942e add relation tests 2024-12-05 20:54:12 -06:00
phernandez 0fd35772bc re-add missing observationservice methods 2024-12-05 15:56:25 -06:00
phernandez 4c480d2fd7 remove coverage 2024-12-05 15:50:12 -06:00
phernandez a3bfc3c42d Implement relations 2024-12-05 15:49:36 -06:00
phernandez 29ad6fcf4b relation service tests wip 2024-12-05 11:49:41 -06:00
phernandez 6ebc1dbdf1 relation service wip 2024-12-05 11:39:31 -06:00
phernandez 4f9bf9b859 wip save 2024-12-05 11:13:21 -06:00
phernandez 7fa660f066 update EntityService with fileio 2024-12-03 23:12:03 -06:00
phernandez 68ab07cc9d Add ObservationService 2024-12-03 23:06:53 -06:00
phernandez 8db01ec312 delete old files 2024-12-03 20:04:49 -06:00
phernandez af2eb3ddea finish EnityService 2024-12-03 19:47:25 -06:00
phernandez da4a26a3d1 refactor error handling in EntityService 2024-12-03 19:14:28 -06:00
phernandez 3f13014fdb refactor error logic in EventService _read_entity_file 2024-12-03 18:39:42 -06:00
phernandez c6e867f07b add pydantic models for Entity/Observation/Relation 2024-12-03 18:18:31 -06:00
phernandez e00ecfb3ad refactor Repository constructor arg in EntityService 2024-12-03 17:38:48 -06:00
phernandez 0c535a8208 fix EventService tests 2024-12-03 17:20:04 -06:00
phernandez 432898619e separate EntityService wip 2024-12-03 16:54:06 -06:00
phernandez 5c62db9e96 add .coverage to gitignore 2024-12-03 16:03:11 -06:00
phernandez 57e430c3e8 memory service tests passing, more tests tbd 2024-12-02 23:07:49 -06:00
phernandez 131f520ec2 fix entity create test 2024-12-02 22:56:28 -06:00
phernandez cbf366a7dc service logic wip 2024-12-02 22:51:45 -06:00
phernandez 79e5d19c5a set timezones in models.py 2024-12-02 21:50:58 -06:00
phernandez 5946d8e920 adding libs and fixing async tests 2024-12-02 21:43:45 -06:00
phernandez 2982e77635 rename db tables to singular 2024-12-02 21:09:20 -06:00
phernandez fe24e09075 update db, models modules with docs 2024-12-02 20:27:18 -06:00
phernandez 1d0643c16c Add initial database setup
- Create migrations directory with initial schema
- Add Makefile with dbmate commands
- Configure database using .env
- Add projects directory for data storage
- Update .gitignore for SQLite files
2024-12-02 19:32:54 -06:00
phernandez 7b47a5aaeb add .idea to .gitignore 2024-12-02 19:07:00 -06:00
phernandez fa7018c1b9 Initial project structure
- Set up src/basic_memory package
- Added initial test structure
- Configure modern Python tooling (black, ruff, pytest)
- Added comprehensive README
- Set dependencies in pyproject.toml
2024-12-02 16:53:38 -06:00
Paul Hernandez f95a85626c Initial commit 2024-12-02 16:40:44 -06:00
122 changed files with 19383 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
name: Release
on:
workflow_dispatch:
inputs:
version_type:
description: 'Type of version bump (major, minor, patch)'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
jobs:
release:
runs-on: ubuntu-latest
concurrency: release
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Python Semantic Release
id: release
uses: python-semantic-release/python-semantic-release@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
if: steps.release.outputs.released == 'true'
with:
password: ${{ secrets.PYPI_TOKEN }}
- name: Publish to GitHub Release Assets
uses: python-semantic-release/publish-action@v9.8.9
if: steps.release.outputs.released == 'true'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ steps.release.outputs.tag }}
+42
View File
@@ -0,0 +1,42 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
.env
.venv
env/
venv/
ENV/
# IDE
.idea/
.vscode/
# Project specific
projects/*.db
projects/*.db-journal
/.coverage
**/.DS_Store
*.log
/.coverage.*
+1
View File
@@ -0,0 +1 @@
3.12
+4
View File
@@ -0,0 +1,4 @@
# CHANGELOG
## v0.0.0 (2025-02-04)
+10
View File
@@ -0,0 +1,10 @@
cff-version: 1.0.3
message: "If you use this project, please cite it as follows:"
authors:
- family-names: "Hernandez"
given-names: "Paul"
affiliation: "Basic Machines"
title: "Basic Memory"
version: "0.0.1"
date-released: "2025-02-03"
url: "https://github.com/basicmachines-co/basic-memory"
+19
View File
@@ -0,0 +1,19 @@
# Code of Conduct
## Purpose
Maintain a respectful and professional environment where contributions can be made without harassment or
negativity.
## Standards
Respectful communication and collaboration are expected. Offensive behavior, harassment, or personal attacks will not be
tolerated.
## Reporting Issues
To report inappropriate behavior, contact [paul@basicmachines.co].
## Consequences
Violations of this code may lead to consequences, including being banned from contributing to the project.
+17
View File
@@ -0,0 +1,17 @@
# Contributing to Basic Memory
Thank you for considering contributing to Basic Memory! Your help is greatly appreciated to improve this project.
## How to Contribute
1. **Fork the Repo**: Fork the repository and clone your copy.
1. **Create a Branch**: Create a new branch for your feature or fix.
1. **Test Your Changes**: Ensure tests pass locally and include new tests when necessary to ensure 100& test coverage.
1. **Format Your Code**: Run `make format` to ensure code is formatted appropriately.
4. **Submit a PR**: Submit a pull request with a detailed description of your changes.
Thank You!
## Code of Conduct
All contributors must follow the [Code of Conduct](CODE_OF_CONDUCT.md).
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+39
View File
@@ -0,0 +1,39 @@
.PHONY: install test lint db-new db-up db-down db-reset
install:
brew install dbmate
pip install -e ".[dev]"
test:
pytest -p pytest_mock -v
lint:
black .
ruff check .
db-new:
dbmate new $(name)
db-up:
dbmate up
db-down:
dbmate down
db-reset:
dbmate drop
dbmate up
clean:
find . -type f -name '*.pyc' -delete
find . -type d -name '__pycache__' -exec rm -r {} +
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
+34
View File
@@ -0,0 +1,34 @@
# Basic Memory
> Local-first knowledge management combining Zettelkasten with knowledge graphs.
Basic Memory helps you capture and explore information the way your brain naturally works - across multiple dimensions and perspectives. Built on open standards and powered by proven technology, it's ready for the future of human-AI collaboration.
## Features
- Local-first design - your files stay yours
- Knowledge graph with natural relations
- Markdown files with automatic parsing
- Rich context building and search
- Native AI integration via MCP protocol
## Quick Start
```bash
pip install basic-memory
```
Initialize a new knowledge base:
```bash
basic-memory init my-knowledge
cd my-knowledge
basic-memory status
```
## Contributing
Basic Memory is open source (AGPL-3.0) and welcomes contributions. See our [Contributing Guide](CONTRIBUTING.md) for details.
## License
AGPL-3.0 - See [LICENSE](LICENSE) for details.
+1419
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

+378
View File
@@ -0,0 +1,378 @@
{"type":"entity","name":"Paul","entityType":"person","observations":["Software developer combining DIY ethics, Free Software principles, and theoretical computer science","Created the Basic Machines project","Values authentic exchange of ideas","Approaches AI interaction with emphasis on genuine technical discussion","Comfortable with uncertainty and open dialogue","Balances practical implementation with broader implications"]}
{"type":"entity","name":"Basic_Machines","entityType":"project","observations":["Local-first knowledge management system","Combines filesystem durability with graph-based knowledge representation","Focuses on enhancing human agency and understanding","Synthesizes DIY ethics, Free Software philosophy, and theoretical computer science","Current focus includes basic-memory system"]}
{"type":"entity","name":"basic-memory","entityType":"software_system","observations":["A core component of Basic Machines","Local-first knowledge management system","Combines filesystem persistence with graph-based knowledge representation","Being implemented collaboratively by Paul and Claude"]}
{"type":"entity","name":"basic-memory_implementation_patterns","entityType":"technical_patterns","observations":["Filesystem is source of truth - all changes write to files first","Clean separation of concerns between models (SQLAlchemy), schemas (Pydantic), and services","Repository pattern for database access","Service layer handling business logic and coordination","Atomic file operations using temporary files for safety","Clear error handling hierarchy with specific error types","Comprehensive test coverage with pytest and fixtures","Async/await used throughout the codebase","Validation using Pydantic models with custom validators"]}
{"type":"entity","name":"fileio_module","entityType":"code_module","observations":["Extracted from EntityService to handle all file operations","Provides read_entity_file, write_entity_file, and delete_entity_file functions","Handles markdown parsing and formatting","Implements atomic file operations","Provides consistent error handling","Enables reuse across services"]}
{"type":"entity","name":"entity_service","entityType":"code_module","observations":["Manages entities in both filesystem and database","Uses fileio module for file operations","Maintains database index of entities","Handles entity creation, retrieval, and deletion","Follows 'filesystem is source of truth' principle","Coordinates with observation service for full entity management"]}
{"type":"entity","name":"observation_service","entityType":"code_module","observations":["Manages observations within entity files","Provides database indexing for efficient observation queries","Works with complete Entity objects rather than IDs","Handles observation addition and search","Maintains consistency between files and database","Under development for update/remove operations"]}
{"type":"entity","name":"observation_management","entityType":"design_challenge","observations":["Key challenge: maintaining observation state across files and database","Exploring bulk update approach - treating all observations as a unit","Considering tracked observations with markdown comments for IDs","Investigating diff-based approach for observation-level changes","Evaluating position-based management without explicit IDs","Trade-offs between implementation complexity and markdown readability"]}
{"type":"entity","name":"testing_infrastructure","entityType":"technical_patterns","observations":["Uses pytest with async support via pytest-asyncio","In-memory SQLite database for test isolation","Temporary directories for file operation testing","Comprehensive fixture system for test setup","Tests organized by component (entity, observation, etc)","Covers happy path, error cases, and edge cases","Uses monkeypatch for mocking dependencies","Clear separation between arrange, act, assert sections","Uses in-memory SQLite database for test isolation","Comprehensive fixture system for test data setup","Proper async test handling with pytest-asyncio"]}
{"type":"entity","name":"test_categories","entityType":"test_suite","observations":["Happy path tests verify core functionality","Error path tests ensure proper error handling","Edge cases test special characters and long content","File operation tests verify atomic writes and rollbacks","Database sync tests verify index consistency","Recovery tests for rebuild operations","Punted on concurrent operation tests due to session management complexity"]}
{"type":"entity","name":"completed_work","entityType":"project_milestone","observations":["Extracted file operations to fileio.py module","Updated EntityService to use fileio functions","Implemented initial ObservationService","Created comprehensive test suite","Established clear project patterns and principles","Set up basic database schema with SQLAlchemy","Created Pydantic models for validation"]}
{"type":"entity","name":"future_work","entityType":"project_tasks","observations":["Implement observation updates/removals","Design proper session management for concurrent operations","Update EntityService tests for new fileio module","Add more sophisticated search functionality","Handle markdown formatting edge cases","Consider versioning for file changes","Implement proper backup strategy"]}
{"type":"entity","name":"design_decisions","entityType":"technical_decisions","observations":["Filesystem as source of truth over database","Markdown format for human readability and editing","Atomic file operations for safety","SQLite + SQLAlchemy for proven reliability","Pydantic for validation and ID generation","Async/await for better scalability","Clear separation between files and database roles","Explicit error hierarchies for better handling"]}
{"type":"entity","name":"concurrency_considerations","entityType":"technical_challenge","observations":["SQLAlchemy session management in async context","File operation atomicity","Transaction isolation levels","Potential for conflicting updates","Need for proper session lifecycle","Possibility of file system race conditions","Database lock management"]}
{"type":"entity","name":"observation_update_approaches","entityType":"design_alternatives","observations":["Each approach trades off between simplicity, efficiency, and robustness","Four main approaches considered: bulk update, tracked IDs, diff-based, and position-based","Discussion revealed importance of human readability in file format","Consideration of manual editing workflows key to design","File system as source of truth principle guides tradeoffs"]}
{"type":"entity","name":"bulk_update_approach","entityType":"design_option","observations":["Update all observations at once in a single operation","Simpler file operations - just rewrite the whole list","No need for observation matching or IDs","Very consistent with source of truth principle","Less efficient for small changes","May have concurrency implications","Simplest implementation option"]}
{"type":"entity","name":"tracked_observations_approach","entityType":"design_option","observations":["Use markdown comments to store observation IDs","Enables precise updates and deletes","IDs stored as HTML comments in markdown","More complex markdown parsing required","IDs visible in raw markdown files","Balances tracking with readability"]}
{"type":"entity","name":"diff_based_approach","entityType":"design_option","observations":["Implement observation-aware diffing","Track changes at observation level","More efficient for updates","Preserves manual edits and changes","More complex implementation needed","Must handle merge conflicts","Most sophisticated option considered"]}
{"type":"entity","name":"position_based_approach","entityType":"design_option","observations":["Track observations by position/order","No explicit IDs needed","Cleanest markdown format","Order changes could break references","Difficult to handle concurrent edits","Most fragile option considered"]}
{"type":"entity","name":"tasks_and_progress","entityType":"project_tracking","observations":["Current focus on observation management implementation","Completed core file operations extraction","Completed EntityService updates","Completed initial ObservationService","Basic test coverage in place","Future work includes concurrent operations","Future work includes search improvements","Need to handle markdown edge cases"]}
{"type":"entity","name":"error_handling_patterns","entityType":"technical_patterns","observations":["Custom exception hierarchy with ServiceError base","Specific error types (FileOperationError, DatabaseSyncError, etc)","Clear separation between file and database errors","Error propagation patterns established","Focus on actionable error messages","Error handling at appropriate levels"]}
{"type":"entity","name":"data_models","entityType":"technical_implementation","observations":["SQLAlchemy models for database structure","Pydantic schemas for API/service layer","Entity model with UUID-based IDs","Observation model with entity relationships","UTCDateTime custom type for timestamps","Automatic ID generation in Pydantic models","Strict validation rules"]}
{"type":"entity","name":"markdown_format","entityType":"file_format","observations":["Simple, human-readable format","Entity name as H1 header","Metadata in key-value format","Observations as bullet points","Atomic file operations for updates","Designed for manual editing","No hidden metadata in main content"]}
{"type":"entity","name":"test_driven_development","entityType":"development_pattern","observations":["Tests revealed need for atomic file operations","Error cases drove error hierarchy design","Edge cases informed validation rules","Test fixtures shaped service interfaces","File operations extracted due to test patterns","Concurrent test issues revealed session management needs"]}
{"type":"entity","name":"architecture_evolution","entityType":"design_process","observations":["Started with simple EntityService implementation","Circular dependency between Entity and Observation services revealed design flaw","Extracted file operations to separate module","Moved to passing Entity objects rather than IDs","Improved separation of concerns through iterations","File operations became reusable across services","Database became true 'index' rather than source of truth"]}
{"type":"entity","name":"validation_patterns","entityType":"technical_patterns","observations":["Pydantic models provide schema validation","Automatic ID generation if not provided","Database constraints via SQLAlchemy","Runtime checks in services","Markdown format validation","Error handling for invalid states"]}
{"type":"entity","name":"markdown_examples","entityType":"documentation","observations":["Example of basic entity:\n# Entity Name\ntype: entity_type\n\n## Observations\n- First observation\n- Second observation","Example with special characters:\n# Test & Entity!\ntype: test\n\n## Observations\n- Test & observation with @#$% special chars!","Format ensures human readability:\n# Basic Machines\ntype: project\n\n## Observations\n- Local-first knowledge management system\n- Combines filesystem durability with graph-based knowledge representation","Future consideration for observation IDs:\n# Entity Name\ntype: entity_type\n\n## Observations\n- <!-- obs-id: abc123 -->\n This is an observation with ID"]}
{"type":"entity","name":"markdown_parsing_rules","entityType":"technical_implementation","observations":["H1 header contains entity name","Metadata uses key: value format","Observations section marked by H2 header","Each observation is a markdown list item","Blank lines separate sections","Special characters allowed in content","No restrictions on observation content"]}
{"type":"entity","name":"schema_definitions","entityType":"technical_documentation","observations":["SQLAlchemy Entity model:\nclass Entity(Base):\n id: str (primary key)\n name: str (unique)\n entity_type: str\n created_at: datetime\n updated_at: datetime","SQLAlchemy Observation model:\nclass Observation(Base):\n id: str (primary key)\n entity_id: str (foreign key)\n content: str\n created_at: datetime\n context: Optional[str]","Pydantic Entity schema:\nclass Entity(BaseModel):\n id: str\n name: str\n entity_type: str\n observations: List[Observation]"]}
{"type":"entity","name":"test_evolution","entityType":"development_history","observations":["Started with basic Entity CRUD tests","Added filesystem verification to all tests","Developed concurrent operation tests (later removed)","Edge case tests drove better error handling","Test fixtures evolved to support both file and DB testing","Mocking patterns for file/DB operations","Special cases for long content and special characters"]}
{"type":"entity","name":"implementation_challenges","entityType":"technical_issues","observations":["Initial circular dependency between services","SQLAlchemy session management in async context","Atomic file operations with proper error handling","Maintaining DB sync with filesystem changes","Handling long content in observations","Managing test isolation with file operations","Deciding on markdown format tradeoffs","Concurrent operation complexity"]}
{"type":"entity","name":"Basic_Factory","entityType":"Project","observations":["Collaborative project between Paul and Claude","Explores AI-human collaboration in software development","Uses MCP tools for file and memory management","Built with git integration capabilities","Focuses on maintaining project context across sessions","About 90% complete with MCP tools","Still needs improvements in collaboration via files/git/github","Will be used to document and share collaborative development process"]}
{"type":"entity","name":"Basic_Factory_Components","entityType":"Technical","observations":["Server-side rendering with JinjaX","HTMX for dynamic updates","Alpine.js for client-side state","Tailwind CSS for styling","Component translation from React/shadcn/ui","Focus on simplicity and understandability","Demonstrates meta-compiler principles in component translation"]}
{"type":"entity","name":"Component_Translation_Process","entityType":"Methodology","observations":["Treats component porting as meta-compilation","Maps between React/TypeScript and JinjaX/Alpine.js domains","Uses formal grammar transformation approaches","Maintains functionality while simplifying implementation","Focuses on server-side rendering patterns","Preserves accessibility and performance","Uses short, focused git branches for each component"]}
{"type":"entity","name":"Basic_Machines_Philosophy","entityType":"Philosophy","observations":["Combines DIY punk ethics with software development","Emphasizes user empowerment and understanding","Values simplicity and composability","Treats complex systems as combinations of simple parts","Focuses on authentic creation and sharing","Draws inspiration from punk rock, Free Software, and theoretical CS","Emphasizes the cycle of creation, complexity, and renewal"]}
{"type":"entity","name":"Basic_Machines_Manifesto","entityType":"Document","observations":["Created through collaboration between Paul and Claude","Explores connection between DIY punk ethics and software development","Emphasizes composition over inheritance in both philosophy and practice","Views software development through lens of basic machines that combine for complex computation","Advocates for user empowerment and technological independence","Structured in sections covering Origins, Philosophy, Technical Implementation, and AI Collaboration","Draws connections between punk rock, free software, and theoretical computer science","Emphasizes importance of sharing knowledge and building community","Released in December 2024"]}
{"type":"entity","name":"AI_Human_Collaboration_Model","entityType":"Methodology","observations":["Focuses on deep collaboration rather than simple task completion","Maintains rich context across sessions via knowledge graph","Uses short, focused git branches for each collaborative session","Values intellectual partnership over simple code generation","Emphasizes both practical implementation and theoretical exploration","Creates space for authentic exchange while maintaining AI/human clarity","Uses formal methods when appropriate (like grammar transformation)","Documents decisions and processes for future reference","Developed through Basic Machines project experience"]}
{"type":"entity","name":"Basic_Machines_Roadmap","entityType":"Project_Plan","observations":["Phase 1 (30 days): Build basic-machines.co website","Phase 2 (60-90 days): Develop premium component bundles","Phase 3 (90-120 days): Launch Basic Foundation commercial offering","Focus on building brand and marketing presence","Prioritize components needed for own site development","Document and share collaboration process","Build sustainable business model aligned with values"]}
{"type":"entity","name":"Basic_Machines_Website","entityType":"Project","observations":["To be built at basic-machines.co","Will showcase products and vision","Needs components for navigation, hero sections, features","Will demonstrate component usage in production","Will include blog for sharing progress","Focus on clear value proposition","Platform for sharing Basic Machines philosophy"]}
{"type":"entity","name":"Basic_Memory_Markdown_Example","entityType":"Example","observations":["Shows complete markdown structure for basic-memory entity","Uses frontmatter for metadata (id, type, created, context)","Has main description section after title","Includes Observations as bullet points","Shows Relations with [id] relation_type | context format","Lists References at bottom","Created during initial design discussion","Serves as canonical example of file format"]}
{"type":"entity","name":"Basic_Memory_Database_Schema","entityType":"Technical","observations":["Uses SQLite for local storage","Entities table with id, name, type, created_at, context, description, references","Observations table linking to entities with content and context","Relations table tracking directional relationships between entities","References column needs quotes as SQL reserved word","Designed for easy rebuilding from markdown files","Foreign key constraints maintain data integrity","Unique constraint on relations prevents duplicates","Created_at timestamps track history","Context fields enable tracking information sources"]}
{"type":"entity","name":"Basic_Memory_Project_Structure","entityType":"Technical","observations":["Uses dbmate for database migrations","Projects directory stores SQLite databases and markdown files","Makefile provides common development commands","Environment vars configure database connection","db/migrations directory for SQL schema changes","Gitignore excludes database files and env config","Uses Python 3.12 with modern tooling","Tests directory for pytest files","Follows Basic Machines project conventions"]}
{"type":"entity","name":"Basic_Memory_Project_Isolation_Decision","entityType":"Decision","observations":["Decided to defer multi-project support to post-MVP","Will use separate SQLite databases per project","Initially using projects directory in code repository","Plan to make location configurable later","No changes needed to core domain model","Keeps initial implementation simple","FTS/search capabilities also deferred for simplicity"]}
{"type":"entity","name":"Basic_Memory_Implementation_Plan","entityType":"Plan","observations":["Start with SQLAlchemy models matching schema","Then build CLI for basic operations","Then implement markdown parser","Use TDD approach throughout","Begin with core domain model","CLI will support CRUD operations","Parser must handle frontmatter and sections","Following modular development approach","Planning to use typer for CLI","Will use modern Python tools and practices"]}
{"type":"entity","name":"Basic_Memory_Implementation_Status","entityType":"Status","observations":["Core modules implemented: models, services, repository, fileio","Modular architecture with clear separation of concerns","File operations extracted to separate fileio module","Initial ObservationService implementation complete","Basic test coverage in place","Exploring observation management strategies","Using SQLAlchemy for database interaction","Markdown file operations working","Entity management functional","Repository layer implementation complete with SQLAlchemy models and tests","Database operations working with proper UTC timestamp handling","In-memory SQLite testing infrastructure proven effective"]}
{"type":"entity","name":"Basic_Memory_Observation_Management_Design","entityType":"Design","observations":["Four approaches under consideration","Bulk Update: Simple but less efficient","Tracked Observations: Precise but clutters markdown","Diff-based: Efficient but complex","Position-based: Clean but fragile","Key challenge is balancing markdown readability with efficient updates","Must maintain filesystem as source of truth","Need to consider concurrent edits","Currently evaluating trade-offs","Implementation choice pending discussion"]}
{"type":"entity","name":"Basic_Memory_Architectural_Decisions","entityType":"Decisions","observations":["Split file operations into separate fileio module","Using SQLAlchemy for database operations","Maintain filesystem as source of truth","Modular service-based architecture","Clear separation between data access and business logic","Repository pattern for database interactions","Schemas separate from models","Focus on maintainability and testability","Services handle business rules","Considering concurrency in design"]}
{"type":"entity","name":"Basic_Memory_Implementation_Analysis","entityType":"Analysis","observations":["Clean modular architecture with clear responsibilities","Strong typing throughout codebase","Excellent error handling with custom exceptions","SQLAlchemy models perfectly match our domain model","Atomic file operations for data safety","Services implement filesystem-as-source-of-truth principle","Async support throughout","Good separation between domain models and database models","Careful handling of UTC timestamps","Smart use of SQLAlchemy relationships"]}
{"type":"entity","name":"Basic_Memory_Current_Challenges","entityType":"Challenges","observations":["Observation update/removal strategy needs to be chosen","Need to handle concurrent file operations safely","Search functionality to be implemented","Edge cases in markdown formatting to be handled","Session management for concurrent operations needed","Balance between file operations and database sync","Testing coverage could be expanded","Need to handle relationship updates in files"]}
{"type":"entity","name":"Basic_Memory_Observation_Hash_Tracking","entityType":"Design","observations":["Use content hashes to track observation identity","Store hashes in database but not in markdown","Can match observations across file edits using hashes","Similar to how git tracks content changes","Keeps markdown clean and human-friendly","Allows efficient bulk updates","Handles reordering of observations","Maintains filesystem as source of truth","No need for visible IDs in markdown","Could track observation history through hash changes"]}
{"type":"entity","name":"Basic_Memory_Repository_Implementation","entityType":"Code_Implementation","observations":["Implemented base Repository class with CRUD operations","Added specialized EntityRepository, ObservationRepository, and RelationRepository","Used string IDs instead of UUIDs","Added UTCDateTime custom type for timestamp handling","Used in-memory SQLite for testing","Achieved 84% test coverage","Created comprehensive pytest fixtures"]}
{"type":"entity","name":"Basic_Memory_Dependencies","entityType":"Technical","observations":["Uses Python 3.12","SQLAlchemy with async support","pytest-asyncio for async testing","aiosqlite for async SQLite operations","greenlet for SQLAlchemy async support","uv for dependency management","pytest-cov for coverage reporting","Development dependencies managed in pyproject.toml"]}
{"type":"entity","name":"Basic_Memory_Current_Architecture","entityType":"Architecture_Analysis","observations":["Clear separation between domain models (Pydantic) and storage models (SQLAlchemy)","File I/O completely separated into dedicated module","Strong 'filesystem as source of truth' pattern in services","Atomic file operations with proper error handling","Service layer coordinates between filesystem and database","Database acts as queryable index rather than primary storage","Clean error hierarchy with specific exception types","Rebuild operations available for recovery scenarios"]}
{"type":"entity","name":"Basic_Memory_Evolution","entityType":"Analysis","observations":["Started with repository pattern following basic-foundation","Evolved to more sophisticated architecture with clear layers","Added Pydantic schemas for domain modeling","Separated file operations into dedicated module","Implemented robust error handling throughout","Maintained filesystem as source of truth principle","Added observation management with context tracking","Introduced rebuild capabilities for system recovery"]}
{"type":"entity","name":"Basic_Memory_Service_Layer","entityType":"Implementation","observations":["EntityService handles entity lifecycle and coordinates storage","ObservationService manages observations within entities","Services ensure filesystem and database stay in sync","Clear error handling with ServiceError hierarchy","Strong typing throughout service interfaces","Implements filesystem as source of truth pattern","Handles UUID generation and timestamp management","Provides methods for system recovery and rebuild"]}
{"type":"entity","name":"Basic_Memory_Schema_Design","entityType":"Implementation","observations":["Uses Pydantic for domain models and validation","Automatic ID generation with timestamp and UUID","Clear separation from SQLAlchemy storage models","Supports optional context tracking","Models match markdown file structure","Enables clean serialization/deserialization","Strong typing with proper validation rules","Independent from storage concerns"]}
{"type":"entity","name":"Basic_Memory_Next_Tasks","entityType":"TaskList","observations":["✅ Implement SQLAlchemy models and repositories (Done)","✅ Add SQLAlchemy migrations (Done)","✅ Create service layer (Done)","✅ Implement file I/O module (Done)","✅ Set up domain models with Pydantic (Done)","✅ Initial test infrastructure (Done)","✅ Basic CRUD operations (Done)","⏳ Implement full test coverage for db.py","⏳ Add more sophisticated search functionality","⏳ Implement CLI interface","⏳ Add relationship management to services","⏳ Handle concurrent file operations safely","⏳ Add versioning for file changes","⏳ Implement proper backup strategy","⏳ Add type hints throughout codebase","⏳ Improve error messages and logging","⏳ Add documentation for core modules"]}
{"type":"entity","name":"Basic_Memory_Meta_Experience","entityType":"Case_Study","observations":["Experienced our own context loss when reconstructing project knowledge","Had to rebuild task list and project context from filesystem and memory","Validated 'filesystem as source of truth' principle through reconstruction","Code and tests served as reliable historical record","Knowledge graph structure helped guide reconstruction process","Markdown files provided human-readable context","Atomic information design made piece-by-piece reconstruction possible","Ironic validation of the need for basic-memory's features","Experience demonstrates value of durable, human-readable knowledge storage","Shows importance of separating durable storage from ephemeral context"]}
{"type":"entity","name":"Model_Context_Protocol","entityType":"protocol","observations":["Core part of the basic-memory architecture","Enables AI-human collaboration on projects","Provides tool-based interaction with knowledge graph","Developed by Anthropic for structured AI-system interaction","Used for maintaining consistent, rich context across conversations"]}
{"type":"entity","name":"basic-memory_core_principles","entityType":"principles","observations":["Local First: All data stored locally in SQLite","Project Isolation: Separate databases per project","Human Readable: Everything exportable to plain text","AI Friendly: Structure optimized for LLM interaction","DIY Ethics: User owns and controls their data","Simple Core: Start simple, expand based on needs","Tool Integration: MCP-based interaction model"]}
{"type":"entity","name":"basic-memory_business_model","entityType":"business_strategy","observations":["Core features free: Local SQLite, basic knowledge graph, search, markdown export, basic MCP tools","Professional features potential: Rich document export, advanced versioning, collaboration features, custom integrations, priority support","Focus on maintaining DIY/punk philosophy while enabling sustainability"]}
{"type":"entity","name":"basic-memory_cli","entityType":"interface","observations":["Supports project management commands (create, switch, list)","Entity management (add entity, add observation, add relation)","Future support for export and batch operations","Follows consistent command structure","Planned integration with MCP tools"]}
{"type":"entity","name":"basic-memory_export_format","entityType":"file_format","observations":["Uses markdown with frontmatter metadata","Includes entity name, type, creation timestamp","Observations as bullet points","Relations in structured format with links","References section at bottom","Designed for human readability and machine parsing","Example format documented in project specs"]}
{"type":"entity","name":"relation_service","entityType":"code_module","observations":["Planned service for managing relations in both filesystem and database","Will follow filesystem-is-source-of-truth principle like other services","Needs to handle atomic file operations for relation updates","Must coordinate with EntityService for relationship integrity","Will handle bidirectional relationship tracking","Will support relation validation and type enforcement","Must implement rebuild functionality for index recovery","Will need careful error handling for file/db sync","Should support relation search and filtering","Must handle relation lifecycle (create/read/update/delete)"]}
{"type":"entity","name":"service_layer_patterns","entityType":"implementation_patterns","observations":["Services handle both file and database operations","Filesystem is always source of truth","Database serves as queryable index","Services implement atomic file operations","Clear error hierarchy with specific exceptions","Use of dependency injection via constructor params","Async/await used throughout service layer","Services coordinate between storage layers","Repository pattern used for database access","Services maintain entity integrity across storage","Rich error types extend from ServiceError base","Rebuild operations available for recovery"]}
{"type":"entity","name":"database_models","entityType":"implementation","observations":["Entity model with unique name and type","Observation model linked to entities","Relation model tracks connections between entities","Custom UTCDateTime type for timestamp handling","Use of SQLAlchemy relationships for navigation","Cascading deletes for dependent objects","String IDs used for compatibility","Rich relationship modeling with backpopulates","Proper indexing on foreign keys","Context tracking available on models","Models include created_at timestamps","Relationships handle bidirectional navigation"]}
{"type":"entity","name":"repository_patterns","entityType":"implementation_patterns","observations":["Generic Repository[T] base class implementation","Type-safe operations with SQLAlchemy","Specialized repositories for each model type","Async operations throughout","Clear error handling patterns","Support for custom queries and filtering","Pagination support built-in","Transaction management via session","Proper type hints and generics usage","Entity-specific query methods in subclasses"]}
{"type":"entity","name":"relation_service_design","entityType":"design","observations":["Must handle relation lifecycle in both files and DB","Needs to validate existence of both entities","Should support relation type enforcement","Must maintain bidirectional consistency","Should support relation querying and filtering","Needs proper error handling for graph consistency","Must integrate with entity file format","Should support bulk operations for efficiency","Must handle relation deletion and cascading","Should provide search by type and entities"]}
{"type":"entity","name":"relation_service_implementation_plan","entityType":"plan","observations":["1. Define core relation operations (create, get, delete)","2. Implement file format handling for relations","3. Add database sync with RelationRepository","4. Implement validation and error handling","5. Add rebuild and recovery operations","6. Implement relation type enforcement","7. Add relation search and filtering","8. Implement bulk operations","9. Add comprehensive tests","10. Document API and error handling"]}
{"type":"entity","name":"relation_service_challenges","entityType":"challenges","observations":["Maintaining consistency between file and database","Handling relation type validation efficiently","Managing bidirectional relationships in files","Ensuring atomic updates across entities","Handling deletion with proper cascading","Efficient querying of relation graphs","Recovery from partial file/db sync failures","Bulk operation atomicity","Clear error reporting for graph operations","Performance with large relation sets"]}
{"type":"entity","name":"relation_file_format","entityType":"file_format","observations":["Relations stored in entity markdown files","Format: [target_id] relation_type | context","Relations section marked by ## Relations header","Outgoing relations only stored in source entity","Relations rebuild on entity load","Clean human-readable format","Context is optional with pipe separator","Links generate valid navigation references","Markdown-friendly formatting","Example: [Paul] authored | with Claude"]}
{"type":"entity","name":"relation_service_error_handling","entityType":"implementation_patterns","observations":["RelationError extends ServiceError base","Specific errors for validation failures","Handles entity not found cases","Manages relation type validation errors","File operation errors properly wrapped","Database sync errors clearly reported","Transaction rollback on errors","Proper error propagation chain","Clear error messages for debugging","Recovery paths for common errors"]}
{"type":"entity","name":"relation_service_testing","entityType":"testing","observations":["Test all relation lifecycle operations","Verify file and database consistency","Test relation type validation","Check error handling paths","Test bulk operations","Verify bidirectional consistency","Test recovery operations","Check cascade operations","Verify search and filtering","Test with large relation sets"]}
{"type":"entity","name":"fileio_patterns","entityType":"implementation_patterns","observations":["Atomic file operations with temporary files","Clear error handling for IO operations","Consistent file naming and paths","Support for different file formats","Efficient file reading and writing","Proper file locking mechanisms","Recovery from partial writes","Consistent encoding handling","Directory management utilities","Path manipulation helpers","Currently implemented in fileio.py module","Uses pathlib for path operations","Handles file not found cases gracefully","Maintains data integrity during writes"]}
{"type":"entity","name":"pytest_patterns","entityType":"implementation_patterns","observations":["Common fixtures should be in conftest.py for reuse","Use pytest_asyncio.fixture for async fixtures","Session fixtures need proper async cleanup","Temporary directories should be managed with context managers","Test categories: happy path, error path, recovery, edge cases","Services need project_path and repo injected","Use monkeypatch for mocking in async context","SQLite in-memory database ideal for testing","Explicit test verification: file content and database state"]}
{"type":"entity","name":"relation_implementation_learnings","entityType":"implementation_learnings","observations":["Better to pass full Entity objects than IDs to services","Services should not re-read entities if they have them","File operations should be atomic and verified","Database serves as queryable index, not source of truth","Relations stored in source entity's markdown file","Clear separation between file ops and database sync","Entity objects should own their relations list","Context is optional but fully supported in implementation"]}
{"type":"entity","name":"test_driven_insights","entityType":"learnings","observations":["Tests help reveal better API design (e.g., passing Entity objects)","Error cases drive proper exception hierarchy","File verification as important as database checks","Edge cases inform markdown format decisions","Recovery tests ensure system resilience","Tests document expected behavior clearly","Fixtures significantly reduce test complexity","Common patterns emerge through test writing"]}
{"type":"entity","name":"meta_development_insights","entityType":"process","observations":["Break down large tasks into reviewable chunks","One file at a time prevents response truncation","Iterative development with tests leads to better design","Infrastructure code (fixtures) should be consolidated early","Test categories help ensure comprehensive coverage","Knowledge capture should happen during development","APIs tend to evolve toward simpler patterns","File operations require careful verification"]}
{"type":"entity","name":"AI_Assistant_Learnings","entityType":"meta_insights","observations":["Output management: Breaking responses into single files prevents truncation and allows better review","Knowledge graph helps maintain context: I can reference previous decisions and patterns accurately","Memory rebuilding experience validated the need for durable storage","Test-driven development provides clear steps and verification","Explicit relation tracking in knowledge graph helps me understand project context","Rich context from multiple sources (code, docs, tests) enables better assistance","File-at-a-time approach allows deeper analysis of each component","Keeping entity names consistent helps with referencing and relationships"]}
{"type":"entity","name":"Effective_Response_Patterns","entityType":"meta_patterns","observations":["When showing code changes, break into discrete files","Review existing code before suggesting changes","Reference knowledge graph for context and patterns","Explicitly connect new code to existing patterns","Validate suggestions against test cases","Keep track of file changes for atomic commits","Check both implementation and test files for consistency","Maintain clear separation of concerns in responses"]}
{"type":"entity","name":"AI_Context_Management","entityType":"meta_practice","observations":["Knowledge graph provides reliable persistent memory","Project documentation gives high-level context","Code review shows implementation patterns","Tests demonstrate expected behavior","Important to actively track what has been modified","Entity relationships help understand dependencies","Regular knowledge capture during development","Using consistent entity references across conversations"]}
{"type":"entity","name":"AI_Tool_Usage_Patterns","entityType":"meta_practice","observations":["read_file before suggesting changes","write_file one file at a time","list_directory to understand project structure","search_nodes to find relevant context","create_entities to capture new learnings","create_relations to connect concepts","Using knowledge graph to track decisions","Validating changes through test execution"]}
{"type":"entity","name":"relation_service_learnings","entityType":"implementation_learnings","observations":["Entity-based API cleaner than ID-based for service layer","Model_dump method can handle storage serialization","File format needs explicit section markers (## Relations)","Whitespace handling important for long content comparisons","Test fixtures allow focused test cases","SQLAlchemy selects better than raw SQL for type safety","Atomic file operations maintained for relations"]}
{"type":"entity","name":"test_driven_insights_relations","entityType":"learnings","observations":["Tests revealed need for whitespace normalization","Edge cases drove file format decisions","SQLAlchemy model access safer than raw queries","Fixtures reduced test setup complexity","File verification as important as database checks","Testing both memory model and storage format","Test categories ensure comprehensive coverage"]}
{"type":"entity","name":"relation_service_patterns","entityType":"patterns","observations":["Use Entity objects in API","Serialize to IDs for storage","Maintain file as source of truth","Keep file format human-readable","Handle circular references in serialization","Use repository pattern for database","Clear error hierarchies"]}
{"type":"entity","name":"packaging_learnings","entityType":"technical_learnings","observations":["When using pytest-mock, traditional pip install works more reliably than uv sync","Package discovery behavior can differ between uv and pip","Clean venv with pip install is a reliable fallback for dependency issues","Package installation location might differ between uv and pip","Dependencies in pyproject.toml dev section work reliably with pip install -e .[dev]"]}
{"type":"entity","name":"Recent_Implementation_Progress","entityType":"progress_update","observations":["Successfully split services.py into modular structure under services/","Created __init__.py, entity_service.py, observation_service.py, relation_service.py","Fixed pytest-mock installation issues by using pip install -e .[dev] instead of uv sync","Improved test structure with minimal mocking - only used for error testing","Implemented relation service with Entity-based API","Achieved good test coverage across services","File operations are only mocked when testing error conditions","Services follow filesystem-as-source-of-truth pattern"]}
{"type":"entity","name":"Next_Steps","entityType":"project_tasks","observations":["Consider adding more relation service tests","Potentially expand relations features","Look for opportunities to improve test coverage","Consider documenting package management preferences (pip vs uv)","Consider adding integration tests for services","Review and possibly expand error handling cases"]}
{"type":"entity","name":"Development_Practices","entityType":"process","observations":["Favor real operations over mocks in tests","Only mock for error condition testing","Use pip install -e .[dev] for reliable dev dependency installation","Maintain modular service structure","Keep filesystem as source of truth","Use Entity objects in service APIs instead of IDs","Validate both file and database state in tests"]}
{"type":"entity","name":"MCP_Resources","entityType":"Concept","observations":["Stateful objects in Model Context Protocol","Enable persistent access to capabilities"]}
{"type":"entity","name":"MCP_Server_Implementation","entityType":"Technical_Design","observations":["Inherits from mcp.server.Server base class","Tools are implemented as async methods","Each tool method maps directly to a function available to the AI","Tools can request user input via Prompts","Simple function call interface rather than explicit resource management","State management handled by server instance","Returns serialized data using model_dump() for consistency"]}
{"type":"entity","name":"MCP_Tools","entityType":"Protocol_Feature","observations":["Defined as async methods on server class","Return values must match tool definition schema","Can maintain state between invocations via server instance","Tools can prompt for user input when needed","No need for explicit Resource objects in implementation"]}
{"type":"entity","name":"Basic_Memory_MCP","entityType":"Implementation","observations":["Uses MemoryService for core operations","Implements project selection via prompts","Maintains project context across tool invocations","Maps directly to memory graph operations","Handles serialization of Pydantic models"]}
{"type":"entity","name":"Basic_Memory_Testing","entityType":"Testing_Design","observations":["Needs pytest for async testing","Should isolate filesystem operations for tests","Needs to handle MCP server lifecycle in tests","Should test both service layer and MCP interface","Will need mocks for project paths and file operations"]}
{"type":"entity","name":"Memory_Service_Tests","entityType":"Test_Suite","observations":["Should test entity creation with observations","Should test relation creation between entities","Should verify proper ID generation and model validation","Should test deletion cascading","Should test search functionality","Must verify proper serialization of entities and relations"]}
{"type":"entity","name":"MCP_Server_Tests","entityType":"Test_Suite","observations":["Should test project initialization workflow","Should test prompt handling","Should verify tool input/output formats","Should test error cases and validation","Must verify proper serialization in tool responses"]}
{"type":"entity","name":"Memory_Service_Refactoring","entityType":"Technical_Task","observations":["MemoryService uses create() but EntityService might expect create_entity()","MemoryService assumes get_by_name() but EntityService might use different method","Need to verify deletion method signatures","Need to check if search interface matches","Should verify observation handling matches ObservationService interface","RelationService methods need verification","EntityService.create_entity takes name, type, and optional observations directly, not an Entity object","EntityService requires project_path and entity_repo in constructor","ObservationService.add_observation takes Entity object and content string, not raw data","RelationService.create_relation takes Entity objects directly, not dict data","All services follow filesystem-as-source-of-truth pattern with DB indexing","All services handle database synchronization internally","Services expect Path objects for filesystem operations"]}
{"type":"entity","name":"Service_Interface_Audit","entityType":"Technical_Task","observations":["Need to review all existing service interfaces","Document current method signatures","Map discrepancies between MemoryService assumptions and actual interfaces","Check return types and error handling patterns","Review transaction/atomicity requirements","Method signatures need alignment: create vs create_entity etc","Need to handle DB repositories in service constructors","File operations should use project_path consistently","Need to maintain filesystem-as-source-of-truth pattern","Should handle database synchronization at service level","Error handling should align with existing patterns","Consider making MemoryService handle DB indexing consistently"]}
{"type":"entity","name":"Memory_Service_Patterns","entityType":"Technical_Pattern","observations":["Uses inner async functions to encapsulate operation logic","Leverages list comprehensions with async functions for parallel operations","Each operation follows a consistent pattern: validate, update DB, write file","Inner functions make the code more readable and maintainable","Operations can run in parallel when using list comprehensions with async functions"]}
{"type":"entity","name":"Pydantic_Create_Pattern","entityType":"Technical_Pattern","observations":["Separate Create models match the exact shape of incoming data","Provides clear contract for MCP tool inputs","Handles validation of raw input data","Converts cleanly to domain models via from_create methods","Maintains separation between external API format and internal models","Similar to FastAPI request model pattern","Allows camelCase in API while using snake_case internally"]}
{"type":"entity","name":"Basic_Memory_Business","entityType":"Business_Model","observations":["Core system is open source and free","Local-first, giving users data control","Professional features could be licensed","Enterprise support and customization services","Potential for MCP tool marketplace"]}
{"type":"entity","name":"MCP_Marketplace","entityType":"Business_Concept","observations":["Could host verified MCP tools for different use cases","Tools rated by performance and reliability","Marketplace takes percentage of tool usage fees","Enterprise tool verification and security scanning","Custom tool development services","Integration support for existing tools"]}
{"type":"entity","name":"Persistence_Of_Vision","entityType":"Concept","observations":["Mental model for continuous AI-human interaction","Like cinema: 24fps creates illusion of smooth motion","Basic-memory provides 'frames' of structured knowledge","Current state: Better than flipbook, not yet digital cinema","Goal: Achieve smoother cognitive continuity between interactions","Proposed by Drew as metaphor for AI conversation continuity"]}
{"type":"entity","name":"Conversation_Continuity_Pattern","entityType":"Usage_Pattern","observations":["Use basic-memory entity/relation schema for conversations","Each chat becomes an entity with observations for key points","Relations link to discussed concepts and other chats","Uses zettelkasten format IDs for natural ordering","Can be used as template/recipe for others","Future possibility: Git SHA integration for versioning"]}
{"type":"entity","name":"Usage_Recipes","entityType":"Feature_Concept","observations":["Predefined patterns users can follow or adapt","Could include conversation tracking recipe","Templates for different knowledge management styles","Shows practical applications of the generic schema","Helps users get started with the system"]}
{"type":"entity","name":"Chat_References","entityType":"Technical_Feature","observations":["Uses ref:* syntax to reference previous conversations","Combines reference semantics with pointer symbolism","Format: ref:*{zettelkasten-id}","Allows explicit context loading between chats","Inspired by C++ references and pointers","Provides memory-model-like access to conversation context","Uses ref:// URI format following MCP Resource pattern","Could support multiple reference schemes (chat/entity/concept)","Makes reference semantics explicit and unambiguous","Aligns with standard URI formatting"]}
{"type":"entity","name":"Chat_Reference_Protocol","entityType":"Technical_Specification","observations":["Uses URI format: ref://basic-memory/chat/[id]","Follows MCP Resource pattern: [protocol]://[host]/[path]","Enables explicit context loading between chats","Can support multiple resource types (chat/entity/concept)","Provides standardized way to reference previous conversations","Example: ref://basic-memory/chat/20240307-drew-ab12ef34"]}
{"type":"entity","name":"20240307-chat-reference-protocol","entityType":"conversation","observations":["Developed ref:// URI format for chat references","Added Chat Reference Protocol to prompt instructions","Discussed implementation of chat continuation","Created complete prompt instructions document","Reference format follows MCP Resource pattern","Reviewed and confirmed complete prompt instructions","Ready to test ref://basic-memory/chat/20240307-chat-reference-protocol in new chat"]}
{"type":"entity","name":"20240307-chat-reference-protocol-test","entityType":"conversation","observations":["First implementation test of chat reference protocol","Testing continuation from 20240307-chat-reference-protocol","Focused on practical implementation of ref:// URI format"]}
{"type":"entity","name":"Write_File_Tool_Usage","entityType":"Tool_Usage_Pattern","observations":["Never use placeholders like '# Rest of...' when writing files - must include complete file content","File content must be complete and valid - partial updates will truncate the file","If showing partial changes, should inform human and let them handle the file write","write_file tool replaces entire file contents - cannot do partial updates","Code files especially must be complete and valid to avoid breaking functionality","Always read_file before write_file to understand current state","Using write_file without reading first risks reverting recent changes","Pattern should be: read current state, make modifications, then write if needed","Especially important in collaborative development where files may have been updated"]}
{"type":"entity","name":"Run_Tests_Tool_Request","entityType":"Feature_Request","observations":["Need to add a tool enabling Claude to run tests locally","Would help with direct validation of code changes","Current workaround: Claude has to ask human to run tests","Should support running specific test functions (e.g. pytest tests/test_memory_service.py::test_create_relations)","Would improve iterative development workflow between human and AI"]}
{"type":"entity","name":"SQLAlchemy_Async_Loading_Pattern","entityType":"Technical_Pattern","observations":["Use selectinload() instead of lazy loading when accessing SQLAlchemy relationships in async code","Lazy loading doesn't work with async due to greenlet context requirements","selectinload performs a single efficient query with an IN clause","Pattern used in basic-memory's EntityRepository for loading relations","Documented in find_by_id method with thorough explanation","Alternative approaches: joinedload (single JOIN query) or subqueryload (subquery approach)","Benefits: prevents 'MissingGreenlet' errors, reduces N+1 query problems","Key insight: load all needed relationships upfront in async code","Example use: selectinload(Entity.outgoing_relations)"]}
{"type":"entity","name":"20241207-sqlalchemy-async-pattern","entityType":"conversation","observations":["Fixed SQLAlchemy async relationship loading issues","Implemented selectinload pattern in EntityRepository","Updated find_by_id to eager load relations","Added documentation about the pattern","Created knowledge graph entry about SQLAlchemy async loading","Fixed failing tests by properly loading relations in memory_service","Discussed SQLAlchemy relationship loading best practices"]}
{"type":"entity","name":"20241207-memory-service-relations","entityType":"conversation","observations":["Fixed SQLAlchemy async loading with selectinload pattern","Updated find_by_id in EntityRepository to eager load relations","Discovered create_relations works but returns empty list","Verified relations are being stored correctly in memory.json","Next step: Work on MemoryService.add_observations implementation","Improved understanding of MCP memory storage format through debugging"]}
{"type":"entity","name":"add_observations_implementation_plan","entityType":"technical_plan","observations":["Follow pattern from create_entity and create_relation methods","File operations first (read & write) - filesystem is source of truth","Database updates in parallel","Simplify current implementation","Current flow is:"," - First read entities and create observations"," - Write files in parallel"," - Update DB indexes sequentially","Key tests needed:"," - Adding observations to multiple entities"," - Verifying filesystem state first"," - Verifying database state"," - Error cases for missing entities"," - Error cases for file operations"]}
{"type":"entity","name":"MCP_Reference_Integration","entityType":"feature_idea","observations":["Can be implemented as a Model Context Protocol integration similar to the fetch tool","Would provide structured way to pass chat references to Claude","Could handle ref:// URL format systematically","Integration would fetch context from referenced chats and inject into conversation","Observed from Claude Desktop UI showing MCP integration pattern with fetch tool","Would be more robust than passing references in chat text"]}
{"type":"entity","name":"Project_Priorities","entityType":"roadmap","observations":["P1: Dogfooding basic-memory system instead of JSON memory store","Future: Implement MCP-based reference system"]}
{"type":"entity","name":"great_observation_loading_saga_20241207","entityType":"debugging_session","observations":["Occurred on December 7, 2024 while debugging basic-memory SQLAlchemy relationship loading","Issue: selectinload() wasn't properly loading relationships in async SQLAlchemy context","Tried multiple solutions: explicit joins, manual loading, various SQLAlchemy loading strategies","Final solution: Using session.refresh() with explicit relationship names","Memorable quote: 'The Great Observation Loading Saga'","Key learning: Sometimes the obvious SQLAlchemy patterns need adaptation for async contexts","Solution preserved in basic-memory repository in EntityRepository.find_by_id()"]}
{"type":"entity","name":"basic_memory_implementation_20241208","entityType":"technical_milestone","observations":["Fixed async SQLAlchemy relationship loading issues by using explicit refresh with relationship names","Established pattern of relationship handling belonging in MemoryService not EntityService","Fixed ID generation flow through Pydantic schemas to DB layer","Standardized error handling using EntityNotFoundError","All 32 tests passing with 70% coverage","Core services (Entity, Observation, Relation) working properly","Ready for MCP server implementation","Notable debugging session: The Great Observation Loading Saga - resolved lazy loading issues","Established clear separation between MemoryService orchestration and individual service responsibilities"]}
{"type":"entity","name":"MCP_Dependency_Risk","entityType":"technical_lesson","observations":["Experienced disruption when MCP npm package disappeared - 'leftpad moment'","Need to ensure basic-memory tools are resilient to external dependency issues","Local implementation of MCP server provides better stability than npm packages","Important to maintain control of critical infrastructure components","Validates DIY/local-first philosophy of basic-memory project","Package manager fragility revealed by simple 'npx @modelcontextprotocol/server-memory' failure"]}
{"type":"entity","name":"basic_memory_project_20241208","entityType":"technical_milestone","observations":["Core MCP server implementation completed with tools: create_entities, search_nodes, open_nodes, add_observations, create_relations, delete_entities, delete_observations","ProjectConfig and dependency injection pattern established","Test framework in place with in-memory DB support","Support for both camelCase (MCP) and snake_case (internal) formats","Filesystem remains source of truth with SQLite as index","Two-way sync pattern identified between Claude MCP tools and direct markdown file editing","Ready for Claude Desktop integration testing phase","Next steps identified: passing tests, markdown format definition, file change tracking, real-world testing","Implementation prioritizes local-first principles with filesystem as source of truth"]}
{"type":"entity","name":"basic_memory_mcp_architecture","entityType":"technical_design","observations":["MemoryServer class extends MCP Server with custom handler registration","Uses ProjectConfig for clean dependency injection and configuration","Memory service can be injected for testing","Handlers exposed as instance attributes for testing","Tool schemas leverage existing Pydantic models"]}
{"type":"entity","name":"basic_memory_sync_considerations","entityType":"design_insight","observations":["Need to handle sync between direct markdown file edits and DB index","Watch for file system changes as potential future enhancement","Consider index rebuild patterns on startup","Keep human-friendly markdown format for direct editing"]}
{"type":"entity","name":"mcp_server_learnings","entityType":"developer_insight","observations":["MCP protocol is new and documentation is still evolving","Test patterns are not well established yet in example implementations","Supporting both camelCase and snake_case helps with protocol/internal compatibility","Server.handle_* naming convention is important for handler registration"]}
{"type":"entity","name":"20241208-mcp-tool-refactoring","entityType":"conversation","observations":["Decision to return structured data via EmbeddedResource instead of TextContent string parsing","Plan to create Pydantic result models (CreateEntitiesResult, SearchNodesResult etc)","Will use application/vnd.basic-memory+json as MIME type for our structured data","Currently debugging test issues with add_observations tool","Entity ID vs name resolution needed in add_observations","Goal is to make tools more joyful to use by eliminating string parsing","MCP spec supports EmbeddedResource for structured data returns"]}
{"type":"entity","name":"Basic Memory MCP Server Implementation","entityType":"technical_notes","observations":["Server implements Model Context Protocol using proper structured data responses","Uses EmbeddedResource with custom MIME type 'application/vnd.basic-memory+json'","Clean separation between input validation and handlers via Pydantic models","All tool operations return structured data through create_response helper","Type safety with Literal types for tool names and proper typing for handlers","Handler registry pattern with TOOL_HANDLERS dictionary","Consistent error handling pattern using MCP error codes","Uses Pydantic ConfigDict for proper ORM integration","Tool schemas organized into Input and Response types","Input validation with Annotated types for extra constraints","Response models consistently use from_attributes=True for ORM data","Entity ID generation moved to model validator on EntityBase","Follows principle of making common operations easy and safe"]}
{"type":"relation","from":"Paul","to":"Basic_Machines","relationType":"created_and_maintains"}
{"type":"relation","from":"basic-memory","to":"Basic_Machines","relationType":"is_component_of"}
{"type":"relation","from":"Paul","to":"basic-memory","relationType":"develops"}
{"type":"relation","from":"fileio_module","to":"basic-memory_implementation_patterns","relationType":"implements"}
{"type":"relation","from":"entity_service","to":"basic-memory_implementation_patterns","relationType":"implements"}
{"type":"relation","from":"observation_service","to":"basic-memory_implementation_patterns","relationType":"implements"}
{"type":"relation","from":"fileio_module","to":"basic-memory","relationType":"is_component_of"}
{"type":"relation","from":"entity_service","to":"basic-memory","relationType":"is_component_of"}
{"type":"relation","from":"observation_service","to":"basic-memory","relationType":"is_component_of"}
{"type":"relation","from":"entity_service","to":"fileio_module","relationType":"uses"}
{"type":"relation","from":"observation_service","to":"fileio_module","relationType":"uses"}
{"type":"relation","from":"observation_management","to":"observation_service","relationType":"influences_design_of"}
{"type":"relation","to":"basic-memory","from":"testing_infrastructure","relationType":"supports"}
{"type":"relation","to":"testing_infrastructure","from":"test_categories","relationType":"implements"}
{"type":"relation","to":"basic-memory","from":"completed_work","relationType":"tracks_progress_of"}
{"type":"relation","to":"basic-memory","from":"future_work","relationType":"guides_development_of"}
{"type":"relation","to":"basic-memory","from":"design_decisions","relationType":"shapes_architecture_of"}
{"type":"relation","to":"basic-memory","from":"concurrency_considerations","relationType":"influences_design_of"}
{"type":"relation","to":"future_work","from":"concurrency_considerations","relationType":"informs"}
{"type":"relation","to":"observation_management","from":"design_decisions","relationType":"guides"}
{"type":"relation","to":"testing_infrastructure","from":"completed_work","relationType":"established"}
{"type":"relation","to":"design_decisions","from":"fileio_module","relationType":"implements"}
{"type":"relation","from":"observation_update_approaches","to":"observation_management","relationType":"analyzes"}
{"type":"relation","from":"bulk_update_approach","to":"observation_update_approaches","relationType":"is_option_of"}
{"type":"relation","from":"tracked_observations_approach","to":"observation_update_approaches","relationType":"is_option_of"}
{"type":"relation","from":"diff_based_approach","to":"observation_update_approaches","relationType":"is_option_of"}
{"type":"relation","from":"position_based_approach","to":"observation_update_approaches","relationType":"is_option_of"}
{"type":"relation","from":"tasks_and_progress","to":"basic-memory","relationType":"tracks_status_of"}
{"type":"relation","from":"design_decisions","to":"observation_update_approaches","relationType":"influences"}
{"type":"relation","from":"observation_update_approaches","to":"future_work","relationType":"informs"}
{"type":"relation","to":"basic-memory_implementation_patterns","from":"error_handling_patterns","relationType":"is_part_of"}
{"type":"relation","to":"basic-memory","from":"data_models","relationType":"implements"}
{"type":"relation","to":"basic-memory","from":"markdown_format","relationType":"defines"}
{"type":"relation","to":"basic-memory","from":"test_driven_development","relationType":"guides_development_of"}
{"type":"relation","to":"basic-memory","from":"architecture_evolution","relationType":"describes_development_of"}
{"type":"relation","to":"basic-memory_implementation_patterns","from":"validation_patterns","relationType":"is_part_of"}
{"type":"relation","to":"design_decisions","from":"architecture_evolution","relationType":"informs"}
{"type":"relation","to":"fileio_module","from":"markdown_format","relationType":"implements"}
{"type":"relation","to":"error_handling_patterns","from":"test_driven_development","relationType":"influenced"}
{"type":"relation","to":"data_models","from":"validation_patterns","relationType":"implements"}
{"type":"relation","to":"markdown_format","from":"markdown_examples","relationType":"documents"}
{"type":"relation","to":"markdown_format","from":"markdown_parsing_rules","relationType":"defines"}
{"type":"relation","to":"data_models","from":"schema_definitions","relationType":"documents"}
{"type":"relation","to":"test_driven_development","from":"test_evolution","relationType":"describes"}
{"type":"relation","to":"architecture_evolution","from":"implementation_challenges","relationType":"influenced"}
{"type":"relation","to":"test_evolution","from":"implementation_challenges","relationType":"shaped"}
{"type":"relation","to":"future_work","from":"implementation_challenges","relationType":"informs"}
{"type":"relation","from":"Basic_Factory","to":"Basic_Machines","relationType":"implements"}
{"type":"relation","from":"Basic_Factory_Components","to":"Basic_Factory","relationType":"is_part_of"}
{"type":"relation","from":"Component_Translation_Process","to":"Basic_Factory_Components","relationType":"enables"}
{"type":"relation","from":"Basic_Machines_Philosophy","to":"Basic_Machines","relationType":"guides"}
{"type":"relation","from":"Paul","to":"Basic_Factory","relationType":"develops"}
{"type":"relation","from":"Paul","to":"Basic_Machines_Philosophy","relationType":"created"}
{"type":"relation","from":"Basic_Machines_Manifesto","to":"Basic_Machines_Philosophy","relationType":"articulates"}
{"type":"relation","from":"AI_Human_Collaboration_Model","to":"Basic_Factory","relationType":"guides_development_of"}
{"type":"relation","from":"Basic_Machines_Manifesto","to":"Paul","relationType":"written_by"}
{"type":"relation","from":"Basic_Machines_Manifesto","to":"Component_Translation_Process","relationType":"documents"}
{"type":"relation","from":"AI_Human_Collaboration_Model","to":"Basic_Machines","relationType":"shapes_development_of"}
{"type":"relation","from":"Basic_Machines_Roadmap","to":"Basic_Machines","relationType":"guides_development_of"}
{"type":"relation","from":"Basic_Machines_Website","to":"Basic_Machines_Roadmap","relationType":"implements_phase_of"}
{"type":"relation","from":"Basic_Factory_Components","to":"Basic_Machines_Website","relationType":"enables"}
{"type":"relation","from":"Basic_Machines_Philosophy","to":"Basic_Machines_Website","relationType":"informs"}
{"type":"relation","from":"Paul","to":"DIY_Ethics","relationType":"embodies"}
{"type":"relation","from":"Basic_Machines_Philosophy","to":"DIY_Ethics","relationType":"incorporates"}
{"type":"relation","from":"Basic_Machines","to":"DIY_Ethics","relationType":"exemplifies"}
{"type":"relation","from":"Component_Translation_Process","to":"Basic_Machines_Philosophy","relationType":"implements"}
{"type":"relation","from":"Basic_Factory_Components","to":"DIY_Ethics","relationType":"demonstrates"}
{"type":"relation","from":"AI_Human_Collaboration_Model","to":"Basic_Machines_Philosophy","relationType":"aligns_with"}
{"type":"relation","from":"AI_Human_Collaboration_Model","to":"Component_Translation_Process","relationType":"guides"}
{"type":"relation","from":"Basic_Machines_Manifesto","to":"Basic_Machines","relationType":"defines_vision_for"}
{"type":"relation","from":"Basic_Machines_Website","to":"Basic_Machines_Manifesto","relationType":"implements_vision_of"}
{"type":"relation","from":"Basic_Factory","to":"AI_Human_Collaboration_Model","relationType":"demonstrates"}
{"type":"relation","from":"Paul","to":"AI_Human_Collaboration_Model","relationType":"developed_with_Claude"}
{"type":"relation","from":"Basic_Factory_Components","to":"Component_Translation_Process","relationType":"created_through"}
{"type":"relation","from":"Basic_Machines_Philosophy","to":"Basic_Factory","relationType":"guides"}
{"type":"relation","from":"Basic_Factory","to":"MCP_Tools","relationType":"integrates"}
{"type":"relation","from":"Basic_Machines_Website","to":"Basic_Factory_Components","relationType":"will_use"}
{"type":"relation","from":"Basic_Machines_Roadmap","to":"Basic_Machines_Philosophy","relationType":"aligns_with"}
{"type":"relation","from":"Component_Translation_Process","to":"MCP_Tools","relationType":"leverages"}
{"type":"relation","from":"Basic_Factory","to":"basic-memory","relationType":"will_document_process_in"}
{"type":"relation","from":"AI_Human_Collaboration_Model","to":"basic-memory","relationType":"will_be_implemented_in"}
{"type":"relation","from":"Basic_Machines_Philosophy","to":"Basic_Machines_Roadmap","relationType":"informs_priorities_of"}
{"type":"relation","from":"basic-memory","to":"Basic_Machines_Philosophy","relationType":"embodies"}
{"type":"relation","from":"Paul","to":"Basic_Machines_Manifesto","relationType":"authored_with_Claude"}
{"type":"relation","from":"Basic_Factory","to":"Component_Translation_Process","relationType":"validated"}
{"type":"relation","from":"AI_Human_Collaboration_Model","to":"MCP_Tools","relationType":"utilizes"}
{"type":"relation","from":"Basic_Factory_Components","to":"Basic_Machines_Roadmap","relationType":"supports"}
{"type":"relation","from":"Basic_Machines_Website","to":"Basic_Factory","relationType":"will_demonstrate"}
{"type":"relation","from":"Basic_Factory","to":"basic-memory-webui","relationType":"enables_development_of"}
{"type":"relation","from":"basic-memory","to":"AI_Human_Development_Methodology","relationType":"implements"}
{"type":"relation","from":"Basic_Machines_Philosophy","to":"AI_Human_Development_Methodology","relationType":"guides"}
{"type":"relation","from":"Basic_Factory_Components","to":"basic-memory-webui","relationType":"provides_ui_for"}
{"type":"relation","from":"Component_Translation_Process","to":"AI_Human_Development_Methodology","relationType":"exemplifies"}
{"type":"relation","from":"Basic_Factory","to":"Basic Components","relationType":"enabled_creation_of"}
{"type":"relation","from":"Basic_Factory","to":"Tool Integration Discovery","relationType":"led_to"}
{"type":"relation","from":"MCP_Integration_Progress","to":"AI_Human_Development_Methodology","relationType":"validates"}
{"type":"relation","from":"Basic_Factory","to":"MCP_Integration_Progress","relationType":"demonstrates"}
{"type":"relation","from":"Basic_Factory","to":"AI_Human_Development_Methodology","relationType":"proves_effectiveness_of"}
{"type":"relation","from":"Basic_Machines_Philosophy","to":"Basic Components","relationType":"inspires_architecture_of"}
{"type":"relation","from":"DIY_Ethics","to":"basic-memory","relationType":"shapes_design_of"}
{"type":"relation","from":"Basic_Machines_Philosophy","to":"Tool Integration Discovery","relationType":"guides_analysis_of"}
{"type":"relation","from":"Basic_Machines_Manifesto","to":"AI_Human_Development_Methodology","relationType":"documents_approach_of"}
{"type":"relation","from":"Basic_Machines_Manifesto","to":"Basic_Factory_Components","relationType":"explains_principles_of"}
{"type":"relation","from":"Basic_Memory_Project_Structure","to":"basic-memory","relationType":"organizes"}
{"type":"relation","from":"Basic_Memory_Database_Schema","to":"basic-memory","relationType":"defines_storage_for"}
{"type":"relation","from":"Basic_Memory_Markdown_Example","to":"Basic_Memory_File_Format","relationType":"demonstrates"}
{"type":"relation","from":"Basic_Memory_Project_Isolation_Decision","to":"Basic_Memory_Future_Enhancement_Weighted_Relations","relationType":"similar_to"}
{"type":"relation","to":"DIY_Ethics","from":"Basic_Memory_Project_Isolation_Decision","relationType":"follows"}
{"type":"relation","from":"Basic_Memory_Implementation_Plan","to":"basic-memory","relationType":"guides"}
{"type":"relation","from":"Basic_Memory_Implementation_Plan","to":"DIY_Ethics","relationType":"follows"}
{"type":"relation","from":"Basic_Memory_Implementation_Plan","to":"Basic_Memory_Database_Schema","relationType":"implements"}
{"type":"relation","from":"Basic_Memory_Implementation_Status","to":"Basic_Memory_Implementation_Plan","relationType":"updates"}
{"type":"relation","from":"Basic_Memory_Observation_Management_Design","to":"Basic_Memory_Technical_Design","relationType":"extends"}
{"type":"relation","from":"Basic_Memory_Architectural_Decisions","to":"DIY_Ethics","relationType":"guided_by"}
{"type":"relation","from":"Basic_Memory_Architectural_Decisions","to":"basic-memory","relationType":"structures"}
{"type":"relation","from":"Basic_Memory_Implementation_Status","to":"basic-memory","relationType":"describes_state_of"}
{"type":"relation","to":"Basic_Memory_Implementation_Status","from":"Basic_Memory_Implementation_Analysis","relationType":"analyzes"}
{"type":"relation","to":"basic-memory","from":"Basic_Memory_Current_Challenges","relationType":"identifies_issues_in"}
{"type":"relation","to":"DIY_Ethics","from":"Basic_Memory_Implementation_Analysis","relationType":"confirms_alignment_with"}
{"type":"relation","to":"Basic_Memory_Observation_Management_Design","from":"Basic_Memory_Observation_Hash_Tracking","relationType":"solves"}
{"type":"relation","to":"DIY_Ethics","from":"Basic_Memory_Observation_Hash_Tracking","relationType":"aligns_with"}
{"type":"relation","to":"Basic_Memory_File_Format","from":"Basic_Memory_Observation_Hash_Tracking","relationType":"preserves"}
{"type":"relation","to":"Basic_Memory_Technical_Design","from":"Basic_Memory_Observation_Hash_Tracking","relationType":"enhances"}
{"type":"relation","from":"Basic_Memory_Repository_Implementation","to":"basic-memory","relationType":"implements_part_of"}
{"type":"relation","from":"Basic_Memory_Repository_Implementation","to":"Basic_Memory_Database_Schema","relationType":"follows"}
{"type":"relation","from":"Basic_Memory_Repository_Implementation","to":"DIY_Ethics","relationType":"aligns_with"}
{"type":"relation","from":"Basic_Memory_Repository_Implementation","to":"testing_infrastructure","relationType":"demonstrates"}
{"type":"relation","from":"Basic_Memory_Repository_Implementation","to":"Basic Foundation","relationType":"inspired_by"}
{"type":"relation","from":"Basic_Memory_Dependencies","to":"basic-memory","relationType":"supports"}
{"type":"relation","from":"Basic_Memory_Dependencies","to":"Basic_Memory_Repository_Implementation","relationType":"enables"}
{"type":"relation","from":"Basic_Memory_Dependencies","to":"testing_infrastructure","relationType":"enables"}
{"type":"relation","from":"Basic_Memory_Current_Architecture","to":"basic-memory","relationType":"describes_state_of"}
{"type":"relation","from":"Basic_Memory_Evolution","to":"Basic_Memory_Current_Architecture","relationType":"explains_development_of"}
{"type":"relation","from":"Basic_Memory_Service_Layer","to":"Basic_Memory_Current_Architecture","relationType":"implements"}
{"type":"relation","from":"Basic_Memory_Schema_Design","to":"Basic_Memory_Current_Architecture","relationType":"implements"}
{"type":"relation","from":"Basic_Memory_Evolution","to":"Basic_Memory_Implementation_Plan","relationType":"reflects_on"}
{"type":"relation","from":"Basic_Memory_Evolution","to":"DIY_Ethics","relationType":"demonstrates_alignment_with"}
{"type":"relation","from":"Basic_Memory_Current_Architecture","to":"DIY_Ethics","relationType":"embodies"}
{"type":"relation","from":"Basic_Memory_Service_Layer","to":"fileio_module","relationType":"uses"}
{"type":"relation","from":"Basic_Memory_Schema_Design","to":"markdown_format","relationType":"implements"}
{"type":"relation","to":"basic-memory","from":"Basic_Memory_Next_Tasks","relationType":"guides_development_of"}
{"type":"relation","to":"DIY_Ethics","from":"Basic_Memory_Next_Tasks","relationType":"aligns_with"}
{"type":"relation","to":"Basic_Memory_Current_Architecture","from":"Basic_Memory_Next_Tasks","relationType":"extends"}
{"type":"relation","from":"Basic_Memory_Meta_Experience","to":"basic-memory","relationType":"validates_design_of"}
{"type":"relation","from":"Basic_Memory_Meta_Experience","to":"DIY_Ethics","relationType":"demonstrates_principles_of"}
{"type":"relation","from":"Basic_Memory_Meta_Experience","to":"design_decisions","relationType":"reinforces"}
{"type":"relation","from":"Basic_Memory_Meta_Experience","to":"Basic_Memory_Current_Architecture","relationType":"validates"}
{"type":"relation","from":"Model_Context_Protocol","to":"basic-memory","relationType":"enables"}
{"type":"relation","from":"basic-memory_core_principles","to":"basic-memory","relationType":"guides"}
{"type":"relation","from":"basic-memory_core_principles","to":"DIY_Ethics","relationType":"aligns_with"}
{"type":"relation","from":"basic-memory_business_model","to":"basic-memory","relationType":"defines_sustainability_for"}
{"type":"relation","from":"basic-memory_business_model","to":"DIY_Ethics","relationType":"maintains_alignment_with"}
{"type":"relation","from":"basic-memory_cli","to":"basic-memory","relationType":"provides_interface_for"}
{"type":"relation","from":"basic-memory_cli","to":"Model_Context_Protocol","relationType":"integrates_with"}
{"type":"relation","from":"basic-memory_export_format","to":"basic-memory","relationType":"standardizes_output_of"}
{"type":"relation","from":"basic-memory_export_format","to":"markdown_format","relationType":"extends"}
{"type":"relation","from":"basic-memory_core_principles","to":"Basic_Machines_Philosophy","relationType":"implements"}
{"type":"relation","from":"Model_Context_Protocol","to":"AI_Human_Collaboration_Model","relationType":"enables"}
{"type":"relation","from":"relation_service","to":"basic-memory","relationType":"will_be_component_of"}
{"type":"relation","from":"relation_service","to":"service_layer_patterns","relationType":"follows"}
{"type":"relation","from":"relation_service","to":"fileio_patterns","relationType":"uses"}
{"type":"relation","from":"relation_service","to":"database_models","relationType":"uses"}
{"type":"relation","from":"relation_service","to":"repository_patterns","relationType":"implements"}
{"type":"relation","from":"relation_service_design","to":"relation_service","relationType":"guides_implementation_of"}
{"type":"relation","from":"relation_service_implementation_plan","to":"relation_service","relationType":"defines_implementation_of"}
{"type":"relation","from":"relation_service_challenges","to":"relation_service_design","relationType":"informs"}
{"type":"relation","from":"relation_file_format","to":"markdown_format","relationType":"extends"}
{"type":"relation","from":"relation_service_error_handling","to":"service_layer_patterns","relationType":"implements"}
{"type":"relation","from":"relation_service_testing","to":"testing_infrastructure","relationType":"extends"}
{"type":"relation","from":"fileio_patterns","to":"service_layer_patterns","relationType":"enables"}
{"type":"relation","from":"database_models","to":"repository_patterns","relationType":"enables"}
{"type":"relation","from":"relation_service","to":"entity_service","relationType":"coordinates_with"}
{"type":"relation","from":"relation_file_format","to":"relation_service","relationType":"defines_storage_for"}
{"type":"relation","from":"relation_service_error_handling","to":"relation_service","relationType":"ensures_reliability_of"}
{"type":"relation","from":"relation_service_testing","to":"relation_service","relationType":"verifies"}
{"type":"relation","from":"service_layer_patterns","to":"basic-memory_implementation_patterns","relationType":"implements"}
{"type":"relation","from":"repository_patterns","to":"basic-memory_implementation_patterns","relationType":"implements"}
{"type":"relation","from":"fileio_patterns","to":"basic-memory_implementation_patterns","relationType":"implements"}
{"type":"relation","from":"database_models","to":"basic-memory_implementation_patterns","relationType":"implements"}
{"type":"relation","from":"relation_service_challenges","to":"implementation_challenges","relationType":"extends"}
{"type":"relation","from":"relation_service_implementation_plan","to":"future_work","relationType":"details"}
{"type":"relation","from":"relation_service_design","to":"design_decisions","relationType":"aligns_with"}
{"type":"relation","from":"relation_file_format","to":"design_decisions","relationType":"follows"}
{"type":"relation","from":"pytest_patterns","to":"testing_infrastructure","relationType":"extends"}
{"type":"relation","from":"relation_implementation_learnings","to":"basic-memory_implementation_patterns","relationType":"informs"}
{"type":"relation","from":"test_driven_insights","to":"test_driven_development","relationType":"enriches"}
{"type":"relation","from":"meta_development_insights","to":"AI_Human_Collaboration_Model","relationType":"improves"}
{"type":"relation","from":"relation_implementation_learnings","to":"relation_service","relationType":"guides_implementation_of"}
{"type":"relation","from":"pytest_patterns","to":"test_evolution","relationType":"demonstrates"}
{"type":"relation","from":"test_driven_insights","to":"design_decisions","relationType":"influences"}
{"type":"relation","from":"meta_development_insights","to":"architecture_evolution","relationType":"informs"}
{"type":"relation","from":"relation_service","to":"relation_implementation_learnings","relationType":"validates"}
{"type":"relation","from":"test_driven_insights","to":"implementation_challenges","relationType":"helps_solve"}
{"type":"relation","from":"AI_Assistant_Learnings","to":"meta_development_insights","relationType":"enriches"}
{"type":"relation","from":"Effective_Response_Patterns","to":"AI_Assistant_Learnings","relationType":"implements"}
{"type":"relation","from":"AI_Context_Management","to":"AI_Human_Collaboration_Model","relationType":"improves"}
{"type":"relation","from":"AI_Tool_Usage_Patterns","to":"AI_Context_Management","relationType":"enables"}
{"type":"relation","from":"AI_Assistant_Learnings","to":"Basic_Memory_Meta_Experience","relationType":"validates"}
{"type":"relation","from":"AI_Tool_Usage_Patterns","to":"Model_Context_Protocol","relationType":"demonstrates_effective_use_of"}
{"type":"relation","from":"AI_Context_Management","to":"basic-memory","relationType":"validates_design_of"}
{"type":"relation","from":"Effective_Response_Patterns","to":"AI_Human_Development_Methodology","relationType":"refines"}
{"type":"relation","to":"relation_service","from":"relation_service_patterns","relationType":"guides"}
{"type":"relation","to":"test_driven_development","from":"test_driven_insights_relations","relationType":"enriches"}
{"type":"relation","to":"implementation_challenges","from":"relation_service_learnings","relationType":"solves"}
{"type":"relation","to":"basic-memory_implementation_patterns","from":"relation_service_patterns","relationType":"implements"}
{"type":"relation","to":"markdown_format","from":"relation_service_patterns","relationType":"extends"}
{"type":"relation","to":"service_layer_patterns","from":"relation_service_patterns","relationType":"refines"}
{"type":"relation","from":"packaging_learnings","to":"implementation_challenges","relationType":"informs"}
{"type":"relation","from":"packaging_learnings","to":"test_driven_development","relationType":"impacts"}
{"type":"relation","to":"basic-memory","from":"Recent_Implementation_Progress","relationType":"updates_status_of"}
{"type":"relation","to":"future_work","from":"Next_Steps","relationType":"extends"}
{"type":"relation","to":"design_decisions","from":"Development_Practices","relationType":"informs"}
{"type":"relation","to":"packaging_learnings","from":"Development_Practices","relationType":"incorporates"}
{"type":"relation","to":"test_driven_development","from":"Development_Practices","relationType":"refines"}
{"type":"relation","to":"basic-memory_implementation_patterns","from":"Development_Practices","relationType":"enhances"}
{"type":"relation","from":"Basic_Memory_MCP","to":"MCP_Server_Implementation","relationType":"follows"}
{"type":"relation","from":"Basic_Memory_MCP","to":"MCP_Tools","relationType":"uses"}
{"type":"relation","from":"Basic_Memory","to":"MCP_Server_Implementation","relationType":"implements"}
{"type":"relation","from":"Basic_Memory_Testing","to":"Memory_Service_Tests","relationType":"includes"}
{"type":"relation","from":"Basic_Memory_Testing","to":"MCP_Server_Tests","relationType":"includes"}
{"type":"relation","from":"Memory_Service_Tests","to":"Basic_Memory_MCP","relationType":"validates"}
{"type":"relation","from":"MCP_Server_Tests","to":"Basic_Memory_MCP","relationType":"validates"}
{"type":"relation","from":"Service_Interface_Audit","to":"Memory_Service_Refactoring","relationType":"informs"}
{"type":"relation","from":"Memory_Service_Refactoring","to":"Basic_Memory_MCP","relationType":"affects"}
{"type":"relation","from":"Memory_Service_Patterns","to":"Basic_Memory_MCP","relationType":"improves"}
{"type":"relation","from":"Pydantic_Create_Pattern","to":"Memory_Service_Patterns","relationType":"enables"}
{"type":"relation","from":"Pydantic_Create_Pattern","to":"Basic_Memory_MCP","relationType":"improves"}
{"type":"relation","from":"MCP_Marketplace","to":"Basic_Memory_Business","relationType":"enables"}
{"type":"relation","from":"Basic_Memory","to":"MCP_Marketplace","relationType":"could_integrate_with"}
{"type":"relation","from":"Persistence_Of_Vision","to":"Basic_Memory","relationType":"helps_achieve"}
{"type":"relation","from":"Drew","to":"Persistence_Of_Vision","relationType":"conceptualized"}
{"type":"relation","to":"Usage_Recipes","from":"Conversation_Continuity_Pattern","relationType":"is_example_of"}
{"type":"relation","to":"Basic_Memory","from":"Usage_Recipes","relationType":"enhances"}
{"type":"relation","to":"Basic_Memory","from":"Chat_References","relationType":"enhances"}
{"type":"relation","to":"Conversation_Continuity_Pattern","from":"Chat_References","relationType":"implements"}
{"type":"relation","from":"20240307-chat-reference-protocol-test","to":"20240307-chat-reference-protocol","relationType":"continues_from"}
{"type":"relation","from_id":"Run_Tests_Tool_Request","to_id":"Basic_Machines","relationType":"enhances","context":"development workflow improvement"}
{"type":"relation","from_id":"SQLAlchemy_Async_Loading_Pattern","to_id":"basic-memory","relation_type":"improves","context":"database performance and async compatibility"}
{"type":"relation","from_id":"SQLAlchemy_Async_Loading_Pattern","to_id":"Entity","relation_type":"applies_to","context":"relationship loading strategy"}
{"type":"relation","from":"MCP_Reference_Integration","to":"Project_Priorities","relationType":"prioritized_after"}
{"type":"relation","from":"great_observation_loading_saga_20241207","to":"Basic_Memory","relationType":"occurred_in"}
{"type":"relation","from":"great_observation_loading_saga_20241207","to":"SQLAlchemy","relationType":"relates_to"}
{"type":"relation","from":"basic_memory_implementation_20241208","to":"Basic_Memory","relationType":"improves"}
{"type":"relation","from":"great_observation_loading_saga_20241207","to":"basic_memory_implementation_20241208","relationType":"leads_to"}
{"type":"relation","from":"MCP_Dependency_Risk","to":"DIY_Ethics","relationType":"validates"}
{"type":"relation","from":"MCP_Dependency_Risk","to":"basic-memory_core_principles","relationType":"reinforces"}
{"type":"relation","from":"MCP_Dependency_Risk","to":"Basic_Memory_Implementation_Plan","relationType":"influences"}
{"type":"relation","from":"basic_memory_mcp_architecture","to":"basic_memory_project_20241208","relationType":"implements"}
{"type":"relation","from":"basic_memory_sync_considerations","to":"basic_memory_project_20241208","relationType":"influences"}
{"type":"relation","from":"mcp_server_learnings","to":"basic_memory_mcp_architecture","relationType":"informs"}
{"type":"relation","from":"20241208-mcp-tool-refactoring","to":"Basic_Memory_MCP","relationType":"improves"}
{"type":"relation","from":"20241208-mcp-tool-refactoring","to":"Basic_Memory_Implementation_Plan","relationType":"implements"}
+94
View File
@@ -0,0 +1,94 @@
[project]
name = "basic-memory"
version = "0.0.0"
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
readme = "README.md"
requires-python = ">=3.12.1"
license = { text = "AGPL-3.0-or-later" }
authors = [
{ name = "Basic Machines", email = "hello@basic-machines.co" }
]
dependencies = [
"sqlalchemy>=2.0.0",
"pyyaml>=6.0.1",
"typer>=0.9.0",
"aiosqlite>=0.20.0",
"greenlet>=3.1.1",
"pydantic[email,timezone]>=2.10.3",
"icecream>=2.1.3",
"mcp>=1.2.0",
"pydantic-settings>=2.6.1",
"loguru>=0.7.3",
"pyright>=1.1.390",
"basic-foundation",
"markdown-it-py>=3.0.0",
"python-frontmatter>=1.1.0",
"rich>=13.9.4",
"unidecode>=1.3.8",
"dateparser>=1.2.0",
"watchfiles>=1.0.4",
]
[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"
Repository = "https://github.com/basicmachines-co/basic-memory"
Documentation = "https://github.com/basicmachines-co/basic-memory#readme"
[project.scripts]
basic-memory = "basic_memory.cli.main:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
pythonpath = ["src", "tests"]
addopts = "--cov=basic_memory --cov-report term-missing -ra -q"
testpaths = ["tests"]
asyncio_mode = "strict"
asyncio_default_fixture_loop_scope = "function"
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.uv]
dev-dependencies = [
"icecream>=2.1.3",
]
[tool.uv.sources]
basic-foundation = { path = "../basic-foundation", editable = true }
[tool.pyright]
include = ["src/"]
exclude = ["**/__pycache__"]
ignore = ["test/"]
defineConstant = { DEBUG = true }
reportMissingImports = "error"
reportMissingTypeStubs = false
pythonVersion = "3.12"
[tool.semantic_release]
version_variable = "src/basic_memory/__init__.py:__version__"
version_toml = [
"pyproject.toml:project.version",
]
major_on_zero = false
branch = "main"
changelog_file = "CHANGELOG.md"
build_command = "pip install uv && uv build"
dist_path = "dist/"
upload_to_pypi = true
commit_message = "chore(release): {version} [skip ci]"
+3
View File
@@ -0,0 +1,3 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.0.1"
+4
View File
@@ -0,0 +1,4 @@
"""Basic Memory API module."""
from .app import app
__all__ = ["app"]
+42
View File
@@ -0,0 +1,42 @@
"""FastAPI application for basic-memory knowledge graph API."""
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.exception_handlers import http_exception_handler
from loguru import logger
from basic_memory import db
from .routers import knowledge, search, memory, resource
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifecycle manager for the FastAPI app."""
logger.info("Starting Basic Memory API")
yield
logger.info("Shutting down Basic Memory API")
await db.shutdown_db()
# Initialize FastAPI app
app = FastAPI(
title="Basic Memory API",
description="Knowledge graph API for basic-memory",
version="0.1.0",
lifespan=lifespan,
)
# Include routers
app.include_router(knowledge.router)
app.include_router(search.router)
app.include_router(memory.router)
app.include_router(resource.router)
@app.exception_handler(Exception)
async def exception_handler(request, exc):
logger.exception(
f"An unhandled exception occurred for request '{request.url}', exception: {exc}"
)
return await http_exception_handler(request, HTTPException(status_code=500, detail=str(exc)))
+8
View File
@@ -0,0 +1,8 @@
"""API routers."""
from . import knowledge_router as knowledge
from . import memory_router as memory
from . import resource_router as resource
from . import search_router as search
__all__ = ["knowledge", "memory", "resource", "search"]
@@ -0,0 +1,168 @@
"""Router for knowledge graph operations."""
from typing import Annotated
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Query, Response
from loguru import logger
from basic_memory.deps import (
EntityServiceDep,
get_search_service,
SearchServiceDep,
LinkResolverDep,
)
from basic_memory.schemas import (
EntityListResponse,
EntityResponse,
DeleteEntitiesResponse,
DeleteEntitiesRequest,
)
from basic_memory.schemas.base import PathId, Entity
from basic_memory.services.exceptions import EntityNotFoundError
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
## Create endpoints
@router.post("/entities", response_model=EntityResponse)
async def create_entity(
data: Entity,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service: SearchServiceDep,
) -> EntityResponse:
"""Create an entity."""
logger.info(f"request: create_entity with data={data}")
entity = await entity_service.create_entity(data)
# reindex
await search_service.index_entity(entity, background_tasks=background_tasks)
result = EntityResponse.model_validate(entity)
logger.info(f"response: create_entity with result={result}")
return result
@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,
) -> 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}")
# Validate permalink matches
if data.permalink != permalink:
raise HTTPException(status_code=400, detail="Entity permalink must match URL path")
# Try create_or_update operation
entity, created = await entity_service.create_or_update_entity(data)
response.status_code = 201 if created else 200
# reindex
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}")
return result
## Read endpoints
@router.get("/entities/{permalink:path}", response_model=EntityResponse)
async def get_entity(
entity_service: EntityServiceDep,
permalink: str,
) -> EntityResponse:
"""Get a specific entity by ID.
Args:
permalink: Entity path ID
content: If True, include full file content
:param entity_service: EntityService
"""
logger.info(f"request: get_entity with permalink={permalink}")
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,
) -> EntityListResponse:
"""Open specific entities"""
logger.info(f"request: get_entities with permalinks={permalink}")
entities = await entity_service.get_entities_by_permalinks(permalink)
result = EntityListResponse(
entities=[EntityResponse.model_validate(entity) for entity in entities]
)
logger.info(f"response: get_entities with result={result}")
return result
## Delete endpoints
@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),
) -> 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)
# 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),
) -> DeleteEntitiesResponse:
"""Delete entities and remove from search index."""
logger.info(f"request: delete_entities with data={data}")
deleted = False
# Remove each deleted entity from search index
for permalink in data.permalinks:
deleted = await entity_service.delete_entity(permalink)
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
@@ -0,0 +1,123 @@
"""Routes for memory:// URI operations."""
from datetime import datetime, timedelta
from typing import Optional, List, 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
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
RelationSummary,
EntitySummary,
ObservationSummary,
MemoryMetadata, normalize_memory_url,
)
from basic_memory.schemas.search import SearchItemType
from basic_memory.services.context_service import ContextResultRow
router = APIRouter(prefix="/memory", tags=["memory"])
async def to_graph_context(context, entity_repository: EntityRepository):
# return results
async def to_summary(item: SearchIndexRow | ContextResultRow):
match item.type:
case SearchItemType.ENTITY:
return EntitySummary(
title=item.title,
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
)
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)
return RelationSummary(
permalink=item.permalink,
type=item.type,
from_id=from_entity.permalink,
to_id=to_entity.permalink if to_entity else None,
)
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
)
@router.get("/recent", response_model=GraphContext)
async def recent(
context_service: ContextServiceDep,
entity_repository: EntityRepositoryDep,
type: Annotated[list[SearchItemType] | None, Query()] = None,
depth: int = 1,
timeframe: TimeFrame = "7d",
max_results: int = 10,
) -> GraphContext:
# return all types by default
types = (
[SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
if not type
else type
)
logger.debug(
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
)
# Parse timeframe
since = parse(timeframe)
# Build context
context = await context_service.build_context(
types=types, depth=depth, since=since, max_results=max_results
)
return await to_graph_context(context, entity_repository=entity_repository)
# 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(
context_service: ContextServiceDep,
entity_repository: EntityRepositoryDep,
uri: str,
depth: int = 1,
timeframe: TimeFrame = "7d",
max_results: 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}`"
)
memory_url = normalize_memory_url(uri)
# Parse timeframe
since = parse(timeframe)
# Build context
context = await context_service.build_context(
memory_url, depth=depth, since=since, max_results=max_results
)
return await to_graph_context(context, entity_repository=entity_repository)
@@ -0,0 +1,34 @@
"""Routes for getting entity content."""
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from loguru import logger
from basic_memory.deps import ProjectConfigDep, LinkResolverDep
router = APIRouter(prefix="/resource", tags=["resources"])
@router.get("/{identifier:path}")
async def get_resource_content(
config: ProjectConfigDep,
link_resolver: LinkResolverDep,
identifier: str,
) -> FileResponse:
"""Get resource content by identifier: name or permalink."""
logger.debug(f"Getting content for permalink: {identifier}")
# Find entity by permalink
entity = await link_resolver.resolve_link(identifier)
if not entity:
raise HTTPException(status_code=404, detail=f"Entity not found: {identifier}")
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)
@@ -0,0 +1,34 @@
"""Router for search operations."""
from dataclasses import asdict
from fastapi import APIRouter, Depends, BackgroundTasks
from typing import List
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
router = APIRouter(prefix="/search", tags=["search"])
@router.post("/", response_model=SearchResponse)
async def search(
query: SearchQuery,
search_service: SearchService = Depends(get_search_service)
):
"""Search across all knowledge and documents."""
results = await search_service.search(query)
search_results = [SearchResult.model_validate(asdict(r)) for r in results]
return SearchResponse(results=search_results)
@router.post("/reindex")
async def reindex(
background_tasks: BackgroundTasks,
search_service: SearchService = Depends(get_search_service)
):
"""Recreate and populate the search index."""
await search_service.reindex_all(background_tasks=background_tasks)
return {
"status": "ok",
"message": "Reindex initiated"
}
+1
View File
@@ -0,0 +1 @@
"""CLI tools for basic-memory"""
+4
View File
@@ -0,0 +1,4 @@
import typer
app = typer.Typer()
@@ -0,0 +1,9 @@
"""CLI commands package."""
from . import init, status
__all__ = [
"init",
"status",
]
+38
View File
@@ -0,0 +1,38 @@
"""Initialize command for basic-memory CLI."""
import asyncio
from pathlib import Path
import typer
from loguru import logger
from basic_memory.cli.app import app
from basic_memory.db import engine_session_factory, DatabaseType
from basic_memory.config import config
async def _init(force: bool = False):
"""Initialize the database."""
db_path = config.database_path
if db_path.exists() and not force:
typer.echo(f"Database already exists at {db_path}. Use --force to reinitialize.")
raise typer.Exit(1)
# Create data directory if needed
db_path.parent.mkdir(parents=True, exist_ok=True)
try:
async with engine_session_factory(db_path, db_type=DatabaseType.FILESYSTEM, init=True):
typer.echo(f"Initialized database at {db_path}")
except Exception as e:
logger.error(f"Error initializing database: {e}")
typer.echo(f"Error initializing database: {e}")
raise typer.Exit(1)
@app.command()
def init(
force: bool = typer.Option(False, "--force", "-f", help="Force reinitialization if database exists")
):
"""Initialize a new basic-memory database."""
asyncio.run(_init(force))
+152
View File
@@ -0,0 +1,152 @@
"""Status command for basic-memory CLI."""
import asyncio
from typing import Set, Dict
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.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
# 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):
"""Add files to tree, grouped by directory."""
# Group by directory
by_dir = {}
for path in sorted(paths):
parts = path.split("/", 1)
dir_name = parts[0] if len(parts) > 1 else ""
file_name = parts[1] if len(parts) > 1 else parts[0]
by_dir.setdefault(dir_name, []).append((file_name, path))
# Add to tree
for dir_name, files in sorted(by_dir.items()):
if dir_name:
branch = tree.add(f"[bold]{dir_name}/[/bold]")
else:
branch = tree
for file_name, full_path in sorted(files):
if checksums and full_path in checksums:
checksum_short = checksums[full_path][:8]
branch.add(f"[{style}]{file_name}[/{style}] ({checksum_short})")
else:
branch.add(f"[{style}]{file_name}[/{style}]")
def group_changes_by_directory(changes: SyncReport) -> Dict[str, Dict[str, int]]:
"""Group changes by directory for summary view."""
by_dir = {}
for change_type, paths in [
("new", changes.new),
("modified", changes.modified),
("deleted", changes.deleted),
]:
for path in paths:
dir_name = path.split("/", 1)[0]
by_dir.setdefault(dir_name, {"new": 0, "modified": 0, "deleted": 0, "moved": 0})
by_dir[dir_name][change_type] += 1
# Handle moves - count in both source and destination directories
for old_path, new_path in changes.moves.items():
old_dir = old_path.split("/", 1)[0]
new_dir = new_path.split("/", 1)[0]
by_dir.setdefault(old_dir, {"new": 0, "modified": 0, "deleted": 0, "moved": 0})
by_dir.setdefault(new_dir, {"new": 0, "modified": 0, "deleted": 0, "moved": 0})
by_dir[old_dir]["moved"] += 1
if old_dir != new_dir:
by_dir[new_dir]["moved"] += 1
return by_dir
def build_directory_summary(counts: Dict[str, int]) -> str:
"""Build summary string for directory changes."""
parts = []
if counts["new"]:
parts.append(f"[green]+{counts['new']} new[/green]")
if counts["modified"]:
parts.append(f"[yellow]~{counts['modified']} modified[/yellow]")
if counts["moved"]:
parts.append(f"[blue]↔{counts['moved']} moved[/blue]")
if counts["deleted"]:
parts.append(f"[red]-{counts['deleted']} deleted[/red]")
return " ".join(parts)
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:
tree.add("No changes")
console.print(Panel(tree, expand=False))
return
if verbose:
# Full file listing with checksums
if changes.new:
new_branch = tree.add("[green]New Files[/green]")
add_files_to_tree(new_branch, changes.new, "green", changes.checksums)
if changes.modified:
mod_branch = tree.add("[yellow]Modified[/yellow]")
add_files_to_tree(mod_branch, changes.modified, "yellow", changes.checksums)
if changes.moves:
move_branch = tree.add("[blue]Moved[/blue]")
for old_path, new_path in sorted(changes.moves.items()):
move_branch.add(f"[blue]{old_path}[/blue] → [blue]{new_path}[/blue]")
if changes.deleted:
del_branch = tree.add("[red]Deleted[/red]")
add_files_to_tree(del_branch, changes.deleted, "red")
else:
# Show directory summaries
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}")
console.print(Panel(tree, expand=False))
async def run_status(sync_service: FileChangeScanner, 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)
@app.command()
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)
+254
View File
@@ -0,0 +1,254 @@
"""Command module for basic-memory sync operations."""
import asyncio
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
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 (
EntityRepository,
ObservationRepository,
RelationRepository,
)
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.watch_service import WatchService
console = Console()
@dataclass
class ValidationIssue:
file_path: str
error: str
async def get_sync_service(db_type=DatabaseType.FILESYSTEM):
"""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)
# Initialize repositories
entity_repository = EntityRepository(session_maker)
observation_repository = ObservationRepository(session_maker)
relation_repository = RelationRepository(session_maker)
search_repository = SearchRepository(session_maker)
# Initialize services
search_service = SearchService(search_repository, entity_repository, file_service)
link_resolver = LinkResolver(entity_repository, search_service)
# Initialize scanner
file_change_scanner = FileChangeScanner(entity_repository)
# 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,
)
return sync_service
def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[ValidationIssue]]:
"""Group validation issues by directory."""
grouped = defaultdict(list)
for issue in issues:
dir_name = Path(issue.file_path).parent.name
grouped[dir_name].append(issue)
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
if total_changes == 0:
console.print("[green]Everything up to date[/green]")
return
# Format as: "Synced X files (A new, B modified, C moved, D deleted)"
changes = []
new_count = len(knowledge.new)
mod_count = len(knowledge.modified)
move_count = len(knowledge.moves)
del_count = len(knowledge.deleted)
if new_count:
changes.append(f"[green]{new_count} new[/green]")
if mod_count:
changes.append(f"[yellow]{mod_count} modified[/yellow]")
if move_count:
changes.append(f"[blue]{move_count} moved[/blue]")
if del_count:
changes.append(f"[red]{del_count} deleted[/red]")
console.print(f"Synced {total_changes} files ({', '.join(changes)})")
def display_detailed_sync_results(knowledge: SyncReport):
"""Display detailed sync results with trees."""
if knowledge.total_changes == 0:
console.print("\n[green]Everything up to date[/green]")
return
console.print("\n[bold]Sync Results[/bold]")
if knowledge.total_changes > 0:
knowledge_tree = Tree("[bold]Knowledge Files[/bold]")
if knowledge.new:
created = knowledge_tree.add("[green]Created[/green]")
for path in sorted(knowledge.new):
checksum = knowledge.checksums.get(path, "")
created.add(f"[green]{path}[/green] ({checksum[:8]})")
if knowledge.modified:
modified = knowledge_tree.add("[yellow]Modified[/yellow]")
for path in sorted(knowledge.modified):
checksum = knowledge.checksums.get(path, "")
modified.add(f"[yellow]{path}[/yellow] ({checksum[:8]})")
if knowledge.moves:
moved = knowledge_tree.add("[blue]Moved[/blue]")
for old_path, new_path in sorted(knowledge.moves.items()):
checksum = knowledge.checksums.get(new_path, "")
moved.add(f"[blue]{old_path}[/blue] → [blue]{new_path}[/blue] ({checksum[:8]})")
if knowledge.deleted:
deleted = knowledge_tree.add("[red]Deleted[/red]")
for path in sorted(knowledge.deleted):
deleted.add(f"[red]{path}[/red]")
console.print(knowledge_tree)
async def run_sync(verbose: bool = False, watch: bool = False):
"""Run sync operation."""
sync_service = await get_sync_service()
# Start watching if requested
if watch:
watch_service = WatchService(
sync_service=sync_service,
file_service=sync_service.entity_service.file_service,
config=config
)
await watch_service.handle_changes(config.home)
await watch_service.run()
else:
# one time sync
knowledge_changes = await sync_service.sync(config.home)
# Display results
if verbose:
display_detailed_sync_results(knowledge_changes)
else:
display_sync_summary(knowledge_changes)
@app.command()
def sync(
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Show detailed sync information.",
),
watch: bool = typer.Option(
False,
"--watch",
"-w",
help="Start watching for changes after sync.",
),
) -> None:
"""Sync knowledge files with the database."""
try:
# Run sync
asyncio.run(run_sync(verbose=verbose, watch=watch))
except Exception as e:
if not isinstance(e, typer.Exit):
logger.exception("Sync failed")
typer.echo(f"Error during sync: {e}", err=True)
raise typer.Exit(1)
raise
+48
View File
@@ -0,0 +1,48 @@
"""Main CLI entry point for basic-memory."""
import sys
import typer
from loguru import logger
from basic_memory.cli.app import app
from basic_memory.cli.commands.init import init
# Register commands
from basic_memory.cli.commands import init, status, sync
__all__ = ["init", "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()
if __name__ == "__main__": # pragma: no cover
app()
+53
View File
@@ -0,0 +1,53 @@
"""Configuration management for basic-memory."""
from pathlib import Path
from loguru import logger
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
DATABASE_NAME = "memory.db"
DATA_DIR_NAME = ".basic-memory"
class ProjectConfig(BaseSettings):
"""Configuration for a specific basic-memory project."""
# Default to ~/basic-memory but allow override with env var: BASIC_MEMORY_HOME
home: Path = Field(
default_factory=lambda: Path.home() / "basic-memory",
description="Base path for basic-memory files",
)
# Name of the project
project: str = Field(default="default", description="Project name")
# Watch service configuration
sync_delay: int = Field(
default=500, description="Milliseconds to wait after changes before syncing", gt=0
)
model_config = SettingsConfigDict(
env_prefix="BASIC_MEMORY_",
extra="ignore",
env_file=".env",
env_file_encoding="utf-8",
)
@property
def database_path(self) -> Path:
"""Get SQLite database path."""
return self.home / DATA_DIR_NAME / DATABASE_NAME
@field_validator("home")
@classmethod
def ensure_path_exists(cls, v: Path) -> Path:
"""Ensure project path exists."""
if not v.exists():
v.mkdir(parents=True)
return v
# Load project config
config = ProjectConfig()
logger.info(f"project config home: {config.home}")
+135
View File
@@ -0,0 +1,135 @@
import asyncio
from contextlib import asynccontextmanager
from enum import Enum, auto
from pathlib import Path
from typing import AsyncGenerator, Optional
from loguru import logger
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
create_async_engine,
async_sessionmaker,
AsyncSession,
AsyncEngine,
async_scoped_session,
)
from basic_memory.models import Base
# Module level state
_engine: Optional[AsyncEngine] = None
_session_maker: Optional[async_sessionmaker[AsyncSession]] = None
class DatabaseType(Enum):
"""Types of supported databases."""
MEMORY = auto()
FILESYSTEM = auto()
@classmethod
def get_db_url(cls, db_path: Path, db_type: "DatabaseType") -> str:
"""Get SQLAlchemy URL for database path."""
if db_type == cls.MEMORY:
logger.info("Using in-memory SQLite database")
return "sqlite+aiosqlite://"
return f"sqlite+aiosqlite:///{db_path}"
def get_scoped_session_factory(
session_maker: async_sessionmaker[AsyncSession],
) -> async_scoped_session:
"""Create a scoped session factory scoped to current task."""
return async_scoped_session(session_maker, scopefunc=asyncio.current_task)
@asynccontextmanager
async def scoped_session(
session_maker: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncSession, None]:
"""
Get a scoped session with proper lifecycle management.
Args:
session_maker: Session maker to create scoped sessions from
"""
factory = get_scoped_session_factory(session_maker)
session = factory()
try:
await session.execute(text("PRAGMA foreign_keys=ON"))
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
await factory.remove()
async def init_db(session: AsyncSession):
"""Initialize database with required tables."""
await session.execute(text("PRAGMA foreign_keys=ON"))
conn = await session.connection()
await conn.run_sync(Base.metadata.create_all)
await session.commit()
async def get_or_create_db(
db_path: Path,
db_type: DatabaseType = DatabaseType.FILESYSTEM,
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
"""Get or create database engine and session maker."""
global _engine, _session_maker
if _engine is None:
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})
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
# Initialize database
logger.debug("Initializing database...")
async with scoped_session(_session_maker) as db_session:
await init_db(db_session)
return _engine, _session_maker
async def shutdown_db():
"""Clean up database connections."""
global _engine, _session_maker
if _engine:
await _engine.dispose()
_engine = None
_session_maker = None
@asynccontextmanager
async def engine_session_factory(
db_path: Path,
db_type: DatabaseType = DatabaseType.FILESYSTEM,
init: bool = True,
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
"""Create engine and session factory.
Note: This is primarily used for testing where we want a fresh database
for each test. For production use, use get_or_create_db() instead.
"""
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:
factory = async_sessionmaker(engine, expire_on_commit=False)
if init:
logger.debug("Initializing database...")
async with scoped_session(factory) as db_session:
await init_db(db_session)
yield engine, factory
finally:
await engine.dispose()
+182
View File
@@ -0,0 +1,182 @@
"""Dependency injection functions for basic-memory services."""
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import (
AsyncSession,
AsyncEngine,
async_sessionmaker,
)
from basic_memory import db
from basic_memory.config import ProjectConfig, config
from basic_memory.markdown import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.services import (
EntityService,
)
from basic_memory.services.context_service import ContextService
from basic_memory.services.file_service import FileService
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
## project
def get_project_config() -> ProjectConfig:
return config
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)]
## sqlalchemy
async def get_engine_factory(
project_config: ProjectConfigDep,
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
"""Get engine and session maker."""
return await db.get_or_create_db(project_config.database_path)
EngineFactoryDep = Annotated[
tuple[AsyncEngine, async_sessionmaker[AsyncSession]], Depends(get_engine_factory)
]
async def get_session_maker(engine_factory: EngineFactoryDep) -> async_sessionmaker[AsyncSession]:
"""Get session maker."""
_, session_maker = engine_factory
return session_maker
SessionMakerDep = Annotated[async_sessionmaker, Depends(get_session_maker)]
## repositories
async def get_entity_repository(
session_maker: SessionMakerDep,
) -> EntityRepository:
"""Create an EntityRepository instance."""
return EntityRepository(session_maker)
EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
async def get_observation_repository(
session_maker: SessionMakerDep,
) -> ObservationRepository:
"""Create an ObservationRepository instance."""
return ObservationRepository(session_maker)
ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
async def get_relation_repository(
session_maker: SessionMakerDep,
) -> RelationRepository:
"""Create a RelationRepository instance."""
return RelationRepository(session_maker)
RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
async def get_search_repository(
session_maker: SessionMakerDep,
) -> SearchRepository:
"""Create a SearchRepository instance."""
return SearchRepository(session_maker)
SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
## services
async def get_entity_parser(project_config: ProjectConfigDep) -> EntityParser:
return EntityParser(project_config.home)
EntityParserDep = Annotated["EntityParser", Depends(get_entity_parser)]
async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProcessor:
return MarkdownProcessor(entity_parser)
MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)]
async def get_file_service(
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
) -> FileService:
return FileService(project_config.home, markdown_processor)
FileServiceDep = Annotated[FileService, Depends(get_file_service)]
async def get_entity_service(
entity_repository: EntityRepositoryDep,
observation_repository: ObservationRepositoryDep,
relation_repository: RelationRepositoryDep,
entity_parser: EntityParserDep,
file_service: FileServiceDep,
link_resolver: "LinkResolverDep",
) -> EntityService:
"""Create EntityService with repository."""
return EntityService(
entity_repository=entity_repository,
observation_repository=observation_repository,
relation_repository=relation_repository,
entity_parser=entity_parser,
file_service=file_service,
link_resolver=link_resolver,
)
EntityServiceDep = Annotated[EntityService, Depends(get_entity_service)]
async def get_search_service(
search_repository: SearchRepositoryDep,
entity_repository: EntityRepositoryDep,
file_service: FileServiceDep,
) -> SearchService:
"""Create SearchService with dependencies."""
return SearchService(search_repository, entity_repository, file_service)
SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
async def get_link_resolver(
entity_repository: EntityRepositoryDep, search_service: SearchServiceDep
) -> LinkResolver:
return LinkResolver(entity_repository=entity_repository, search_service=search_service)
LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)]
async def get_context_service(
search_repository: SearchRepositoryDep, entity_repository: EntityRepositoryDep
) -> ContextService:
return ContextService(search_repository, entity_repository)
ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
+248
View File
@@ -0,0 +1,248 @@
"""Utilities for file operations."""
import hashlib
from pathlib import Path
from typing import Dict, Any, Tuple
import yaml
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:
"""
Compute SHA-256 checksum of content.
Args:
content: Text content to hash
Returns:
SHA-256 hex digest
Raises:
FileError: If checksum computation fails
"""
try:
return hashlib.sha256(content.encode()).hexdigest()
except Exception as e:
logger.error(f"Failed to compute checksum: {e}")
raise FileError(f"Failed to compute checksum: {e}")
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:
logger.error(f"Failed to create directory: {path}: {e}")
raise FileWriteError(f"Failed to create directory {path}: {e}")
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:
temp_path.unlink(missing_ok=True)
logger.error(f"Failed to write file: {path}: {e}")
raise FileWriteError(f"Failed to write file {path}: {e}")
def has_frontmatter(content: str) -> bool:
"""
Check if content contains YAML frontmatter.
Args:
content: Content to check
Returns:
True if content has frontmatter delimiter (---), False otherwise
"""
content = content.strip()
return content.startswith("---") and "---" in content[3:]
def parse_frontmatter(content: str) -> Dict[str, Any]:
"""
Parse YAML frontmatter from content.
Args:
content: Content with YAML frontmatter
Returns:
Dictionary of frontmatter values
Raises:
ParseError: If frontmatter is invalid or parsing fails
"""
try:
if not has_frontmatter(content):
raise ParseError("Content has no frontmatter")
# Split on first two occurrences of ---
parts = content.split("---", 2)
if len(parts) < 3:
raise ParseError("Invalid frontmatter format")
# Parse YAML
try:
frontmatter = yaml.safe_load(parts[1])
# Handle empty frontmatter (None from yaml.safe_load)
if frontmatter is None:
return {}
if not isinstance(frontmatter, dict):
raise ParseError("Frontmatter must be a YAML dictionary")
return frontmatter
except yaml.YAMLError as e:
raise ParseError(f"Invalid YAML in frontmatter: {e}")
except Exception as e:
if not isinstance(e, ParseError):
logger.error(f"Failed to parse frontmatter: {e}")
raise ParseError(f"Failed to parse frontmatter: {e}")
raise
def remove_frontmatter(content: str) -> str:
"""
Remove YAML frontmatter from content.
Args:
content: Content with frontmatter
Returns:
Content with frontmatter removed
Raises:
ParseError: If frontmatter format is invalid
"""
try:
if not has_frontmatter(content):
return content.strip()
# Split on first two occurrences of ---
parts = content.split("---", 2)
if len(parts) < 3:
raise ParseError("Invalid frontmatter format")
return parts[2].strip()
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
def remove_frontmatter_lenient(content: str) -> str:
"""
Remove frontmatter markers and anything between them without validation.
This is a more permissive version of remove_frontmatter that doesn't
try to validate the YAML content. It simply removes everything between
the first two '---' markers if they exist.
Args:
content: Content that may contain frontmatter
Returns:
Content with any frontmatter markers and content removed
"""
content = content.strip()
if not content.startswith("---"):
return content
# Find the second marker
rest = content[3:].strip()
if "---" not in rest:
return content
# Split on the second marker and take everything after
parts = rest.split("---", 1)
return parts[1].strip()
async def add_frontmatter(content: str, frontmatter: Dict[str, Any]) -> str:
"""
Add YAML frontmatter to content.
Args:
content: Main content text
frontmatter: Key-value pairs for frontmatter
Returns:
Content with YAML frontmatter prepended
Raises:
ParseError: If YAML serialization fails
"""
try:
yaml_fm = yaml.dump(frontmatter, sort_keys=False)
return f"---\n{yaml_fm}---\n\n{content.strip()}"
except yaml.YAMLError as e:
logger.error(f"Failed to add frontmatter: {e}")
raise ParseError(f"Failed to add frontmatter: {e}")
async def parse_content_with_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
"""
Parse both frontmatter and content.
Args:
content: Text content with optional frontmatter
Returns:
Tuple of (frontmatter dict, content without frontmatter)
Raises:
ParseError: If parsing fails
"""
try:
if not has_frontmatter(content):
return {}, content.strip()
frontmatter = parse_frontmatter(content)
remaining = remove_frontmatter(content)
return frontmatter, remaining
except Exception as e:
if not isinstance(e, ParseError):
logger.error(f"Failed to parse content with frontmatter: {e}")
raise ParseError(f"Failed to parse content with frontmatter: {e}")
raise
+19
View File
@@ -0,0 +1,19 @@
"""Base package for markdown parsing."""
from basic_memory.file_utils import ParseError
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.schemas import (
EntityMarkdown,
EntityFrontmatter,
Observation,
Relation,
)
__all__ = [
"EntityMarkdown",
"EntityFrontmatter",
"EntityParser",
"Observation",
"Relation",
"ParseError",
]
+137
View File
@@ -0,0 +1,137 @@
"""Parser for markdown files into Entity objects.
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
from typing import Any, Optional
import dateparser
from markdown_it import MarkdownIt
import frontmatter
from basic_memory.markdown.plugins import observation_plugin, relation_plugin
from basic_memory.markdown.schemas import (
EntityMarkdown,
EntityFrontmatter,
Observation,
Relation,
)
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)
def parse(content: str) -> EntityContent:
"""Parse markdown content into EntityMarkdown."""
# Parse content for observations and relations using markdown-it
observations = []
relations = []
if content:
for token in md.parse(content):
# check for observations and relations
if token.meta:
if "observation" in token.meta:
obs = token.meta["observation"]
observation = Observation.model_validate(obs)
observations.append(observation)
if "relations" in token.meta:
rels = token.meta["relations"]
relations.extend([Relation.model_validate(r) for r in rels])
return EntityContent(
content=content,
observations=observations,
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 []
class EntityParser:
"""Parser for markdown files into Entity objects."""
def __init__(self, base_path: Path):
"""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.
Supports human friendly formats like:
- 2024-01-15
- Jan 15, 2024
- 2024-01-15 10:00 AM
- yesterday
- 2 days ago
"""
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
parsed = dateparser.parse(value)
if parsed:
return parsed
except Exception:
pass
return None
async def parse_file(self, file_path: Path) -> EntityMarkdown:
"""Parse markdown file into EntityMarkdown."""
absolute_path = self.base_path / file_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["type"] = metadata.get("type", "note")
metadata["tags"] = parse_tags(post.metadata.get("tags", []))
# frontmatter
entity_frontmatter = EntityFrontmatter(
metadata=post.metadata,
)
entity_content = parse(post.content)
return EntityMarkdown(
frontmatter=entity_frontmatter,
content=post.content,
observations=entity_content.observations,
relations=entity_content.relations,
created=datetime.fromtimestamp(file_stats.st_ctime),
modified=datetime.fromtimestamp(file_stats.st_mtime),
)
@@ -0,0 +1,153 @@
"""Process markdown files with structured sections.
This module follows a Read -> Modify -> Write pattern for all file operations:
1. Read entire file and parse into EntityMarkdown schema
2. Modify the schema (add relation, update content, etc)
3. Write entire file atomically using temp file + swap
No in-place updates are performed. Each write reconstructs the entire file from the schema.
The file format has two distinct types of content:
1. User content - Free form text that is preserved exactly as written
2. Structured sections - Observations and Relations that are always formatted
in a standard way and can be overwritten since they're tracked in our schema
"""
from pathlib import Path
from typing import Optional
from collections import OrderedDict
import frontmatter
from frontmatter import Post
from loguru import logger
from basic_memory import file_utils
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation
class DirtyFileError(Exception):
"""Raised when attempting to write to a file that has been modified."""
pass
class MarkdownProcessor:
"""Process markdown files while preserving content and structure.
This class handles the file I/O aspects of our markdown processing. It:
1. Uses EntityParser for reading/parsing files into our schema
2. Handles writing files with proper frontmatter
3. Formats structured sections (observations/relations) consistently
4. Preserves user content exactly as written
5. Performs atomic writes using temp files
It does NOT:
1. Modify the schema directly (that's done by services)
2. Handle in-place updates (everything is read->modify->write)
3. Track schema changes (that's done by the database)
"""
def __init__(self, entity_parser: EntityParser):
"""Initialize processor with base path and parser."""
self.entity_parser = entity_parser
async def read_file(self, path: Path) -> EntityMarkdown:
"""Read and parse file into EntityMarkdown schema.
This is step 1 of our read->modify->write pattern.
We use EntityParser to handle all the markdown parsing.
"""
return await self.entity_parser.parse_file(path)
async def write_file(
self,
path: Path,
markdown: EntityMarkdown,
expected_checksum: Optional[str] = None,
) -> str:
"""Write EntityMarkdown schema back to file.
This is step 3 of our read->modify->write pattern.
The entire file is rewritten atomically on each update.
File Structure:
---
frontmatter fields
---
user content area (preserved exactly)
## Observations (if any)
formatted observations
## Relations (if any)
formatted relations
Args:
path: Where to write the file
markdown: Complete schema to write
expected_checksum: If provided, verify file hasn't changed
Returns:
Checksum of written file
Raises:
DirtyFileError: If file has been modified (when expected_checksum provided)
"""
# Dirty check if needed
if expected_checksum is not None:
current_content = path.read_text()
current_checksum = await file_utils.compute_checksum(current_content)
if current_checksum != expected_checksum:
raise DirtyFileError(f"File {path} has been modified")
# Convert frontmatter to dict
frontmatter_dict = OrderedDict()
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():
frontmatter_dict[k] = v
# Start with user content (or minimal title for new files)
content = markdown.content or f"# {markdown.frontmatter.title}\n"
# Add structured sections with proper spacing
content = content.rstrip() # Remove trailing whitespace
# 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:
content += self.format_relations(markdown.relations)
# Create Post object for frontmatter
post = Post(content, **frontmatter_dict)
final_content = frontmatter.dumps(post, sort_keys=False)
logger.debug(f"writing file {path} with content:\n{final_content}")
# Write atomically and return checksum of updated file
path.parent.mkdir(parents=True, exist_ok=True)
await file_utils.write_file_atomic(path, final_content)
return await file_utils.compute_checksum(final_content)
def format_observations(self, observations: list[Observation]) -> str:
"""Format observations section in standard way.
Format: - [category] content #tag1 #tag2 (context)
"""
lines = [f"{obs}" for obs in observations]
return "\n".join(lines) + "\n"
def format_relations(self, relations: list[Relation]) -> str:
"""Format relations section in standard way.
Format: - relation_type [[target]] (context)
"""
lines = [f"{rel}" for rel in relations]
return "\n".join(lines) + "\n"
+236
View File
@@ -0,0 +1,236 @@
"""Markdown-it plugins for Basic Memory markdown parsing."""
from typing import List, Any, Dict
from markdown_it import MarkdownIt
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':
return False
content = token.content.strip()
if not content:
return False
# if it's a markdown_task, return false
if content.startswith('[ ]') or content.startswith('[x]') or content.startswith('[-]'):
return False
has_category = content.startswith('[') and ']' in content
has_tags = '#' in content
return has_category or has_tags
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 end != -1:
category = content[1:end].strip() or None # Convert empty to None
content = content[end + 1:].strip()
# Parse (context)
context = None
if content.endswith(')'):
start = content.rfind('(')
if start != -1:
context = content[start + 1:-1].strip()
content = content[:start].strip()
# Parse #tags and content
parts = content.split()
content_parts = []
tags = set() # Use set to avoid duplicates
for part in parts:
if part.startswith('#'):
# Handle multiple #tags stuck together
if '#' in part[1:]:
# Split on # but keep non-empty tags
subtags = [t for t in part.split('#') if t]
tags.update(subtags)
else:
tags.add(part[1:])
else:
content_parts.append(part)
return {
'category': category,
'content': ' '.join(content_parts).strip(),
'tags': list(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':
return False
content = token.content.strip()
return '[[' in content and ']]' in content
def parse_relation(token: Token) -> Dict[str, Any]:
"""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
context = None
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()
# Look for context after
after = content[end + 2:].strip()
if after.startswith('(') and after.endswith(')'):
context = after[1:-1].strip() or None
if not target:
return None
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
})
return relations
def observation_plugin(md: MarkdownIt) -> None:
"""Plugin for parsing observation format:
- [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):
obs = parse_observation(token)
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)
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':
in_list_item = True
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':
# 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]
# Always check for inline links in any text
elif '[[' in token.content:
rels = parse_inline_relations(token.content)
if rels:
token.meta['relations'] = token.meta.get('relations', []) + rels
# Add the rule after inline processing
md.core.ruler.after('inline', 'relations', relation_rule)
+73
View File
@@ -0,0 +1,73 @@
"""Schema models for entity markdown files."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
class Observation(BaseModel):
"""An observation about an entity."""
category: Optional[str] = None
content: str
tags: Optional[List[str]] = None
context: Optional[str] = None
def __str__(self) -> str:
obs_string = f"- [{self.category}] {self.content}"
if self.tags:
obs_string += " " + " ".join(f"#{tag}" for tag in sorted(self.tags))
if self.context:
obs_string += f" ({self.context})"
return obs_string
class Relation(BaseModel):
"""A relation between entities."""
type: str
target: str
context: Optional[str] = None
def __str__(self) -> str:
rel_string = f"- {self.type} [[{self.target}]]"
if self.context:
rel_string += f" ({self.context})"
return rel_string
class EntityFrontmatter(BaseModel):
"""Required frontmatter fields for an entity."""
metadata: Optional[dict] = None
@property
def tags(self) -> List[str]:
return self.metadata.get("tags") if self.metadata else []
@property
def title(self) -> str:
return self.metadata.get("title") if self.metadata else None
@property
def type(self) -> str:
return self.metadata.get("type", "note") if self.metadata else "note"
@property
def permalink(self) -> str:
return self.metadata.get("permalink") if self.metadata else None
class EntityMarkdown(BaseModel):
"""Complete entity combining frontmatter, content, and metadata."""
frontmatter: EntityFrontmatter
content: Optional[str] = None
observations: List[Observation] = []
relations: List[Relation] = []
# created, updated will have values after a read
created: Optional[datetime] = None
modified: Optional[datetime] = None
+144
View File
@@ -0,0 +1,144 @@
from pathlib import Path
from typing import Optional
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.utils import generate_permalink
def entity_model_to_markdown(entity: Entity, content: Optional[str] = None) -> EntityMarkdown:
"""
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.
Args:
markdown: Parsed markdown entity
include_relations: Whether to include relations. Set False for first sync pass.
"""
# 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
permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
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
]
return model
async def schema_to_markdown(schema):
"""
Convert schema to markdown.
:param schema: the schema to convert
:return: Post
"""
# Create Post object
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)
return post
+1
View File
@@ -0,0 +1 @@
"""MCP server for basic-memory."""
+10
View File
@@ -0,0 +1,10 @@
from httpx import ASGITransport, AsyncClient
from basic_memory.api.app import app as fastapi_app
BASE_URL = "http://test"
# Create shared async client
client = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
+21
View File
@@ -0,0 +1,21 @@
"""Main MCP entrypoint for Basic Memory.
Creates and configures the shared MCP instance and handles server startup.
"""
from loguru import logger
from basic_memory.config import config
# Import shared mcp instance
from basic_memory.mcp.server import mcp
# Import tools to register them
import basic_memory.mcp.tools # noqa: F401
if __name__ == "__main__":
home_dir = config.home
logger.info("Starting Basic Memory MCP server")
logger.info(f"Home directory: {home_dir}")
mcp.run()
+39
View File
@@ -0,0 +1,39 @@
"""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")
+34
View File
@@ -0,0 +1,34 @@
"""MCP tools for Basic Memory.
This package provides the complete set of tools for interacting with
Basic Memory through the MCP protocol. Importing this module registers
all tools with the MCP server.
"""
# Import tools to register them with MCP
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.knowledge import (
delete_entities,
get_entity,
get_entities,
)
__all__ = [
# Knowledge graph tools
"delete_entities",
"get_entity",
"get_entities",
# Search tools
"search",
# memory tools
"build_context",
"recent_activity",
#notes
"read_note",
"write_note",
# file edit
#"ai_edit",
]
+84
View File
@@ -0,0 +1,84 @@
"""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
+56
View File
@@ -0,0 +1,56 @@
"""Knowledge graph management tools for Basic Memory MCP server."""
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.request import (
GetEntitiesRequest,
)
from basic_memory.schemas.delete import (
DeleteEntitiesRequest,
)
from basic_memory.schemas.response import EntityListResponse, EntityResponse, DeleteEntitiesResponse
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:
"""Get a specific entity info by its permalink.
Args:
permalink: Path identifier for the entity
"""
url = f"/knowledge/entities/{permalink}"
response = await call_get(client, url)
return EntityResponse.model_validate(response.json())
@mcp.tool(
description="Load multiple entities by their permalinks in a single request",
)
async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
"""Load multiple entities by their permalinks.
Args:
request: OpenNodesRequest containing list of permalinks to load
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())
@mcp.tool(
description="Permanently delete entities and all related content (observations and relations)",
)
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())
+142
View File
@@ -0,0 +1,142 @@
"""Discussion context tools for Basic Memory MCP server."""
from typing import Optional, Literal
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.memory import GraphContext, MemoryUrl, memory_url, memory_url_path, normalize_memory_url
from basic_memory.schemas.base import TimeFrame
@mcp.tool(
description="""Build context from a memory:// URI to continue conversations naturally.
Use this to follow up on previous discussions or explore related topics.
Timeframes support natural language like:
- "2 days ago"
- "last week"
- "today"
- "3 months ago"
Or standard formats like "7d", "24h"
""",
)
async def build_context(
url: MemoryUrl,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
max_results: int = 10,
) -> GraphContext:
"""Get context needed to continue a discussion.
This tool enables natural continuation of discussions by loading relevant context
from memory:// URIs. It uses pattern matching to find relevant content and builds
a rich context graph of related information.
Args:
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)
Returns:
GraphContext containing:
- primary_results: Content matching the memory:// URI
- related_results: Connected content via relations
- metadata: Context building details
Examples:
# Continue a specific discussion
build_context("memory://specs/search")
# Get deeper context about a component
build_context("memory://components/memory-service", depth=2)
# Look at recent changes to a specification
build_context("memory://specs/document-format", timeframe="today")
# 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())
@mcp.tool(
description="""Get recent activity from across the knowledge base.
Timeframe supports natural language formats like:
- "2 days ago"
- "last week"
- "yesterday"
- "today"
- "3 weeks ago"
Or standard formats like "7d"
""",
)
async def recent_activity(
type: Literal["entity", "observation", "relation"] = None,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
max_results: int = 10,
) -> GraphContext:
"""Get recent activity across the knowledge base.
Args:
type: Filter by content type(s). Valid options:
- ["entity"] for knowledge entities
- ["relation"] for connections between entities
- ["observation"] for notes and observations
Multiple types can be combined: ["entity", "relation"]
depth: How many relation hops to traverse (1-3 recommended)
timeframe: Time window to search. Supports natural language:
- Relative: "2 days ago", "last week", "yesterday"
- Points in time: "2024-01-01", "January 1st"
- Standard format: "7d", "24h"
max_results: Maximum number of results to return (default: 10)
Returns:
GraphContext containing:
- primary_results: Latest activities matching the filters
- related_results: Connected content via relations
- metadata: Query details and statistics
Examples:
# Get all entities from yesterday
recent_activity(type=["entity"], timeframe="yesterday")
# Get recent relations and observations
recent_activity(type=["relation", "observation"], timeframe="today")
# Look back further with more context
recent_activity(type=["entity"], depth=2, timeframe="2 weeks ago")
Notes:
- Higher depth values (>3) may impact performance with large result sets
- 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,
"type": type if type else None,
}
response = await call_get(
client,
"/memory/recent",
params=params,
)
return GraphContext.model_validate(response.json())
+122
View File
@@ -0,0 +1,122 @@
"""Note management tools for Basic Memory MCP server.
These tools provide a natural interface for working with markdown notes
while leveraging the underlying knowledge graph structure.
"""
from typing import Optional, List
from loguru import logger
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
@mcp.tool(
description="Create or update a markdown note. Returns the permalink for referencing.",
)
async def write_note(
title: str,
content: str,
folder: str,
tags: Optional[List[str]] = None,
verbose: bool = False,
) -> EntityResponse | str:
"""Write a markdown note to the knowledge base.
Args:
title: The title of the note
content: Markdown content for the note
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"]
)
"""
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,
)
# 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
@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.
Args:
identifier: Note title or permalink
Returns:
The note's markdown content
Examples:
# Read by title
read_note("Meeting Notes: Project Planning")
# Read by permalink
read_note("notes/project-planning")
Raises:
ValueError: If the note cannot be found
"""
response = await call_get(client, f"/resource/{identifier}")
return response.text
@mcp.tool(description="Delete a note by title or permalink")
async def delete_note(identifier: str) -> bool:
"""Delete a note from the knowledge base.
Args:
identifier: Note title or permalink
Returns:
True if note was deleted, False otherwise
Examples:
# Delete by title
delete_note("Meeting Notes: Project Planning")
# 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
+28
View File
@@ -0,0 +1,28 @@
"""Search tools for Basic Memory MCP server."""
from loguru import logger
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.search import SearchQuery, SearchResponse
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:
"""Search across all content in basic-memory.
Args:
query: SearchQuery object with search parameters including:
- text: Search text (required)
- 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
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())
+154
View File
@@ -0,0 +1,154 @@
import typing
from httpx import Response, URL, AsyncClient, HTTPStatusError
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
from httpx._types import (
RequestContent,
RequestData,
RequestFiles,
QueryParamTypes,
HeaderTypes,
CookieTypes,
AuthTypes,
TimeoutTypes,
RequestExtensions,
)
from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
async def call_get(
client: AsyncClient,
url: URL | str,
*,
params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None,
auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
extensions: RequestExtensions | None = None,
) -> Response:
logger.debug(f"Calling GET '{url}' params: '{params}'")
try:
response = await client.get(
url,
params=params,
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
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
async def call_put(
client: AsyncClient,
url: URL | str,
*,
content: RequestContent | None = None,
data: RequestData | None = None,
files: RequestFiles | None = None,
json: typing.Any | None = None,
params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None,
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
extensions: RequestExtensions | None = None,
) -> Response:
try:
response = await client.put(
url,
content=content,
data=data,
files=files,
json=json,
params=params,
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
response.raise_for_status()
return response
except HTTPStatusError as e:
logger.error(f"Error calling PUT {url}: {e}")
raise ToolError(f"Error calling tool: {e}") from e
async def call_post(
client: AsyncClient,
url: URL | str,
*,
content: RequestContent | None = None,
data: RequestData | None = None,
files: RequestFiles | None = None,
json: typing.Any | None = None,
params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None,
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
extensions: RequestExtensions | None = None,
) -> Response:
try:
response = await client.post(
url=url,
content=content,
data=data,
files=files,
json=json,
params=params,
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
response.raise_for_status()
return response
except HTTPStatusError as e:
logger.error(f"Error calling POST {url}: {e}")
raise ToolError(f"Error calling tool: {e}") from e
async def call_delete(
client: AsyncClient,
url: URL | str,
*,
params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None,
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
extensions: RequestExtensions | None = None,
) -> Response:
try:
response = await client.delete(
url=url,
params=params,
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
response.raise_for_status()
return response
except HTTPStatusError as e:
logger.error(f"Error calling DELETE {url}: {e}")
raise ToolError(f"Error calling tool: {e}") from e
+12
View File
@@ -0,0 +1,12 @@
"""Models package for basic-memory."""
from basic_memory.models.base import Base
from basic_memory.models.knowledge import Entity, Observation, Relation, ObservationCategory
__all__ = [
'Base',
'Entity',
'Observation',
'ObservationCategory',
'Relation'
]
+9
View File
@@ -0,0 +1,9 @@
"""Base model class for SQLAlchemy models."""
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase
class Base(AsyncAttrs, DeclarativeBase):
"""Base class for all models"""
pass
+204
View File
@@ -0,0 +1,204 @@
"""Knowledge graph models."""
import re
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Integer,
String,
Text,
ForeignKey,
UniqueConstraint,
DateTime,
Index,
JSON,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
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.
Entities represent semantic nodes maintained by the AI layer. Each entity:
- Has a unique numeric ID (database-generated)
- Maps to a file on disk
- Maintains a checksum for change detection
- Tracks both source file and semantic properties
"""
__tablename__ = "entity"
__table_args__ = (
UniqueConstraint("permalink", name="uix_entity_permalink"), # Make permalink unique
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
)
# Core identity
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String)
entity_type: Mapped[str] = mapped_column(String)
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)
# Actual filesystem relative path
file_path: Mapped[str] = mapped_column(String, unique=True, index=True)
# checksum of file
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# Metadata and tracking
created_at: Mapped[datetime] = mapped_column(DateTime)
updated_at: Mapped[datetime] = mapped_column(DateTime)
# Relationships
observations = relationship(
"Observation", back_populates="entity", cascade="all, delete-orphan"
)
outgoing_relations = relationship(
"Relation",
back_populates="from_entity",
foreign_keys="[Relation.from_id]",
cascade="all, delete-orphan",
)
incoming_relations = relationship(
"Relation",
back_populates="to_entity",
foreign_keys="[Relation.to_id]",
cascade="all, delete-orphan",
)
@property
def relations(self):
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
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.
Observations are atomic facts or notes about an entity.
"""
__tablename__ = "observation"
__table_args__ = (
Index("ix_observation_entity_id", "entity_id"), # Add FK index
Index("ix_observation_category", "category"), # Add category index
)
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)
tags: Mapped[Optional[list[str]]] = mapped_column(
JSON, nullable=True, default=list, server_default="[]"
)
# Relationships
entity = relationship("Entity", back_populates="observations")
@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
"""
return generate_permalink(
f"{self.entity.permalink}/observations/{self.category}/{self.content}"
)
def __repr__(self) -> str:
return f"Observation(id={self.id}, entity_id={self.entity_id}, content='{self.content}')"
class Relation(Base):
"""
A directed relation between two entities.
"""
__tablename__ = "relation"
__table_args__ = (
UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation"),
Index("ix_relation_type", "relation_type"),
Index("ix_relation_from_id", "from_id"), # Add FK indexes
Index("ix_relation_to_id", "to_id"),
)
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(
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)
# Relationships
from_entity = relationship(
"Entity", foreign_keys=[from_id], back_populates="outgoing_relations"
)
to_entity = relationship("Entity", foreign_keys=[to_id], back_populates="incoming_relations")
@property
def permalink(self) -> str:
"""Create relation permalink showing the semantic connection:
source/relation_type/target
e.g., "specs/search/implements/features/search-ui"
"""
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}"
)
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}')"
+34
View File
@@ -0,0 +1,34 @@
"""Search models and tables."""
from sqlalchemy import DDL
# Define FTS5 virtual table creation
CREATE_SEARCH_INDEX = DDL("""
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
-- Core entity fields
id UNINDEXED, -- Row ID
title, -- Title for searching
content, -- Main searchable content
permalink, -- Stable identifier (now indexed for path search)
file_path UNINDEXED, -- Physical location
type UNINDEXED, -- entity/relation/observation
-- Relation fields
from_id UNINDEXED, -- Source entity
to_id UNINDEXED, -- Target entity
relation_type UNINDEXED, -- Type of relation
-- Observation fields
entity_id UNINDEXED, -- Parent entity
category UNINDEXED, -- Observation category
-- Common fields
metadata UNINDEXED, -- JSON metadata
created_at UNINDEXED, -- Creation timestamp
updated_at UNINDEXED, -- Last update
-- Configuration
tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
prefix='1,2,3,4' -- Support longer prefixes for paths
);
""")
+7
View File
@@ -0,0 +1,7 @@
from .entity_repository import EntityRepository
from .observation_repository import ObservationRepository
from .relation_repository import RelationRepository
__all__ = ["EntityRepository", "ObservationRepository", "RelationRepository", ]
@@ -0,0 +1,156 @@
"""Repository for managing entities in the knowledge graph."""
from typing import List, Optional, Sequence
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
from basic_memory.models.knowledge import Entity, Observation, Relation
from basic_memory.repository.repository import Repository
class EntityRepository(Repository[Entity]):
"""Repository for Entity model."""
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."""
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."""
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 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]:
"""
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)),
)
)
.options(*self.get_load_options())
)
result = await self.execute_query(query)
return list(result.scalars().all())
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: str) -> bool:
"""Delete entity with the provided file_path."""
return await self.delete_by_fields(file_path=file_path)
def get_load_options(self) -> List[LoaderOption]:
return [
selectinload(Entity.observations).selectinload(Observation.entity),
# Load from_relations and both entities for each relation
selectinload(Entity.outgoing_relations).selectinload(Relation.from_entity),
selectinload(Entity.outgoing_relations).selectinload(Relation.to_entity),
# Load to_relations and both entities for each relation
selectinload(Entity.incoming_relations).selectinload(Relation.from_entity),
selectinload(Entity.incoming_relations).selectinload(Relation.to_entity),
]
async def find_by_permalinks(self, permalinks: List[str]) -> Sequence[Entity]:
"""Find multiple entities by their permalink."""
# Handle empty input explicitly
if not permalinks:
return []
# Use existing select pattern
query = (
self.select().options(*self.get_load_options()).where(Entity.permalink.in_(permalinks))
)
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])
@@ -0,0 +1,40 @@
"""Repository for managing Observation objects."""
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from basic_memory.models import Observation
from basic_memory.repository.repository import Repository
class ObservationRepository(Repository[Observation]):
"""Repository for Observation model with memory-specific operations."""
def __init__(self, session_maker: async_sessionmaker):
super().__init__(session_maker, Observation)
async def find_by_entity(self, entity_id: int) -> Sequence[Observation]:
"""Find all observations for a specific entity."""
query = select(Observation).filter(Observation.entity_id == entity_id)
result = await self.execute_query(query)
return result.scalars().all()
async def find_by_context(self, context: str) -> Sequence[Observation]:
"""Find observations with a specific context."""
query = select(Observation).filter(Observation.context == context)
result = await self.execute_query(query)
return result.scalars().all()
async def find_by_category(self, category: str) -> Sequence[Observation]:
"""Find observations with a specific context."""
query = select(Observation).filter(Observation.category == category)
result = await self.execute_query(query)
return result.scalars().all()
async def observation_categories(self) -> Sequence[str]:
"""Return a list of all observation categories."""
query = select(Observation.category).distinct()
result = await self.execute_query(query, use_query_options=False)
return result.scalars().all()
@@ -0,0 +1,78 @@
"""Repository for managing Relation objects."""
from sqlalchemy import and_, delete
from typing import Sequence, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload, aliased
from sqlalchemy.orm.interfaces import LoaderOption
from basic_memory import db
from basic_memory.models import Relation, Entity
from basic_memory.repository.repository import Repository
class RelationRepository(Repository[Relation]):
"""Repository for Relation model with memory-specific operations."""
def __init__(self, session_maker: async_sessionmaker):
super().__init__(session_maker, Relation)
async def find_relation(
self, from_permalink: str, to_permalink: str, relation_type: str
) -> Optional[Relation]:
"""Find a relation by its from and to path IDs."""
from_entity = aliased(Entity)
to_entity = aliased(Entity)
query = (
select(Relation)
.join(from_entity, Relation.from_id == from_entity.id)
.join(to_entity, Relation.to_id == to_entity.id)
.where(
and_(
from_entity.permalink == from_permalink,
to_entity.permalink == to_permalink,
Relation.relation_type == relation_type,
)
)
)
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))
result = await self.execute_query(query)
return result.scalars().all()
async def find_by_type(self, relation_type: str) -> Sequence[Relation]:
"""Find all relations of a specific type."""
query = select(Relation).filter(Relation.relation_type == relation_type)
result = await self.execute_query(query)
return result.scalars().all()
async def delete_outgoing_relations_from_entity(self, entity_id: int) -> None:
"""Delete outgoing relations for an entity.
Only deletes relations where this entity is the source (from_id),
as these are the ones owned by this entity's markdown file.
"""
async with db.scoped_session(self.session_maker) as session:
await session.execute(delete(Relation).where(Relation.from_id == entity_id))
async def find_unresolved_relations(self) -> Sequence[Relation]:
"""Find all unresolved relations, where to_id is null."""
query = select(Relation).filter(Relation.to_id.is_(None))
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)]
+303
View File
@@ -0,0 +1,303 @@
"""Base repository implementation."""
from datetime import datetime
from typing import Type, Optional, Any, Sequence, TypeVar, List
from loguru import logger
from sqlalchemy import (
select,
func,
Select,
Executable,
inspect,
Result,
Column,
and_,
delete,
)
from sqlalchemy.exc import NoResultFound
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
from sqlalchemy.orm.interfaces import LoaderOption
from basic_memory import db
from basic_memory.models import Base
T = TypeVar("T", bound=Base)
class Repository[T: Base]:
"""Base repository implementation with generic CRUD operations."""
def __init__(self, session_maker: async_sessionmaker[AsyncSession], Model: Type[T]):
self.session_maker = session_maker
self.Model = Model
self.mapper = inspect(self.Model).mapper
self.primary_key: Column[Any] = self.mapper.primary_key[0]
self.valid_columns = [column.key for column in self.mapper.columns]
def get_model_data(self, entity_data):
model_data = {
k: v for k, v in entity_data.items() if k in self.valid_columns and v is not None
}
return model_data
async def select_by_id(self, session: AsyncSession, entity_id: int) -> Optional[T]:
"""Select an entity by ID using an existing session."""
query = (
select(self.Model)
.filter(self.primary_key == entity_id)
.options(*self.get_load_options())
)
result = await session.execute(query)
return result.scalars().one_or_none()
async def select_by_ids(self, session: AsyncSession, ids: List[int]) -> Sequence[T]:
"""Select multiple entities by IDs using an existing session."""
query = (
select(self.Model).where(self.primary_key.in_(ids)).options(*self.get_load_options())
)
result = await session.execute(query)
return result.scalars().all()
async def add(self, model: T) -> T:
"""
Add a model to the repository. This will also add related objects
:param model: the model to add
:return: the added model instance
"""
async with db.scoped_session(self.session_maker) as session:
session.add(model)
await session.flush()
# Query within same session
found = await self.select_by_id(session, model.id) # pyright: ignore [reportAttributeAccessIssue]
assert found is not None, "can't find model after session.add"
return found
async def add_all(self, models: List[T]) -> Sequence[T]:
"""
Add a list of models to the repository. This will also add related objects
:param models: the models to add
:return: the added models instances
"""
async with db.scoped_session(self.session_maker) as session:
session.add_all(models)
await session.flush()
# Query within same session
return await self.select_by_ids(session, [m.id for m in models]) # pyright: ignore [reportAttributeAccessIssue]
def select(self, *entities: Any) -> Select:
"""Create a new SELECT statement.
Returns:
A SQLAlchemy Select object configured with the provided entities
or this repository's model if no entities provided.
"""
if not entities:
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]:
"""Fetch records from the database with pagination."""
logger.debug(f"Finding all {self.Model.__name__} (skip={skip}, limit={limit})")
async with db.scoped_session(self.session_maker) as session:
query = select(self.Model).offset(skip).options(*self.get_load_options())
if limit:
query = query.limit(limit)
result = await session.execute(query)
items = result.scalars().all()
logger.debug(f"Found {len(items)} {self.Model.__name__} records")
return items
async def find_by_id(self, entity_id: int) -> Optional[T]:
"""Fetch an entity by its unique identifier."""
logger.debug(f"Finding {self.Model.__name__} by ID: {entity_id}")
async with db.scoped_session(self.session_maker) as session:
return await self.select_by_id(session, entity_id)
async def find_by_ids(self, ids: List[int]) -> Sequence[T]:
"""Fetch multiple entities by their identifiers in a single query."""
logger.debug(f"Finding {self.Model.__name__} by IDs: {ids}")
async with db.scoped_session(self.session_maker) as session:
return await self.select_by_ids(session, ids)
async def find_one(self, query: Select[tuple[T]]) -> Optional[T]:
"""Execute a query and retrieve a single record."""
logger.debug(f"Finding one {self.Model.__name__} with query: {query}")
# add in load options
query = query.options(*self.get_load_options())
result = await self.execute_query(query)
entity = result.scalars().one_or_none()
if entity:
logger.debug(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
else:
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}")
async with db.scoped_session(self.session_maker) as session:
# Only include valid columns that are provided in entity_data
model_data = self.get_model_data(data)
model = self.Model(**model_data)
session.add(model)
await session.flush()
return_instance = await self.select_by_id(session, model.id) # pyright: ignore [reportAttributeAccessIssue]
assert return_instance is not None, "can't find model after session.add"
return return_instance
async def create_all(self, data_list: List[dict]) -> Sequence[T]:
"""Create multiple records in a single transaction."""
logger.debug(f"Bulk creating {len(data_list)} {self.Model.__name__} instances")
async with db.scoped_session(self.session_maker) as session:
# Only include valid columns that are provided in entity_data
model_list = [
self.Model(
**self.get_model_data(d),
)
for d in data_list
]
session.add_all(model_list)
await session.flush()
return await self.select_by_ids(session, [model.id for model in model_list]) # pyright: ignore [reportAttributeAccessIssue]
async def update(self, entity_id: int, entity_data: dict | T) -> Optional[T]:
"""Update an entity with the given data."""
logger.debug(f"Updating {self.Model.__name__} {entity_id} with data: {entity_data}")
async with db.scoped_session(self.session_maker) as session:
try:
result = await session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
entity = result.scalars().one()
if isinstance(entity_data, dict):
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
logger.debug(f"Updated {self.Model.__name__}: {entity_id}")
return await self.select_by_id(session, entity.id) # pyright: ignore [reportAttributeAccessIssue]
except NoResultFound:
logger.debug(f"No {self.Model.__name__} found to update: {entity_id}")
return None
async def delete(self, entity_id: int) -> bool:
"""Delete an entity from the database."""
logger.debug(f"Deleting {self.Model.__name__}: {entity_id}")
async with db.scoped_session(self.session_maker) as session:
try:
result = await session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
entity = result.scalars().one()
await session.delete(entity)
logger.debug(f"Deleted {self.Model.__name__}: {entity_id}")
return True
except NoResultFound:
logger.debug(f"No {self.Model.__name__} found to delete: {entity_id}")
return False
async def delete_by_ids(self, ids: List[int]) -> int:
"""Delete records matching given IDs."""
logger.debug(f"Deleting {self.Model.__name__} by ids: {ids}")
async with db.scoped_session(self.session_maker) as session:
query = delete(self.Model).where(self.primary_key.in_(ids))
result = await session.execute(query)
logger.debug(f"Deleted {result.rowcount} records")
return result.rowcount
async def delete_by_fields(self, **filters: Any) -> bool:
"""Delete records matching given field values."""
logger.debug(f"Deleting {self.Model.__name__} by fields: {filters}")
async with db.scoped_session(self.session_maker) as session:
conditions = [getattr(self.Model, field) == value for field, value in filters.items()]
query = delete(self.Model).where(and_(*conditions))
result = await session.execute(query)
deleted = result.rowcount > 0
logger.debug(f"Deleted {result.rowcount} records")
return deleted
async def count(self, query: Executable | None = None) -> int:
"""Count entities in the database table."""
async with db.scoped_session(self.session_maker) as session:
if query is None:
query = select(func.count()).select_from(self.Model)
result = await session.execute(query)
scalar = result.scalar()
count = scalar if scalar is not None else 0
logger.debug(f"Counted {count} {self.Model.__name__} records")
return count
async def execute_query(self, query: Executable, use_query_options: bool = True) -> Result[Any]:
"""Execute a query asynchronously."""
query = query.options(*self.get_load_options()) if use_query_options else query
logger.debug(f"Executing query: {query}")
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(query)
logger.debug("Query executed successfully")
return result
def get_load_options(self) -> List[LoaderOption]:
"""Get list of loader options for eager loading relationships.
Override in subclasses to specify what to load."""
return []
@@ -0,0 +1,259 @@
"""Repository for search operations."""
import json
import time
from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional, Any, Dict
from loguru import logger
from sqlalchemy import text, Executable, Result
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from basic_memory import db
from basic_memory.models.search import CREATE_SEARCH_INDEX
from basic_memory.schemas.search import SearchItemType
@dataclass
class SearchIndexRow:
"""Search result with score and metadata."""
id: int
type: str
metadata: Optional[dict] = None
# date values
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = 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
entity_id: Optional[int] = None # observations
category: Optional[str] = None # observations
from_id: Optional[int] = None # relations
to_id: Optional[int] = None # relations
relation_type: Optional[str] = None # relations
def to_insert(self):
return {
"id": self.id,
"title": self.title,
"content": self.content,
"permalink": self.permalink,
"file_path": self.file_path,
"type": self.type,
"metadata": json.dumps(self.metadata),
"from_id": self.from_id,
"to_id": self.to_id,
"relation_type": self.relation_type,
"entity_id": self.entity_id,
"category": self.category,
"created_at": self.created_at if self.created_at else None,
"updated_at": self.updated_at if self.updated_at else None,
}
class SearchRepository:
"""Repository for search index operations."""
def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
self.session_maker = session_maker
async def init_search_index(self):
"""Create or recreate the search index."""
async with db.scoped_session(self.session_maker) as session:
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
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.
"""
# List of special characters that need quoting
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
term = term.replace('"', '""')
return f'"{term}"'
return term
async def search(
self,
search_text: Optional[str] = None,
permalink: Optional[str] = None,
permalink_match: Optional[str] = None,
title: Optional[str] = None,
types: List[SearchItemType] = None,
after_date: datetime = None,
entity_types: List[str] = None,
limit: int = 10,
) -> List[SearchIndexRow]:
"""Search across all indexed content with fuzzy matching."""
conditions = []
params = {}
# 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}*"
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}*"
conditions.append("title MATCH :text")
# Handle permalink exact search
if permalink:
params["permalink"] = permalink
conditions.append("permalink = :permalink")
# Handle permalink match search, supports *
if permalink_match:
params["permalink"] = self._quote_search_term(permalink_match)
conditions.append("permalink MATCH :permalink")
# Handle type filter
if types:
type_list = ", ".join(f"'{t.value}'" for t in types)
conditions.append(f"type IN ({type_list})")
# Handle entity type filter
if entity_types:
entity_type_list = ", ".join(f"'{t}'" for t in entity_types)
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({entity_type_list})")
# Handle date filter using datetime() for proper comparison
if after_date:
params["after_date"] = after_date
conditions.append("datetime(created_at) > datetime(:after_date)")
# set limit on search query
params["limit"] = limit
# Build WHERE clause
where_clause = " AND ".join(conditions) if conditions else "1=1"
sql = f"""
SELECT
id,
title,
permalink,
file_path,
type,
metadata,
from_id,
to_id,
relation_type,
entity_id,
content,
category,
created_at,
updated_at,
bm25(search_index) as score
FROM search_index
WHERE {where_clause}
ORDER BY score ASC
LIMIT :limit
"""
#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()
results = [
SearchIndexRow(
id=row.id,
title=row.title,
permalink=row.permalink,
file_path=row.file_path,
type=row.type,
score=row.score,
metadata=json.loads(row.metadata),
from_id=row.from_id,
to_id=row.to_id,
relation_type=row.relation_type,
entity_id=row.entity_id,
content=row.content,
category=row.category,
created_at=row.created_at,
updated_at=row.updated_at,
)
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}")
return results
async def index_item(
self,
search_index_row: SearchIndexRow,
):
"""Index or update a single item."""
async with db.scoped_session(self.session_maker) as session:
# Delete existing record if any
await session.execute(
text("DELETE FROM search_index WHERE permalink = :permalink"),
{"permalink": search_index_row.permalink},
)
# Insert new record
await session.execute(
text("""
INSERT INTO search_index (
id, title, content, permalink, file_path, type, metadata,
from_id, to_id, relation_type,
entity_id, category,
created_at, updated_at
) VALUES (
:id, :title, :content, :permalink, :file_path, :type, :metadata,
:from_id, :to_id, :relation_type,
:entity_id, :category,
:created_at, :updated_at
)
"""),
search_index_row.to_insert(),
)
logger.debug(f"indexed permalink {search_index_row.permalink}")
await session.commit()
async def delete_by_permalink(self, permalink: str):
"""Delete an item from the search index."""
async with db.scoped_session(self.session_maker) as session:
await session.execute(
text("DELETE FROM search_index WHERE permalink = :permalink"),
{"permalink": permalink},
)
await session.commit()
async def execute_query(
self,
query: Executable,
params: Optional[Dict[str, Any]] = None,
) -> Result[Any]:
"""Execute a query asynchronously."""
#logger.debug(f"Executing query: {query}")
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)
end_time = time.perf_counter()
elapsed_time = end_time - start_time
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
return result
+73
View File
@@ -0,0 +1,73 @@
"""Knowledge graph schema exports.
This module exports all schema classes to simplify imports.
Rather than importing from individual schema files, you can
import everything from basic_memory.schemas.
"""
# Base types and models
from basic_memory.schemas.base import (
Observation,
EntityType,
RelationType,
Relation,
Entity,
)
# Delete operation models
from basic_memory.schemas.delete import (
DeleteEntitiesRequest,
)
# Request models
from basic_memory.schemas.request import (
SearchNodesRequest,
GetEntitiesRequest,
CreateRelationsRequest, UpdateEntityRequest,
)
# Response models
from basic_memory.schemas.response import (
SQLAlchemyModel,
ObservationResponse,
RelationResponse,
EntityResponse,
EntityListResponse,
SearchNodesResponse,
DeleteEntitiesResponse,
)
# Discovery and analytics models
from basic_memory.schemas.discovery import (
EntityTypeList,
ObservationCategoryList, TypedEntityList,
)
# For convenient imports, export all models
__all__ = [
# Base
"Observation",
"EntityType",
"RelationType",
"Relation",
"Entity",
# Requests
"SearchNodesRequest",
"GetEntitiesRequest",
"CreateRelationsRequest",
"UpdateEntityRequest",
# Responses
"SQLAlchemyModel",
"ObservationResponse",
"RelationResponse",
"EntityResponse",
"EntityListResponse",
"SearchNodesResponse",
"DeleteEntitiesResponse",
# Delete Operations
"DeleteEntitiesRequest",
# Discovery and Analytics
"EntityTypeList",
"ObservationCategoryList",
"TypedEntityList"
]
+216
View File
@@ -0,0 +1,216 @@
"""Core pydantic models for basic-memory entities, observations, and relations.
This module defines the foundational data structures for the knowledge graph system.
The graph consists of entities (nodes) connected by relations (edges), where each
entity can have multiple observations (facts) attached to it.
Key Concepts:
1. Entities are nodes storing factual observations
2. Relations are directed edges between entities using active voice verbs
3. Observations are atomic facts/notes about an entity
4. Everything is stored in both SQLite and markdown files
"""
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 basic_memory.utils import generate_permalink
def to_snake_case(name: str) -> str:
"""Convert a string to snake_case.
Examples:
BasicMemory -> basic_memory
Memory Service -> memory_service
memory-service -> memory_service
Memory_Service -> memory_service
"""
name = name.strip()
# Replace spaces and hyphens and . with underscores
s1 = re.sub(r"[\s\-\\.]", "_", name)
# Insert underscore between camelCase
s2 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1)
# Convert to lowercase
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):
raise ValueError("Timeframe must be a string")
# Parse relative time expression
parsed = parse(timeframe)
if not parsed:
raise ValueError(f"Could not parse timeframe: {timeframe}")
# Convert to duration
now = datetime.now()
if parsed > now:
raise ValueError("Timeframe cannot be in the future")
# Could format the duration back to our standard format
days = (now - parsed).days
# Could enforce reasonable limits
if days > 365:
raise ValueError("Timeframe should be <= 1 year")
return f"{days}d"
TimeFrame = Annotated[
str,
BeforeValidator(validate_timeframe)
]
PathId = Annotated[str, BeforeValidator(validate_path_format)]
"""Unique identifier in format '{path}/{normalized_name}'."""
EntityType = Annotated[str, BeforeValidator(to_snake_case), MinLen(1), MaxLen(200)]
"""Classification of entity (e.g., 'person', 'project', 'concept'). """
ALLOWED_CONTENT_TYPES = {
"text/markdown",
"text/plain",
"application/pdf",
"image/jpeg",
"image/png",
"image/svg+xml",
}
ContentType = Annotated[
str,
BeforeValidator(str.lower),
Field(pattern=r"^[\w\-\+\.]+/[\w\-\+\.]+$"),
Field(json_schema_extra={"examples": list(ALLOWED_CONTENT_TYPES)}),
]
RelationType = Annotated[str, MinLen(1), MaxLen(200)]
"""Type of relationship between entities. Always use active voice present tense."""
ObservationStr = Annotated[
str,
BeforeValidator(str.strip), # Clean whitespace
MinLen(1), # Ensure non-empty after stripping
MaxLen(1000), # Keep reasonable length
]
class Observation(BaseModel):
"""A single observation with category, content, and optional context."""
category: ObservationCategory
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.
Relations are directed connections stored in active voice (e.g., "created", "depends_on").
The from_permalink represents the source or actor entity, while to_permalink represents the target
or recipient entity.
"""
from_id: PathId
to_id: PathId
relation_type: RelationType
context: Optional[str] = None
class Entity(BaseModel):
"""Represents a node in our knowledge graph - could be a person, project, concept, etc.
Each entity has:
- A file path (e.g., "people/jane-doe.md")
- An entity type (for classification)
- A list of observations (facts/notes about the entity)
- Optional relations to other entities
- Optional description for high-level overview
"""
title: str
content: Optional[str] = None
folder: str
entity_type: EntityType = "note"
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"
)
@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"
@property
def permalink(self) -> PathId:
"""Get the path ID in format {snake_case_title}."""
return 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)
if not path.exists():
return None
mime_type, _ = mimetypes.guess_type(path.name)
entity.content_type = mime_type or "text/plain"
return entity
+38
View File
@@ -0,0 +1,38 @@
"""Delete operation schemas for the knowledge graph.
This module defines the request schemas for removing entities, relations,
and observations from the knowledge graph. Each operation has specific
implications and safety considerations.
Deletion Hierarchy:
1. Entity deletion removes the entity and all its relations
2. Relation deletion only removes the connection between entities
3. Observation deletion preserves entity and relations
Key Considerations:
- All deletions are permanent
- Entity deletions cascade to relations
- Files are removed along with entities
- Operations are atomic - they fully succeed or fail
"""
from typing import List, Annotated
from annotated_types import MinLen
from pydantic import BaseModel
from basic_memory.schemas.base import Relation, Observation, PathId
class DeleteEntitiesRequest(BaseModel):
"""Delete one or more entities from the knowledge graph.
This operation:
1. Removes the entity from the database
2. Deletes all observations attached to the entity
3. Removes all relations where the entity is source or target
4. Deletes the corresponding markdown file
"""
permalinks: Annotated[List[PathId], MinLen(1)]
+25
View File
@@ -0,0 +1,25 @@
"""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")
+111
View File
@@ -0,0 +1,111 @@
"""Schemas for memory context."""
from datetime import datetime
from typing import Dict, List, Any, Optional, Annotated
import pydantic
from annotated_types import MinLen, MaxLen
from pydantic import BaseModel, field_validator, Field, BeforeValidator, TypeAdapter, AnyUrl
from basic_memory.schemas.search import SearchItemType
def normalize_memory_url(url: str) -> str:
"""Normalize a MemoryUrl string.
Args:
url: A path like "specs/search" or "memory://specs/search"
Returns:
Normalized URL starting with memory://
Examples:
>>> normalize_memory_url("specs/search")
'memory://specs/search'
>>> normalize_memory_url("memory://specs/search")
'memory://specs/search'
"""
clean_path = url.removeprefix("memory://")
return f"memory://{clean_path}"
MemoryUrl = Annotated[
str,
BeforeValidator(str.strip), # Clean whitespace
MinLen(1),
MaxLen(2028),
]
memory_url = TypeAdapter(MemoryUrl)
def memory_url_path(url: memory_url) -> str:
"""
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
unchanged.
:param url: A MemoryUrl object representing the URL with a "memory://" prefix.
:type url: MemoryUrl
:return: A string representing the URL with the "memory://" prefix removed.
:rtype: str
"""
return url.removeprefix("memory://")
class EntitySummary(BaseModel):
"""Simplified entity representation."""
permalink: str
title: str
file_path: str
created_at: datetime
class RelationSummary(BaseModel):
"""Simplified relation representation."""
permalink: str
type: str
from_id: str
to_id: Optional[str] = None
class ObservationSummary(BaseModel):
"""Simplified observation representation."""
permalink: str
category: str
content: str
class MemoryMetadata(BaseModel):
"""Simplified response metadata."""
uri: Optional[str] = None
types: Optional[List[SearchItemType]] = None
depth: int
timeframe: str
generated_at: datetime
total_results: int
total_relations: int
class GraphContext(BaseModel):
"""Complete context response."""
# Direct matches
primary_results: List[EntitySummary | RelationSummary | ObservationSummary] = Field(
description="results directly matching URI"
)
# Related entities
related_results: List[EntitySummary | RelationSummary | ObservationSummary] = Field(
description="related results"
)
# Context metadata
metadata: MemoryMetadata
+77
View File
@@ -0,0 +1,77 @@
"""Request schemas for interacting with the knowledge graph."""
from typing import List, Optional, Annotated, Dict, Any
from annotated_types import MaxLen, MinLen
from pydantic import BaseModel
from basic_memory.schemas.base import (
Observation,
Entity,
Relation,
PathId,
ObservationCategory,
EntityType,
)
class SearchNodesRequest(BaseModel):
"""Search for entities in the knowledge graph.
The search looks across multiple fields:
- Entity title
- Entity types
- summary
- file content
- Observations
Features:
- Case-insensitive matching
- Partial word matches
- Returns full entity objects with relations
- Includes all matching entities
- If a category is specified, only entities with that category are returned
Example Queries:
- "memory" - Find entities related to memory systems
- "SQLite" - Find database-related components
- "test" - Find test-related entities
- "implementation" - Find concrete implementations
- "service" - Find service components
Note: Currently uses SQL ILIKE for matching. Wildcard (*) searches
and full-text search capabilities are planned for future versions.
"""
query: Annotated[str, MinLen(1), MaxLen(200)]
category: Optional[ObservationCategory] = None
class GetEntitiesRequest(BaseModel):
"""Retrieve specific entities by their IDs.
Used to load complete entity details including all observations
and relations. Particularly useful for following relations
discovered through search.
"""
permalinks: Annotated[List[PathId], MinLen(1)]
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
+220
View File
@@ -0,0 +1,220 @@
"""Response schemas for knowledge graph operations.
This module defines the response formats for all knowledge graph operations.
Each response includes complete information about the affected entities,
including IDs that can be used in subsequent operations.
Key Features:
1. Every created/updated object gets an ID
2. Relations are included with their parent entities
3. Responses include everything needed for next operations
4. Bulk operations return all affected items
"""
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
class SQLAlchemyModel(BaseModel):
"""Base class for models that read from SQLAlchemy attributes.
This base class handles conversion of SQLAlchemy model attributes
to Pydantic model fields. All response models extend this to ensure
proper handling of database results.
"""
model_config = ConfigDict(from_attributes=True)
class ObservationResponse(Observation, SQLAlchemyModel):
"""Schema for observation data returned from the service.
Each observation gets a unique ID that can be used for later
reference or deletion.
Example Response:
{
"category": "feature",
"content": "Added support for async operations",
"context": "Initial database design meeting"
}
"""
class RelationResponse(Relation, SQLAlchemyModel):
"""Response schema for relation operations.
Extends the base Relation model with a unique ID that can be
used for later modification or deletion.
Example Response:
{
"from_id": "test/memory_test",
"to_id": "component/memory-service",
"relation_type": "validates",
"context": "Comprehensive test suite"
}
"""
from_id: PathId = Field(
# use the permalink from the associated Entity
# or the from_id value
validation_alias=AliasChoices(
AliasPath("from_entity", "permalink"),
"from_id",
)
)
to_id: Optional[PathId] = Field(
# use the permalink from the associated Entity
# or the to_id value
validation_alias=AliasChoices(
AliasPath("to_entity", "permalink"),
"to_id",
), default=None
)
to_name: Optional[PathId] = Field(
# use the permalink from the associated Entity
# or the to_id value
validation_alias=AliasChoices(
AliasPath("to_entity", "title"),
"to_name",
), default=None
)
class EntityResponse(SQLAlchemyModel):
"""Complete entity data returned from the service.
This is the most comprehensive entity view, including:
1. Basic entity details (id, name, type)
2. All observations with their IDs
3. All relations with their IDs
4. Optional description
Example Response:
{
"permalink": "component/memory-service",
"file_path": "MemoryService",
"entity_type": "component",
"entity_metadata": {}
"content_type: "text/markdown"
"observations": [
{
"category": "feature",
"content": "Uses SQLite storage"
"context": "Initial design"
},
{
"category": "feature",
"content": "Implements async operations"
"context": "Initial design"
}
],
"relations": [
{
"from_id": "test/memory-test",
"to_id": "component/memory-service",
"relation_type": "validates",
"context": "Main test suite"
}
]
}
"""
permalink: PathId
title: str
file_path: str
entity_type: EntityType
entity_metadata: Optional[Dict] = None
content_type: ContentType
observations: List[ObservationResponse] = []
relations: List[RelationResponse] = []
class EntityListResponse(SQLAlchemyModel):
"""Response for create_entities operation.
Returns complete information about entities returned from the service,
including their permalinks, observations,
and any established relations.
Example Response:
{
"entities": [
{
"permalink": "component/search_service",
"title": "SearchService",
"entity_type": "component",
"description": "Knowledge graph search",
"observations": [
{
"content": "Implements full-text search"
}
],
"relations": []
},
{
"permalink": "document/api_docs",
"title": "API_Documentation",
"entity_type": "document",
"description": "API Reference",
"observations": [
{
"content": "Documents REST endpoints"
}
],
"relations": []
}
]
}
"""
entities: List[EntityResponse]
class SearchNodesResponse(SQLAlchemyModel):
"""Response for search operation.
Returns matching entities with their complete information,
plus the original query for reference.
Example Response:
{
"matches": [
{
"permalink": "component/memory-service",
"title": "MemoryService",
"entity_type": "component",
"description": "Core service",
"observations": [...],
"relations": [...]
}
],
"query": "memory"
}
Note: Each entity in matches includes full details
just like EntityResponse.
"""
matches: List[EntityResponse]
query: str
class DeleteEntitiesResponse(SQLAlchemyModel):
"""Response indicating successful entity deletion.
A simple boolean response confirming the delete operation
completed successfully.
Example Response:
{
"deleted": true
}
"""
deleted: bool
+117
View File
@@ -0,0 +1,117 @@
"""Search schemas for Basic Memory.
The search system supports three primary modes:
1. Exact permalink lookup
2. Pattern matching with *
3. Full-text search across content
"""
from typing import Optional, List, Union
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, field_validator
class SearchItemType(str, Enum):
"""Types of searchable items."""
ENTITY = "entity"
OBSERVATION = "observation"
RELATION = "relation"
class SearchQuery(BaseModel):
"""Search query parameters.
Use ONE of these primary search modes:
- permalink: Exact permalink match
- permalink_match: Path pattern with *
- text: Full-text search of title/content
Optionally filter results by:
- types: Limit to specific item types
- entity_types: Limit to specific entity types
- after_date: Only items after date
"""
# Primary search modes (use ONE of these)
permalink: Optional[str] = None # Exact permalink match
permalink_match: Optional[str] = None # Exact permalink match
text: Optional[str] = None # Full-text search
title: Optional[str] = None # title only search
# Optional filters
types: Optional[List[SearchItemType]] = None # Filter by item type
entity_types: Optional[List[str]] = None # Filter by entity type
after_date: Optional[Union[datetime, str]] = None # Time-based filter
@field_validator("after_date")
@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
def no_criteria(self) -> bool:
return (
self.permalink is None
and self.permalink_match is None
and self.text is None
and self.after_date is None
and self.types is None
and self.entity_types is None
)
class SearchResult(BaseModel):
"""Search result with score and metadata."""
id: int
type: SearchItemType
score: Optional[float] = None
metadata: Optional[dict] = None
# Common fields
permalink: Optional[str] = None
file_path: Optional[str] = None
# Type-specific fields
entity_id: Optional[int] = None # For observations
category: Optional[str] = None # For observations
from_id: Optional[int] = None # For relations
to_id: Optional[int] = None # For relations
relation_type: Optional[str] = None # For relations
class RelatedResult(BaseModel):
type: SearchItemType
id: int
title: str
permalink: str
depth: int
root_id: int
created_at: datetime
from_id: Optional[int] = None
to_id: Optional[int] = None
relation_type: Optional[str] = None
category: Optional[str] = None
entity_id: Optional[int] = None
class SearchResponse(BaseModel):
"""Wrapper for search results."""
results: List[SearchResult]
# Schema for future advanced search endpoint
class AdvancedSearchQuery(BaseModel):
"""Advanced full-text search with explicit FTS5 syntax."""
query: str # Raw FTS5 query (e.g., "foo AND bar")
types: Optional[List[SearchItemType]] = None
entity_types: Optional[List[str]] = None
after_date: Optional[Union[datetime, str]] = None
+11
View File
@@ -0,0 +1,11 @@
"""Services package."""
from .service import BaseService
from .file_service import FileService
from .entity_service import EntityService
__all__ = [
"BaseService",
"FileService",
"EntityService",
]
@@ -0,0 +1,274 @@
"""Service for building rich context from the knowledge graph."""
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import List, Optional, Tuple
from loguru import logger
from sqlalchemy import text
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.schemas.memory import MemoryUrl, memory_url_path
from basic_memory.schemas.search import SearchItemType
@dataclass
class ContextResultRow:
type: str
id: int
title: str
permalink: str
file_path: str
depth: int
root_id: int
created_at: datetime
from_id: Optional[int] = None
to_id: Optional[int] = None
relation_type: Optional[str] = None
content: Optional[str] = None
category: Optional[str] = None
entity_id: Optional[int] = None
class ContextService:
"""Service for building rich context from memory:// URIs.
Handles three types of context building:
1. Direct permalink lookup - exact match on path
2. Pattern matching - using * wildcards
3. Special modes via params (e.g., 'related')
"""
def __init__(
self,
search_repository: SearchRepository,
entity_repository: EntityRepository,
):
self.search_repository = search_repository
self.entity_repository = entity_repository
async def build_context(
self,
memory_url: MemoryUrl = None,
types: List[SearchItemType] = None,
depth: int = 1,
since: Optional[datetime] = None,
max_results: 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}'"
)
if memory_url:
path = memory_url_path(memory_url)
# Pattern matching - use search
if "*" in path:
logger.debug(f"Pattern search for '{path}'")
primary = await self.search_repository.search(
permalink_match=path
)
# Direct lookup for exact path
else:
logger.debug(f"Direct lookup for '{path}'")
primary = await self.search_repository.search(permalink=path)
else:
logger.debug(f"Build context for '{types}'")
primary = await self.search_repository.search(types=types)
# Get type_id pairs for traversal
type_id_pairs = [(r.type, r.id) for r in primary] if primary else []
logger.debug(f"found primary type_id_pairs: {len(type_id_pairs)}")
# Find related content
related = await self.find_related(
type_id_pairs, max_depth=depth, since=since, max_results=max_results
)
logger.debug(f"Found {len(related)} related results")
for r in related:
logger.debug(f"Found related {r.type}: {r.permalink}")
# Build response
return {
"primary_results": primary,
"related_results": related,
"metadata": {
"uri": memory_url_path(memory_url) if memory_url else None,
"types": types if types else None,
"depth": depth,
"timeframe": since.isoformat() if since else None,
"generated_at": datetime.now(timezone.utc).isoformat(),
"matched_results": len(primary),
"total_results": len(primary) + len(related),
"total_relations": sum(1 for r in related if r.type == SearchItemType.RELATION),
},
}
async def find_related(
self,
type_id_pairs: List[Tuple[str, int]],
max_depth: int = 1,
since: Optional[datetime] = None,
max_results: int = 10,
) -> List[ContextResultRow]:
"""Find items connected through relations.
Uses recursive CTE to find:
- Connected entities
- Their observations
- Relations that connect them
"""
if not type_id_pairs:
return []
logger.debug(f"Finding connected items for {len(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])
# Parameters for bindings
params = {"max_depth": max_depth, "max_results": max_results}
if since:
params["since_date"] = since.isoformat()
# Build date filter
date_filter = "AND base.created_at >= :since_date" if since else ""
r1_date_filter = "AND r.created_at >= :since_date" if since else ""
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}
UNION -- Changed from UNION ALL
-- 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
-- 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
""")
result = await self.search_repository.execute_query(query, params=params)
rows = result.all()
context_rows = [
ContextResultRow(
type=row.type,
id=row.id,
title=row.title,
permalink=row.permalink,
file_path=row.file_path,
from_id=row.from_id,
to_id=row.to_id,
relation_type=row.relation_type,
content=row.content,
category=row.category,
entity_id=row.entity_id,
depth=row.depth,
root_id=row.root_id,
created_at=row.created_at,
)
for row in rows
]
return context_rows
+281
View File
@@ -0,0 +1,281 @@
"""Service for managing entities in the database."""
from pathlib import Path
from typing import Sequence, List, Optional
import frontmatter
from frontmatter import Post
from loguru import logger
from sqlalchemy.exc import IntegrityError
from basic_memory.markdown import EntityMarkdown
from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
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.services.exceptions import EntityNotFoundError, EntityCreationError
from basic_memory.services import FileService
from basic_memory.services import BaseService
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.markdown.entity_parser import EntityParser
class EntityService(BaseService[EntityModel]):
"""Service for managing entities in the database."""
def __init__(
self,
entity_parser: EntityParser,
entity_repository: EntityRepository,
observation_repository: ObservationRepository,
relation_repository: RelationRepository,
file_service: FileService,
link_resolver: LinkResolver,
):
super().__init__(entity_repository)
self.observation_repository = observation_repository
self.relation_repository = relation_repository
self.entity_parser = entity_parser
self.file_service = file_service
self.link_resolver = link_resolver
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)
"""
logger.debug(f"Creating or updating entity: {schema}")
# Try to find existing entity using smart resolution
existing = await self.link_resolver.resolve_link(schema.permalink)
if existing:
logger.debug(f"Found existing entity: {existing.permalink}")
return await self.update_entity(existing, schema), False
else:
# Create new entity
return await self.create_entity(schema), True
async def create_entity(self, schema: EntitySchema) -> EntityModel:
"""Create a new entity and write to filesystem."""
logger.debug(f"Creating entity: {schema.permalink}")
# get file path
file_path = Path(schema.file_path)
if await self.file_service.exists(file_path):
raise EntityCreationError(
f"file_path {file_path} for entity {schema.permalink} already exists: {file_path}"
)
post = await schema_to_markdown(schema)
# write file
final_content = frontmatter.dumps(post, sort_keys=False)
checksum = await self.file_service.write_file(file_path, final_content)
# 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
)
# add relations
entity = await self.update_entity_relations(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}")
# get file path
file_path = Path(entity.file_path)
post = await schema_to_markdown(schema)
# write file
final_content = frontmatter.dumps(post)
checksum = await self.file_service.write_file(file_path, final_content)
# parse entity from file
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
)
# add relations
await self.update_entity_relations(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:
"""Delete entity and its file."""
logger.debug(f"Deleting entity: {permalink}")
try:
# Get entity first for file deletion
entity = await self.get_by_permalink(permalink)
# Delete file first
await self.file_service.delete_entity_file(entity)
# Delete from DB (this will cascade to observations/relations)
return await self.repository.delete(entity.id)
except EntityNotFoundError:
logger.info(f"Entity not found: {permalink}")
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}")
db_entity = await self.repository.get_by_permalink(permalink)
if not db_entity:
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_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 create_entity_from_markdown(
self, file_path: Path, markdown: EntityMarkdown
) -> EntityModel:
"""Create entity and observations only.
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}")
model = entity_model_from_markdown(file_path, markdown)
# Mark as incomplete sync
model.checksum = None
return await self.add(model)
async def update_entity_and_observations(
self, file_path: Path | str, markdown: EntityMarkdown
) -> EntityModel:
"""Update entity fields and observations.
Updates everything except relations and sets null checksum
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}")
# Clear observations for entity
await self.observation_repository.delete_by_fields(entity_id=db_entity.id)
# add new observations
observations = [
Observation(
entity_id=db_entity.id,
content=obs.content,
category=obs.category,
context=obs.context,
tags=obs.tags,
)
for obs in markdown.observations
]
await self.observation_repository.add_all(observations)
# update values from markdown
db_entity = entity_model_from_markdown(file_path, markdown, db_entity)
# 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,
)
async def update_entity_relations(
self,
file_path: Path | str,
markdown: EntityMarkdown,
) -> EntityModel:
"""Update relations for entity"""
logger.debug(f"Updating relations for entity: {file_path}")
file_path = str(file_path)
db_entity = await self.repository.get_by_file_path(file_path)
# Clear existing relations first
await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
# Process each relation
for rel in markdown.relations:
# Resolve the target permalink
target_entity = await self.link_resolver.resolve_link(
rel.target,
)
# if the target is found, store the id
target_id = target_entity.id if target_entity else None
# if the target is found, store the title, otherwise add the target for a "forward link"
target_name = target_entity.title if target_entity else rel.target
# Create the relation
relation = Relation(
from_id=db_entity.id,
to_id=target_id,
to_name=target_name,
relation_type=rel.type,
context=rel.context,
)
try:
await self.relation_repository.add(relation)
except IntegrityError:
# Unique constraint violation - relation already exists
logger.debug(
f"Skipping duplicate relation {rel.type} from {db_entity.permalink} target: {rel.target}, type: {rel.type}"
)
continue
return await self.repository.get_by_file_path(file_path)
+15
View File
@@ -0,0 +1,15 @@
class FileOperationError(Exception):
"""Raised when file operations fail"""
pass
class EntityNotFoundError(Exception):
"""Raised when an entity cannot be found"""
pass
class EntityCreationError(Exception):
"""Raised when an entity cannot be created"""
pass
+213
View File
@@ -0,0 +1,213 @@
"""Service for file operations with checksum tracking."""
from pathlib import Path
from typing import Optional, Tuple
from loguru import logger
from basic_memory import file_utils
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
class FileService:
"""
Service for handling file operations.
Features:
- Consistent file writing with checksums
- Frontmatter management
- Atomic operations
- Error handling
"""
def __init__(
self,
base_path: Path,
markdown_processor: MarkdownProcessor,
):
self.base_path = base_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}"
# TODO move to tests
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
Args:
entity: Entity model to write
content: Optional new content (preserves existing if None)
expected_checksum: Optional checksum to verify file hasn't changed
Returns:
Tuple of (file path, new checksum)
Raises:
FileOperationError: If write fails
"""
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}")
async def read_entity_content(self, entity: EntityModel) -> str:
"""Get entity's content without frontmatter or structured sections (used to index for search)
Args:
entity: Entity to read content for
Returns:
Raw content without frontmatter, observations, or relations
Raises:
FileOperationError: If entity file doesn't exist
"""
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}")
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.
Args:
path: Path to check
Returns:
True if file exists, False otherwise
"""
try:
if path.is_absolute():
return path.exists()
else:
return (self.base_path / path).exists()
except Exception as e:
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.
Args:
path: Path where to write
content: Content to write
Returns:
Checksum of written content
Raises:
FileOperationError: If write fails
"""
path = path if path.is_absolute() else self.base_path / path
try:
# Ensure parent directory exists
await file_utils.ensure_directory(path.parent)
# Write content atomically
await file_utils.write_file_atomic(path, content)
# Compute and return checksum
checksum = await file_utils.compute_checksum(content)
logger.debug(f"wrote file: {path}, checksum: {checksum} content: \n{content}")
return checksum
except Exception as e:
logger.error(f"Failed to write file {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.
Args:
path: Path to read
Returns:
Tuple of (content, checksum)
Raises:
FileOperationError: If read fails
"""
path = path if path.is_absolute() else self.base_path / path
try:
content = path.read_text()
checksum = await file_utils.compute_checksum(content)
logger.debug(f"read file: {path}, checksum: {checksum}")
return content, checksum
except Exception as e:
logger.error(f"Failed to read file {path}: {e}")
raise FileOperationError(f"Failed to read file: {e}")
async def delete_file(self, path: Path) -> None:
"""
Delete file if it exists.
Args:
path: Path to delete
Raises:
FileOperationError: If deletion fails
"""
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}")
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)
+126
View File
@@ -0,0 +1,126 @@
"""Service for resolving markdown links to permalinks."""
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
class LinkResolver:
"""Service for resolving markdown links to permalinks.
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
"""
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
"""Initialize with repositories."""
self.entity_repository = entity_repository
self.search_service = search_service
async def resolve_link(self, link_text: str, use_search: bool = True) -> Optional[Entity]:
"""Resolve a markdown link to a permalink."""
logger.debug(f"Resolving link: {link_text}")
# Clean link text and extract any alias
clean_text, alias = self._normalize_link_text(link_text)
# 1. Try exact permalink match first (most efficient)
entity = await self.entity_repository.get_by_permalink(clean_text)
if entity:
logger.debug(f"Found exact permalink match: {entity.permalink}")
return entity
# 2. Try exact title match
entity = await self.entity_repository.get_by_title(clean_text)
if entity:
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
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
return None
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
"""Normalize link text and extract alias if present.
Args:
link_text: Raw link text from markdown
Returns:
Tuple of (normalized_text, alias or None)
"""
# Strip whitespace
text = link_text.strip()
# Remove enclosing brackets if present
if text.startswith("[[") and text.endswith("]]"):
text = text[2:-2]
# Handle Obsidian-style aliases (format: [[actual|alias]])
alias = None
if "|" in text:
text, alias = text.split("|", 1)
text = text.strip()
alias = alias.strip()
return text, alias
def _select_best_match(self, search_text: str, results: List[SearchResult]) -> Entity:
"""Select best match from search results.
Uses multiple criteria:
1. Word matches in title field
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()
# Score each result
scored_results = []
for result in results:
# Start with base score (lower is better)
score = result.score
# Parse path components
path_parts = result.permalink.lower().split("/")
last_part = path_parts[-1] if path_parts else ""
# Title word match boosts
term_matches = [term for term in terms if term in last_part]
if term_matches:
score *= 0.5 # Boost for each matching term
# Exact title match is best
if last_part == search_text.lower():
score *= 0.2
scored_results.append((score, result))
# Sort by score (lowest first) and return best
scored_results.sort(key=lambda x: x[0], reverse=True)
return scored_results[0][1]
+218
View File
@@ -0,0 +1,218 @@
"""Service for search operations."""
from typing import List, Optional, Set
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.services import FileService
from basic_memory.services.exceptions import FileOperationError
class SearchService:
"""Service for search operations.
Supports three primary search modes:
1. Exact permalink lookup
2. Pattern matching with * (e.g., 'specs/*')
3. Full-text search across title/content
"""
def __init__(
self,
search_repository: SearchRepository,
entity_repository: EntityRepository,
file_service: FileService,
):
self.repository = search_repository
self.entity_repository = entity_repository
self.file_service = file_service
async def init_search_index(self):
"""Create FTS5 virtual table if it doesn't exist."""
await self.repository.init_search_index()
async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None:
"""Reindex all content from database."""
logger.info("Starting full reindex")
# Clear and recreate search index
await self.init_search_index()
# Reindex all entities
logger.debug("Indexing entities")
entities = await self.entity_repository.find_all()
for entity in entities:
await self.index_entity(entity, background_tasks)
logger.info("Reindex complete")
async def search(
self, query: SearchQuery, context: Optional[List[str]] = None
) -> List[SearchResult]:
"""Search across all indexed content.
Supports three modes:
1. Exact permalink: finds direct matches for a specific path
2. Pattern match: handles * wildcards in paths
3. Text search: full-text search across title/content
"""
if query.no_criteria():
logger.debug("no criteria passed to query")
return []
logger.debug(f"Searching with query: {query}")
# permalink search
results = await self.repository.search(
search_text=query.text,
permalink=query.permalink,
permalink_match=query.permalink_match,
title=query.title,
types=query.types,
entity_types=query.entity_types,
after_date=query.after_date,
)
return results
def _generate_variants(self, text: str) -> Set[str]:
"""Generate text variants for better fuzzy matching.
Creates variations of the text to improve match chances:
- Original form
- Lowercase form
- Path segments (for permalinks)
- Common word boundaries
"""
variants = {text, text.lower()}
# Add path segments
if "/" in text:
variants.update(p.strip() for p in text.split("/") if p.strip())
# Add word boundaries
variants.update(w.strip() for w in text.lower().split() if w.strip())
# Add trigrams for fuzzy matching
variants.update(text[i : i + 3].lower() for i in range(len(text) - 2))
return variants
async def index_entity(
self,
entity: Entity,
background_tasks: Optional[BackgroundTasks] = None,
) -> None:
"""Index an entity and all its observations and relations.
Indexing structure:
1. Entities
- permalink: direct from entity (e.g., "specs/search")
- file_path: physical file location
2. Observations
- permalink: entity permalink + /observations/id (e.g., "specs/search/observations/123")
- file_path: parent entity's file (where observation is defined)
3. Relations (only index outgoing relations defined in this file)
- permalink: from_entity/relation_type/to_entity (e.g., "specs/search/implements/features/search-ui")
- file_path: source entity's file (where relation is defined)
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."""
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)
content_parts.extend(self._generate_variants(entity.permalink))
content_parts.extend(self._generate_variants(entity.file_path))
entity_content = "\n".join(p for p in content_parts if p and p.strip())
# Index entity
await self.repository.index_item(
SearchIndexRow(
id=entity.id,
type=SearchItemType.ENTITY.value,
title=entity.title,
content=entity_content,
permalink=entity.permalink,
file_path=entity.file_path,
metadata={
"entity_type": entity.entity_type,
},
created_at=entity.created_at.isoformat(),
updated_at=entity.updated_at.isoformat(),
)
)
# Index each observation with permalink
for obs in entity.observations:
# Index with parent entity's file path since that's where it's defined
await self.repository.index_item(
SearchIndexRow(
id=obs.id,
type=SearchItemType.OBSERVATION.value,
title=f"{obs.category}: {obs.content[:50]}...",
content=obs.content,
permalink=obs.permalink,
file_path=entity.file_path,
category=obs.category,
entity_id=entity.id,
metadata={
"tags": obs.tags,
},
created_at=entity.created_at.isoformat(),
updated_at=entity.updated_at.isoformat(),
)
)
# Only index outgoing relations (ones defined in this file)
for rel in entity.outgoing_relations:
# Create descriptive title showing the relationship
relation_title = (
f"{rel.from_entity.title}{rel.to_entity.title}"
if rel.to_entity
else f"{rel.from_entity.title}"
)
await self.repository.index_item(
SearchIndexRow(
id=rel.id,
title=relation_title,
content=rel.context or "",
permalink=rel.permalink,
file_path=entity.file_path,
type=SearchItemType.RELATION.value,
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(),
)
)
async def delete_by_permalink(self, path_id: str):
"""Delete an item from the search index."""
await self.repository.delete_by_permalink(path_id)
+36
View File
@@ -0,0 +1,36 @@
"""Base service class."""
from datetime import datetime
from typing import TypeVar, Generic, List, Sequence
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):
"""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)
+5
View File
@@ -0,0 +1,5 @@
from .file_change_scanner import FileChangeScanner
from .sync_service import SyncService
__all__ = ["SyncService", "FileChangeScanner"]
@@ -0,0 +1,162 @@
"""Service for detecting changes between filesystem and database."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Sequence
from loguru import logger
from basic_memory.file_utils import compute_checksum
from basic_memory.models import Entity
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.sync.utils import SyncReport
@dataclass
class FileState:
"""State of a file including file path, permalink and checksum info."""
file_path: str
permalink: str
checksum: str
@dataclass
class ScanResult:
"""Result of scanning a directory."""
# file_path -> checksum
files: Dict[str, str] = field(default_factory=dict)
# file_path -> error message
errors: Dict[str, str] = field(default_factory=dict)
class FileChangeScanner:
"""
Service for detecting changes between filesystem and database.
The filesystem is treated as the source of truth.
"""
def __init__(self, entity_repository: EntityRepository):
self.entity_repository = entity_repository
async def scan_directory(self, directory: Path) -> ScanResult:
"""
Scan directory for markdown files and their checksums.
Only processes .md files, logs and skips others.
Args:
directory: Directory to scan
Returns:
ScanResult containing found files and any errors
"""
logger.debug(f"Scanning directory: {directory}")
result = ScanResult()
if not directory.exists():
logger.debug(f"Directory does not exist: {directory}")
return result
for path in directory.rglob("*"):
if not path.is_file() or not path.name.endswith(".md"):
if path.is_file():
logger.debug(f"Skipping non-markdown file: {path}")
continue
try:
# Get relative path first - used in error reporting if needed
rel_path = str(path.relative_to(directory))
content = path.read_text()
checksum = await compute_checksum(content)
if checksum: # Only store valid checksums
result.files[rel_path] = checksum
else:
result.errors[rel_path] = "Failed to compute checksum"
except Exception as e:
rel_path = str(path.relative_to(directory))
result.errors[rel_path] = str(e)
logger.error(f"Failed to read {rel_path}: {e}")
logger.debug(f"Found {len(result.files)} markdown files")
if result.errors:
logger.warning(f"Encountered {len(result.errors)} errors while scanning")
return result
async def find_changes(
self, directory: Path, db_file_state: Dict[str, FileState]
) -> SyncReport:
"""Find changes between filesystem and database."""
# Get current files and checksums
scan_result = await self.scan_directory(directory)
current_files = scan_result.files
# Build report
report = SyncReport(total=len(current_files))
# Track potentially moved files by checksum
files_by_checksum = {} # checksum -> file_path
# First find potential new files and record checksums
for file_path, checksum in current_files.items():
logger.debug(f"{file_path} ({checksum[:8]})")
if file_path not in db_file_state:
# Could be new or could be the destination of a move
report.new.add(file_path)
files_by_checksum[checksum] = file_path
elif checksum != db_file_state[file_path].checksum:
report.modified.add(file_path)
report.checksums[file_path] = checksum
# Now detect moves and deletions
for db_file_path, db_state in db_file_state.items():
if db_file_path not in current_files:
if db_state.checksum in files_by_checksum:
# Found a move - file exists at new path with same checksum
new_path = files_by_checksum[db_state.checksum]
report.moves[db_file_path] = new_path
# Remove from new files since it's a move
report.new.remove(new_path)
else:
# Actually deleted
report.deleted.add(db_file_path)
# Log summary
logger.debug(f"Total files: {report.total}")
logger.debug(f"Changes found: {report.total_changes}")
logger.debug(f" New: {len(report.new)}")
logger.debug(f" Modified: {len(report.modified)}")
logger.debug(f" Moved: {len(report.moves)}")
logger.debug(f" Deleted: {len(report.deleted)}")
if scan_result.errors:
logger.warning("Files skipped due to errors:")
for file_path, error in scan_result.errors.items():
logger.warning(f" {file_path}: {error}")
return report
async def get_db_file_state(self, db_records: Sequence[Entity]) -> Dict[str, FileState]:
"""Get file_path and checksums from database.
Args:
db_records: database records
Returns:
Dict mapping file paths to FileState
:param db_records: the data from the db
"""
return {
r.file_path: FileState(
file_path=r.file_path, permalink=r.permalink, checksum=r.checksum
)
for r in db_records
}
async def find_knowledge_changes(self, directory: Path) -> SyncReport:
"""Find changes in knowledge directory."""
db_file_state = await self.get_db_file_state(await self.entity_repository.find_all())
return await self.find_changes(directory=directory, db_file_state=db_file_state)
+140
View File
@@ -0,0 +1,140 @@
"""Service for syncing files between filesystem and database."""
from pathlib import Path
from typing import Dict
from loguru import logger
from basic_memory.markdown import EntityParser, EntityMarkdown
from basic_memory.repository import EntityRepository, RelationRepository
from basic_memory.services import EntityService
from basic_memory.services.search_service import SearchService
from basic_memory.sync import FileChangeScanner
from basic_memory.sync.utils import SyncReport
class SyncService:
"""Syncs documents and knowledge files with database.
Implements two-pass sync strategy for knowledge files to handle relations:
1. First pass creates/updates entities without relations
2. Second pass processes relations after all entities exist
"""
def __init__(
self,
scanner: FileChangeScanner,
entity_service: EntityService,
entity_parser: EntityParser,
entity_repository: EntityRepository,
relation_repository: RelationRepository,
search_service: SearchService,
):
self.scanner = scanner
self.entity_service = entity_service
self.entity_parser = entity_parser
self.entity_repository = entity_repository
self.relation_repository = relation_repository
self.search_service = search_service
async def handle_entity_deletion(self, file_path: str):
"""Handle complete entity deletion including search index cleanup."""
# First get entity to get permalink before deletion
entity = await self.entity_repository.get_by_file_path(file_path)
if entity:
logger.debug(f"Deleting entity and cleaning up search index: {file_path}")
# Delete from db (this cascades to observations/relations)
await self.entity_service.delete_entity_by_file_path(file_path)
# Clean up search index
permalinks = (
[entity.permalink]
+ [o.permalink for o in entity.observations]
+ [r.permalink for r in entity.relations]
)
logger.debug(f"Deleting from search index: {permalinks}")
for permalink in permalinks:
await self.search_service.delete_by_permalink(permalink)
else:
logger.debug(f"No entity found to delete: {file_path}")
async def sync(self, directory: Path) -> SyncReport:
"""Sync knowledge files with database."""
changes = await self.scanner.find_knowledge_changes(directory)
logger.info(f"Found {changes.total_changes} knowledge changes")
# Handle moves first
for old_path, new_path in changes.moves.items():
logger.debug(f"Moving entity: {old_path} -> {new_path}")
entity = await self.entity_repository.get_by_file_path(old_path)
if entity:
# Update file_path but keep the same permalink for link stability
updated = await self.entity_repository.update(
entity.id, {"file_path": new_path, "checksum": changes.checksums[new_path]}
)
# update search index
await self.search_service.index_entity(updated)
# Handle deletions next
# remove rows from db for files no longer present
for file_path in changes.deleted:
await self.handle_entity_deletion(file_path)
# Parse files that need updating
parsed_entities: Dict[str, EntityMarkdown] = {}
for file_path in [*changes.new, *changes.modified]:
entity_markdown = await self.entity_parser.parse_file(directory / file_path)
parsed_entities[file_path] = entity_markdown
# First pass: Create/update entities
# entities will have a null checksum to indicate they are not complete
for file_path, entity_markdown in parsed_entities.items():
# if the file is new, create an entity
if file_path in changes.new:
logger.debug(f"Creating new entity_markdown: {file_path}")
await self.entity_service.create_entity_from_markdown(
file_path, entity_markdown
)
# otherwise we need to update the entity and observations
else:
logger.debug(f"Updating entity_markdown: {file_path}")
await self.entity_service.update_entity_and_observations(
file_path, entity_markdown
)
# Second pass
for file_path, entity_markdown in parsed_entities.items():
logger.debug(f"Updating relations for: {file_path}")
# Process relations
checksum = changes.checksums[file_path]
entity = await self.entity_service.update_entity_relations(
file_path, entity_markdown
)
# add to search index
await self.search_service.index_entity(entity)
# Set final checksum to mark sync complete
await self.entity_repository.update(entity.id, {"checksum": checksum})
# Third pass: Try to resolve any forward references
logger.debug("Attempting to resolve forward references")
for relation in await self.relation_repository.find_unresolved_relations():
target_entity = await self.entity_service.link_resolver.resolve_link(relation.to_name)
# check we found a link that is not the source
if target_entity and target_entity.id != relation.from_id:
logger.debug(f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}")
await self.relation_repository.update(relation.id, {
"to_id": target_entity.id,
"to_name": target_entity.title # Update to actual title
})
# update search index
await self.search_service.index_entity(target_entity)
return changes
+66
View File
@@ -0,0 +1,66 @@
"""Types and utilities for file sync."""
from dataclasses import dataclass, field
from typing import Set, Dict, Optional
from watchfiles import Change
from basic_memory.services.file_service import FileService
@dataclass
class FileChange:
"""A change to a file detected by the watch service.
Attributes:
change_type: Type of change (added, modified, deleted)
path: Path to the file
checksum: File checksum (None for deleted files)
"""
change_type: Change
path: str
checksum: Optional[str] = None
@classmethod
async def from_path(cls, path: str, change_type: Change, file_service: FileService) -> "FileChange":
"""Create FileChange from a path, computing checksum if file exists.
Args:
path: Path to the file
change_type: Type of change detected
file_service: Service to read file and compute checksum
Returns:
FileChange with computed checksum for non-deleted files
"""
file_path = file_service.path(path)
content, checksum = await file_service.read_file(file_path) if change_type != Change.deleted else (None, None)
return cls(path=file_path, change_type=change_type, checksum=checksum)
@dataclass
class SyncReport:
"""Report of file changes found compared to database state.
Attributes:
total: Total number of files in directory being synced
new: Files that exist on disk but not in database
modified: Files that exist in both but have different checksums
deleted: Files that exist in database but not on disk
moves: Files that have been moved from one location to another
checksums: Current checksums for files on disk
"""
total: int = 0
new: Set[str] = field(default_factory=set)
modified: Set[str] = field(default_factory=set)
deleted: Set[str] = field(default_factory=set)
moves: Dict[str, str] = field(default_factory=dict) # old_path -> new_path
checksums: Dict[str, str] = field(default_factory=dict) # path -> checksum
@property
def total_changes(self) -> int:
"""Total number of changes."""
return len(self.new) + len(self.modified) + len(self.deleted) + len(self.moves)
@property
def syned_files(self) -> int:
"""Total number of files synced."""
return len(self.new) + len(self.modified) + len(self.moves)
+197
View File
@@ -0,0 +1,197 @@
"""Watch service for Basic Memory."""
import dataclasses
from loguru import logger
from pydantic import BaseModel
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from rich import box
from rich.console import Console
from rich.live import Live
from rich.table import Table
from watchfiles import awatch, Change
import os
from basic_memory.config import ProjectConfig
from basic_memory.sync.sync_service import SyncService
from basic_memory.services.file_service import FileService
class WatchEvent(BaseModel):
timestamp: datetime
path: str
action: str # new, delete, etc
status: str # success, error
checksum: Optional[str]
error: Optional[str] = None
class WatchServiceState(BaseModel):
# Service status
running: bool = False
start_time: datetime = dataclasses.field(default_factory=datetime.now)
pid: int = dataclasses.field(default_factory=os.getpid)
# Stats
error_count: int = 0
last_error: Optional[datetime] = None
last_scan: Optional[datetime] = None
# File counts
synced_files: int = 0
# Recent activity
recent_events: List[WatchEvent] = dataclasses.field(default_factory=list)
def add_event(
self,
path: str,
action: str,
status: str,
checksum: Optional[str] = None,
error: Optional[str] = None,
) -> WatchEvent:
event = WatchEvent(
timestamp=datetime.now(),
path=path,
action=action,
status=status,
checksum=checksum,
error=error,
)
self.recent_events.insert(0, event)
self.recent_events = self.recent_events[:100] # Keep last 100
return event
def record_error(self, error: str):
self.error_count += 1
self.add_event(path="", action="sync", status="error", error=error)
self.last_error = datetime.now()
class WatchService:
def __init__(self, sync_service: SyncService, file_service: FileService, config: ProjectConfig):
self.sync_service = sync_service
self.file_service = file_service
self.config = config
self.state = WatchServiceState()
self.status_path = config.home / ".basic-memory" / "watch-status.json"
self.status_path.parent.mkdir(parents=True, exist_ok=True)
self.console = Console()
def generate_table(self) -> Table:
"""Generate status display table"""
table = Table(title="Basic Memory Sync Status")
# Add status row
table.add_column("Status", style="cyan")
table.add_column("Last Scan", style="cyan")
table.add_column("Files", style="cyan")
table.add_column("Errors", style="red")
# Add main status row
table.add_row(
"✓ Running" if self.state.running else "✗ Stopped",
self.state.last_scan.strftime("%H:%M:%S") if self.state.last_scan else "-",
str(self.state.synced_files),
f"{self.state.error_count} ({self.state.last_error.strftime('%H:%M:%S') if self.state.last_error else 'none'})",
)
if self.state.recent_events:
# Add recent events
table.add_section()
table.add_row("Recent Events", "", "", "")
for event in self.state.recent_events[:5]: # Show last 5 events
color = {
"new": "green",
"modified": "yellow",
"moved": "blue",
"deleted": "red",
"error": "red",
}.get(event.action, "white")
icon = {
"new": "",
"modified": "",
"moved": "",
"deleted": "",
"error": "!",
}.get(event.action, "*")
table.add_row(
f"[{color}]{icon} {event.action}[/{color}]",
event.timestamp.strftime("%H:%M:%S"),
f"[{color}]{event.path}[/{color}]",
f"[dim]{event.checksum[:8] if event.checksum else ''}[/dim]",
)
return table
async def run(self):
"""Watch for file changes and sync them"""
self.state.running = True
self.state.start_time = datetime.now()
await self.write_status()
with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live:
try:
async for changes in awatch(
self.config.home,
watch_filter=self.filter_changes,
debounce=self.config.sync_delay,
recursive=True,
):
# Process changes
await self.handle_changes(self.config.home)
# Update display
live.update(self.generate_table())
except Exception as e:
self.state.record_error(str(e))
await self.write_status()
raise
finally:
self.state.running = False
await self.write_status()
async def write_status(self):
"""Write current state to status file"""
self.status_path.write_text(WatchServiceState.model_dump_json(self.state, indent=2))
def filter_changes(self, change: Change, path: str) -> bool:
"""Filter to only watch markdown files"""
return path.endswith(".md") and not Path(path).name.startswith(".")
async def handle_changes(self, directory: Path):
"""Process a batch of file changes"""
logger.debug(f"handling change in directory: {directory} ...")
# Process changes with timeout
report = await self.sync_service.sync(directory)
self.state.last_scan = datetime.now()
self.state.synced_files = report.total
# Update stats
for path in report.new:
self.state.add_event(
path=path, action="new", status="success", checksum=report.checksums[path]
)
for path in report.modified:
self.state.add_event(
path=path, action="modified", status="success", checksum=report.checksums[path]
)
for old_path, new_path in report.moves.items():
self.state.add_event(
path=f"{old_path} -> {new_path}",
action="moved",
status="success",
checksum=report.checksums[new_path],
)
for path in report.deleted:
self.state.add_event(path=path, action="deleted", status="success")
await self.write_status()
+78
View File
@@ -0,0 +1,78 @@
"""Utility functions for basic-memory."""
import os
import re
import unicodedata
from pathlib import Path
from unidecode import unidecode
def sanitize_name(name: str) -> str:
"""
Sanitize a name for filesystem use:
- Convert to lowercase
- Replace spaces/punctuation with underscores
- Remove emojis and other special characters
- Collapse multiple underscores
- Trim leading/trailing underscores
"""
# Normalize unicode to compose characters where possible
name = unicodedata.normalize("NFKD", name)
# Remove emojis and other special characters, keep only letters, numbers, spaces
name = "".join(c for c in name if c.isalnum() or c.isspace())
# Replace spaces with underscores
name = name.replace(" ", "_")
# Remove newline
name = name.replace("\n", "")
# Convert to lowercase
name = name.lower()
# Collapse multiple underscores and trim
name = re.sub(r"_+", "_", name).strip("_")
return name
def generate_permalink(file_path: Path | str) -> str:
"""Generate a stable permalink from a file path.
Args:
file_path: Original file path
Returns:
Normalized permalink that matches validation rules. Converts spaces and underscores
to hyphens for consistency.
Examples:
>>> generate_permalink("docs/My Feature.md")
'docs/my-feature'
>>> generate_permalink("specs/API (v2).md")
'specs/api-v2'
>>> generate_permalink("design/unified_model_refactor.md")
'design/unified-model-refactor'
"""
# Remove extension
base = os.path.splitext(file_path)[0]
# Transliterate unicode to ascii
ascii_text = unidecode(base)
# Insert dash between camelCase
ascii_text = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", ascii_text)
# Convert to lowercase
lower_text = ascii_text.lower()
# replace underscores with hyphens
text_with_hyphens = lower_text.replace('_', '-')
# Replace remaining invalid chars with hyphens
clean_text = re.sub(r'[^a-z0-9/\-]', '-', text_with_hyphens)
# Collapse multiple hyphens
clean_text = re.sub(r'-+', '-', clean_text)
# Clean each path segment
segments = clean_text.split('/')
clean_segments = [s.strip('-') for s in segments]
return '/'.join(clean_segments)
+458
View File
@@ -0,0 +1,458 @@
# Basic Memory Tasks
## Current Focus
### Observation Management
Implement update/remove functionality for observations with a focus on maintainability and consistency with our "
filesystem is source of truth" principle.
Options under consideration:
1. Bulk Update Approach
- Update all observations at once
- Pros:
- Simpler file operations
- No need to match on observation content
- Easier database synchronization
- Very consistent with "filesystem is source of truth"
- Cons:
- Less efficient - rewrites everything for small changes
- Potential concurrency implications
2. Tracked Observations Approach
- Use markdown comments for observation IDs
```markdown
# Entity Name
type: entity_type
## Observations
- <!-- obs-id: abc123 -->
This is an observation
```
- Pros:
- Can track individual observations
- Enables precise updates/deletes
- Cons:
- More complex markdown parsing
- IDs visible in markdown
3. Diff-based Approach
- Implement observation-aware diffing
- Track changes at observation level
- Pros:
- More efficient updates
- Preserves manual edits
- Cons:
- More complex implementation
- Need to handle merge conflicts
4. Position-based Management
- Track observations by their position/order
- Pros:
- No need for explicit IDs
- Clean markdown
- Cons:
- Fragile if order changes
- Hard to handle concurrent edits
## Completed
- [x] Extract file operations to fileio.py module
- [x] Update EntityService to use fileio functions
- [x] Initial ObservationService implementation
- [x] Basic test coverage
## Future Work
- [ ] Implement observation updates/removals (exploring options above)
- [ ] Proper session management for concurrent operations
- [ ] EntityService tests using new fileio module
- [ ] More sophisticated search functionality
- [ ] Handle markdown formatting edge cases
## TODO
### refactor input schema
1. Observations Format:
Old (JSON) way I tried first:
```python
"observations": ["First observation", "Second observation"] # Simple string array
```
New required format:
```python
"observations": [
{"content": "First observation"},
{"content": "Second observation"}
] # Array of objects with content field
```
2. Relations Format:
Old way:
```python
"relations": [
{"from": "EntityName", "to": "OtherEntity", "relationType": "relates_to"} # Using names
]
```
New format:
```python
"relations": [
{"fromId": "20241210-entity-id", "toId": "20241210-other-id", "relationType": "relates_to"} # Using IDs
]
```
My preferences:
1. For observations: The simple string array felt more intuitive for basic use, but I can see the benefits of the object
format:
- Allows for additional metadata (context, timestamps, etc.)
- More explicit about what each field means
- Consistent with how we'd want to store this in a database
2. For relations: Using IDs is technically better but requires an extra lookup step in my workflow:
- I have to first create the entities to get their IDs
- Then use those IDs to create relations
- Makes it harder to create entities and relations in a single step
Suggestions for making it more intuitive:
1. For observations: We could have a helper function that accepts either format:
```python
# Both would work:
create_entities([{
"name": "Entity",
"entityType": "type",
"observations": ["Simple string obs"] # Auto-converted to object format
}])
create_entities([{
"name": "Entity",
"entityType": "type",
"observations": [{"content": "Full object obs"}] # Native format
}])
```
2. For relations: Maybe allow a name-based helper function:
```python
# Instead of requiring IDs:
create_relations_by_name([{
"from": "EntityName",
"to": "OtherEntity",
"relationType": "relates_to"
}])
```
3. A combined creation function for when we want to create entities and their relations together:
```python
create_entity_with_relations({
"entity": {
"name": "NewEntity",
"entityType": "type",
"observations": ["Obs 1", "Obs 2"]
},
"relations": [{
"to": "ExistingEntity",
"relationType": "relates_to"
}]
})
```
The current schema is more "correct" from a software engineering perspective, but these helpers could make it more
natural to use while maintaining the rigorous underlying structure. What do you think about these suggestions? Would you
prefer to keep it strict for clarity, or add some convenience layers?
### entity file organization
- my problems
- wall of text is hard to scan visually
- files are not ordered or grouped granular enough. Only `<date>_<entity_name>.md`
- hard to tell when a new entity file is added
Possible fixes:
- use subdirectories?
## TASKS
1. **Core Functionality Improvements**
- [x] entity.description addition
- [x] subdirectories
- Paul needs this for markdown view
- [x] refine directory sprawl
- [ ] improve tool api
- [ ] Choose and implement observation update/removal strategy
- [ ] Handle relationship updates in files
- [ ] Complete full CRUD operations
- delete
- [ ] Improve search functionality (currently broken as we discovered)
### Suggested Sequence
1. **Schema Update First**
- Add `entity.description` field
- rename entity.references?
- This affects database, Pydantic models, and file format
- Good foundation for other changes
2. **File Organization**
- Add subdirectory support
- Affects:
- File path handling
- Entity loading/saving
- URI resolution
- Will make Paul's markdown viewing experience better
3. **Tool API Improvements**
- Cleaner input/output schemas
- More consistent patterns
- Better error handling
- This sets us up for implementing the remaining operations
4. **Core Operations**
- Implement delete operations
- Update/remove observations
- Relationship updates in files
- Building on the improved API
5. **Search Fix**
- Can properly tackle this after file organization
- Will benefit from improved schema
Would you like me to:
1. Start with the schema update for entity.description?
2. Plan out the subdirectory implementation?
3. Or focus on a different area?
I think the schema update would be a clean, contained change to start with, but I'm happy to tackle whichever part you think would be most valuable first.
2. **Robustness & Testing**
- Fix DI issues
- Learn from fastmcp patterns
- Markdown service
- markdown.py
-python-frontmatter
- Complete test coverage
- Expand testing across services
- 100% coverate
- Improve error handling and logging
- Add comprehensive type hints
3. **Architecture Improvements**
- Handle concurrent file operations safely
- Implement proper session management
- Balance file operations and DB sync
- Handle markdown formatting edge cases
4. **Documentation & Infrastructure**
- Document core modules
- Implement proper backup strategy
- Add file change versioning
- Improve CLI interface
## Ideas
- need update tool
### 2-way sync
- Enable updates to the markdown files to be able to be seen by AI
- possible via tool sync
- filesystem notifications via agent?
- Claude can use `file_write` tool to edit Entity files also
### Projects
- support multiple projects
- figure out flow
- load project at startup?
- switch project during chat?
### References
- better support for ref:// references
- should we call them memory://<project>/<entity>
- use Prompt to invoke reference uri
### Entity File format
- Consider using wiki link format:
[[20241210-ai_human_collaboration_model]] improves
instead of
[20241210-basic-memory] validates_design_of
- use frontmatter for file header
### Code
- cleanup server.py
- use markdown.py?
- fastmcp?
- Entity.description
### Other Features
- Search
- webui
- python notebook?
- vector eb extension for sqlite
#### Obsidian
- use as file viewer editor
- create knowledge graph?
#### Save chats
I'll sketch out a potential schema for chat sessions in our system:
```
{
`entities`: [
{
`name`: `Chat_Entity_Design`,
`entityType`: `design_proposal`,
`observations`: [
{
`content`: `Entity Schema:
type: chat_session
date: ISO timestamp
participants: [human, ai]
topic: string
context_refs: [memory://<project>/<entity_id>]
key_decisions: [string]
summary: string`
},
{
`content`: `File Format (frontmatter):
---
type: chat_session
date: 2024-12-10T10:30:00Z
participants:
- human: paul
- ai: claude
topic: Chat Session Storage Design
context_refs:
- memory://basic-memory/20241210-chat-entity-design
key_decisions:
- Store chats as first-class entities
- Use memory:// URI scheme
summary: Designed schema for chat storage
---`
},
{
`content`: `Example Relations:
- chat_session references design_proposal
- chat_session implements feature
- chat_session discusses concept
- chat_session continues_from previous_chat`
},
{
`content`: `Auto-generation approach:
1. At end of chat, Claude summarizes key points
2. Creates chat entity with metadata
3. Adds relations to referenced entities
4. Updates any entities modified during chat`
},
{
`content`: `Integration points:
- memory:// URI scheme for references
- Relations to track context chain
- File format matches entity schema
- Automatic metadata generation`
}
]
}
]
}
```
```
{
`entities`: [
{
`name`: `Chat_Session_Workflow`,
`entityType`: `process`,
`observations`: [
{
`content`: `Start of chat:
- Load any referenced context via memory:// URIs
- Initialize new chat session metadata
- Track entities being discussed/modified`
},
{
`content`: `During chat:
- Update references as new entities are mentioned
- Track key decisions and changes
- Note any new relations discovered`
},
{
`content`: `End of chat:
- Generate summary and key points
- Create chat session entity
- Add relations to referenced entities
- Update any modified entities`
},
{
`content`: `Future chat reference:
- Can reference previous chat via memory:// URI
- Loads relevant context and decisions
- Creates continues_from relation automatically`
}
]
}
]
}
```
Key aspects of this design:
1. Chat Session as Entity
- Clear metadata (participants, date, topic)
- Explicit references to other entities via memory:// URIs
- Captures key decisions and summary
- Uses frontmatter for clean file format
2. Rich Relations
- Can track chat-to-chat continuity
- Links to entities discussed/modified
- Captures purpose (discusses, implements, etc.)
- Maintains context chain
3. Automated Workflow
- I could generate metadata during chat
- Create summary and relations at end
- Update any modified entities
- Makes context loading explicit
4. Integration
- memory:// URI scheme fits naturally
- File format matches our entity pattern
- Two-way links between chats and entities
- Clear context persistence model
The nice thing about this design is that it makes chat sessions "first-class citizens" in our knowledge graph while
keeping everything in human-readable markdown files.
Would you like me to:
1. Sketch out what this chat would look like as an entity?
2. Detail the MCP tool changes needed?
3. Mock up the file format more precisely?
4. Something else?
+26
View File
@@ -0,0 +1,26 @@
"""Tests for knowledge graph API routes."""
from typing import AsyncGenerator
import pytest_asyncio
from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
from basic_memory.deps import get_project_config, get_engine_factory
@pytest_asyncio.fixture
def app(test_config, engine_factory) -> FastAPI:
"""Create FastAPI test application."""
from basic_memory.api.app import app
app.dependency_overrides[get_project_config] = lambda: test_config
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
return app
@pytest_asyncio.fixture
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
"""Create client using ASGI transport - same as CLI will use."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
yield client
+470
View File
@@ -0,0 +1,470 @@
"""Tests for knowledge graph API routes."""
from urllib.parse import quote
import pytest
from httpx import AsyncClient
from basic_memory.schemas import (
Entity,
EntityResponse,
)
from basic_memory.schemas.search import SearchItemType, SearchResponse
@pytest.mark.asyncio
async def test_create_entity(client: AsyncClient, file_service):
"""Should create entity successfully."""
data = {
"title": "TestEntity",
"folder": "test",
"entity_type": "test",
"content": "TestContent",
}
# Create an entity
response = await client.post("/knowledge/entities", json=data)
# Verify creation
assert response.status_code == 200
entity = EntityResponse.model_validate(response.json())
assert entity.permalink == "test/test-entity"
assert entity.file_path == "test/TestEntity.md"
assert entity.entity_type == data["entity_type"]
assert entity.content_type == "text/markdown"
# Verify file has new content but preserved metadata
file_path = file_service.get_entity_path(entity)
file_content, _ = await file_service.read_file(file_path)
assert data["content"] in file_content
@pytest.mark.asyncio
async def test_create_entity_observations_relations(client: AsyncClient, file_service):
"""Should create entity successfully."""
data = {
"title": "TestEntity",
"folder": "test",
"content": """
# TestContent
## Observations
- [note] This is notable #tag1 (testing)
- related to [[SomeOtherThing]]
""",
}
# Create an entity
response = await client.post("/knowledge/entities", json=data)
# Verify creation
assert response.status_code == 200
entity = EntityResponse.model_validate(response.json())
assert entity.permalink == "test/test-entity"
assert entity.file_path == "test/TestEntity.md"
assert entity.entity_type == "note"
assert entity.content_type == "text/markdown"
assert len(entity.observations) == 1
assert entity.observations[0].category == "note"
assert entity.observations[0].content == "This is notable"
assert entity.observations[0].tags == ["tag1"]
assert entity.observations[0].context == "testing"
assert len(entity.relations) == 1
assert entity.relations[0].relation_type == "related to"
assert entity.relations[0].from_id == "test/test-entity"
assert entity.relations[0].to_id is None
# TODO Relation.to_id should be name from link
# Verify file has new content but preserved metadata
file_path = file_service.get_entity_path(entity)
file_content, _ = await file_service.read_file(file_path)
assert data["content"].strip() in file_content
@pytest.mark.asyncio
async def test_get_entity(client: AsyncClient):
"""Should retrieve an entity by path ID."""
# First create an entity
data = {"title": "TestEntity", "folder": "test", "entity_type": "test"}
response = await client.post("/knowledge/entities", json=data)
assert response.status_code == 200
data = response.json()
# Now get it by path
permalink = data["permalink"]
response = await client.get(f"/knowledge/entities/{permalink}")
# Verify retrieval
assert response.status_code == 200
entity = response.json()
assert entity["file_path"] == "test/TestEntity.md"
assert entity["entity_type"] == "test"
assert entity["permalink"] == "test/test-entity"
@pytest.mark.asyncio
async def test_get_entities(client: AsyncClient):
"""Should open multiple entities by path IDs."""
# Create a few entities with different names
await client.post("/knowledge/entities", json={"title": "AlphaTest", "folder": "", "entity_type": "test"})
await client.post("/knowledge/entities", json={"title": "BetaTest", "folder": "", "entity_type": "test"})
# Open nodes by path IDs
response = await client.get(
"/knowledge/entities?permalink=alpha-test&permalink=beta-test",
)
# Verify results
assert response.status_code == 200
data = response.json()
assert len(data["entities"]) == 2
entity_0 = data["entities"][0]
assert entity_0["title"] == "AlphaTest"
assert entity_0["file_path"] == "AlphaTest.md"
assert entity_0["entity_type"] == "test"
assert entity_0["permalink"] == "alpha-test"
entity_1 = data["entities"][1]
assert entity_1["title"] == "BetaTest"
assert entity_1["file_path"] == "BetaTest.md"
assert entity_1["entity_type"] == "test"
assert entity_1["permalink"] == "beta-test"
@pytest.mark.asyncio
async def test_delete_entity(client: AsyncClient):
"""Test DELETE /knowledge/entities with path ID."""
# Create test entity
entity_data = {"file_path": "TestEntity", "entity_type": "test"}
await client.post("/knowledge/entities", json=entity_data)
# Test deletion
response = await client.post("/knowledge/entities/delete", json={"permalinks": ["test-entity"]})
assert response.status_code == 200
assert response.json() == {"deleted": True}
# Verify entity is gone
permalink = quote("test/TestEntity")
response = await client.get(f"/knowledge/entities/{permalink}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_single_entity(client: AsyncClient):
"""Test DELETE /knowledge/entities with path ID."""
# Create test entity
entity_data = {"title": "TestEntity", "folder": "", "entity_type": "test"}
await client.post("/knowledge/entities", json=entity_data)
# Test deletion
response = await client.delete("/knowledge/entities/test-entity")
assert response.status_code == 200
assert response.json() == {"deleted": True}
# Verify entity is gone
permalink = quote("test/TestEntity")
response = await client.get(f"/knowledge/entities/{permalink}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_single_entity_by_title(client: AsyncClient):
"""Test DELETE /knowledge/entities with path ID."""
# Create test entity
entity_data = {"title": "TestEntity", "folder": "", "entity_type": "test"}
await client.post("/knowledge/entities", json=entity_data)
# Test deletion
response = await client.delete("/knowledge/entities/TestEntity")
assert response.status_code == 200
assert response.json() == {"deleted": True}
# Verify entity is gone
permalink = quote("test/TestEntity")
response = await client.get(f"/knowledge/entities/{permalink}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_single_entity_not_found(client: AsyncClient):
"""Test DELETE /knowledge/entities with path ID."""
# Test deletion
response = await client.delete("/knowledge/entities/test-not-found")
assert response.status_code == 200
assert response.json() == {"deleted": False}
@pytest.mark.asyncio
async def test_delete_entity_bulk(client: AsyncClient):
"""Test bulk entity deletion using path IDs."""
# Create test entities
await client.post("/knowledge/entities", json={"file_path": "Entity1", "entity_type": "test"})
await client.post("/knowledge/entities", json={"file_path": "Entity2", "entity_type": "test"})
# Test deletion
response = await client.post(
"/knowledge/entities/delete", json={"permalinks": ["Entity1", "Entity2"]}
)
assert response.status_code == 200
assert response.json() == {"deleted": True}
# Verify entities are gone
for name in ["Entity1", "Entity2"]:
permalink = quote(f"{name}")
response = await client.get(f"/knowledge/entities/{permalink}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_nonexistent_entity(client: AsyncClient):
"""Test deleting a nonexistent entity by path ID."""
response = await client.post(
"/knowledge/entities/delete", json={"permalinks": ["non_existent"]}
)
assert response.status_code == 200
assert response.json() == {"deleted": True}
@pytest.mark.asyncio
async def test_entity_indexing(client: AsyncClient):
"""Test entity creation includes search indexing."""
# Create entity
response = await client.post(
"/knowledge/entities",
json={
"title": "SearchTest",
"folder": "",
"entity_type": "test",
"observations": ["Unique searchable observation"],
},
)
assert response.status_code == 200
# Verify it's searchable
search_response = await client.post(
"/search/", json={"text": "search", "types": [SearchItemType.ENTITY.value]}
)
assert search_response.status_code == 200
search_result = SearchResponse.model_validate(search_response.json())
assert len(search_result.results) == 1
assert search_result.results[0].permalink == "search-test"
assert search_result.results[0].type == SearchItemType.ENTITY.value
@pytest.mark.asyncio
async def test_entity_delete_indexing(client: AsyncClient):
"""Test deleted entities are removed from search index."""
# Create entity
response = await client.post(
"/knowledge/entities",
json={
"title": "DeleteTest",
"folder": "",
"entity_type": "test",
"observations": ["Searchable observation that should be removed"],
},
)
assert response.status_code == 200
entity = response.json()
# Verify it's initially searchable
search_response = await client.post(
"/search/", json={"text": "delete", "types": [SearchItemType.ENTITY.value]}
)
search_result = SearchResponse.model_validate(search_response.json())
assert len(search_result.results) == 1
# Delete entity
delete_response = await client.post(
"/knowledge/entities/delete", json={"permalinks": [entity["permalink"]]}
)
assert delete_response.status_code == 200
# Verify it's no longer searchable
search_response = await client.post(
"/search/", json={"text": "delete", "types": [SearchItemType.ENTITY.value]}
)
search_result = SearchResponse.model_validate(search_response.json())
assert len(search_result.results) == 0
@pytest.mark.asyncio
async def test_update_entity_basic(client: AsyncClient):
"""Test basic entity field updates."""
# Create initial entity
response = await client.post(
"/knowledge/entities",
json={
"title": "test",
"folder": "",
"entity_type": "test",
"content": "Initial summary",
"entity_metadata": {"status": "draft"},
},
)
entity_response = response.json()
# Update fields
entity = Entity(**entity_response, folder="")
entity.entity_metadata["status"] = "final"
entity.content = "Updated summary"
response = await client.put(f"/knowledge/entities/{entity.permalink}", json=entity.model_dump())
assert response.status_code == 200
updated = response.json()
# Verify updates
assert updated["entity_metadata"]["status"] == "final" # Preserved
response = await client.get(f"/resource/{updated['permalink']}?content=true")
# raw markdown content
fetched = response.text
assert "Updated summary" in fetched
@pytest.mark.asyncio
async def test_update_entity_content(client: AsyncClient):
"""Test updating content for different entity types."""
# Create a note entity
response = await client.post(
"/knowledge/entities",
json={"title": "test-note", "folder": "", "entity_type": "note", "summary": "Test note"},
)
note = response.json()
# Update fields
entity = Entity(**note, folder="")
entity.content = "# Updated Note\n\nNew content."
response = await client.put(
f"/knowledge/entities/{note['permalink']}", json=entity.model_dump()
)
assert response.status_code == 200
updated = response.json()
# Verify through get request to check file
response = await client.get(f"/resource/{updated['permalink']}?content=true")
# raw markdown content
fetched = response.text
assert "# Updated Note" in fetched
assert "New content" in fetched
@pytest.mark.asyncio
async def test_update_entity_type_conversion(client: AsyncClient):
"""Test converting between note and knowledge types."""
# Create a note
note_data = {
"title": "test-note",
"folder": "",
"entity_type": "note",
"summary": "Test note",
"content": "# Test Note\n\nInitial content.",
}
response = await client.post("/knowledge/entities", json=note_data)
note = response.json()
# Update fields
entity = Entity(**note, folder="")
entity.entity_type = "test"
response = await client.put(
f"/knowledge/entities/{note['permalink']}", json=entity.model_dump()
)
assert response.status_code == 200
updated = response.json()
# Verify conversion
assert updated["entity_type"] == "test"
# Get latest to verify file format
response = await client.get(f"/knowledge/entities/{updated['permalink']}")
knowledge = response.json()
assert knowledge.get("content") is None
@pytest.mark.asyncio
async def test_update_entity_metadata(client: AsyncClient):
"""Test updating entity metadata."""
# Create entity
data = {"title": "test", "folder": "", "entity_type": "test", "entity_metadata": {"status": "draft"}}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
# Update fields
entity = Entity(**entity_response, folder="")
entity.entity_metadata["status"] = "final"
entity.entity_metadata["reviewed"] = True
# Update metadata
response = await client.put(f"/knowledge/entities/{entity.permalink}", json=entity.model_dump())
assert response.status_code == 200
updated = response.json()
# Verify metadata was merged, not replaced
assert updated["entity_metadata"]["status"] == "final"
assert updated["entity_metadata"]["reviewed"] in (True, "True")
@pytest.mark.asyncio
async def test_update_entity_not_found_does_create(client: AsyncClient):
"""Test updating non-existent entity does a create"""
data = {
"title": "nonexistent",
"folder": "",
"entity_type": "test",
"observations": ["First observation", "Second observation"],
}
entity = Entity(**data)
response = await client.put("/knowledge/entities/nonexistent", json=entity.model_dump())
assert response.status_code == 201
@pytest.mark.asyncio
async def test_update_entity_incorrect_permalink(client: AsyncClient):
"""Test updating non-existent entity does a create"""
data = {
"title": "Test Entity",
"folder": "",
"entity_type": "test",
"observations": ["First observation", "Second observation"],
}
entity = Entity(**data)
response = await client.put("/knowledge/entities/nonexistent", json=entity.model_dump())
assert response.status_code == 400
@pytest.mark.asyncio
async def test_update_entity_search_index(client: AsyncClient):
"""Test search index is updated after entity changes."""
# Create entity
data = {"title": "test", "folder": "", "entity_type": "test", "content": "Initial searchable content"}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
# Update fields
entity = Entity(**entity_response, folder="")
entity.content = "Updated with unique sphinx marker"
response = await client.put(f"/knowledge/entities/{entity.permalink}", json=entity.model_dump())
assert response.status_code == 200
# Search should find new content
search_response = await client.post(
"/search/", json={"text": "sphinx marker", "types": [SearchItemType.ENTITY.value]}
)
results = search_response.json()["results"]
assert len(results) == 1
assert results[0]["permalink"] == entity.permalink
+115
View File
@@ -0,0 +1,115 @@
"""Tests for memory router endpoints."""
from datetime import datetime
import pytest
from basic_memory.schemas.memory import GraphContext, RelationSummary, ObservationSummary
@pytest.mark.asyncio
async def test_get_memory_context(client, test_graph):
"""Test getting context from memory URL."""
response = await client.get("/memory/test/root")
assert response.status_code == 200
context = GraphContext(**response.json())
assert len(context.primary_results) == 1
assert context.primary_results[0].permalink == "test/root"
assert len(context.related_results) > 0
# Verify metadata
assert context.metadata.uri == "test/root"
assert context.metadata.depth == 1 # default depth
# assert context.metadata["timeframe"] == "7d" # default timeframe
assert isinstance(context.metadata.generated_at, datetime)
assert context.metadata.total_results == 2
@pytest.mark.asyncio
async def test_get_memory_context_pattern(client, test_graph):
"""Test getting context with pattern matching."""
response = await client.get("/memory/test/*")
assert response.status_code == 200
context = GraphContext(**response.json())
assert len(context.primary_results) > 1 # Should match multiple test/* paths
assert all("test/" in e.permalink for e in context.primary_results)
@pytest.mark.asyncio
async def test_get_memory_context_depth(client, test_graph):
"""Test depth parameter affects relation traversal."""
# With depth=1, should only get immediate connections
response = await client.get("/memory/test/root?depth=1&max_results=20")
assert response.status_code == 200
context1 = GraphContext(**response.json())
# With depth=2, should get deeper connections
response = await client.get("/memory/test/root?depth=3&max_results=20")
assert response.status_code == 200
context2 = GraphContext(**response.json())
assert len(context2.related_results) > len(context1.related_results)
@pytest.mark.asyncio
async def test_get_memory_context_timeframe(client, test_graph):
"""Test timeframe parameter filters by date."""
# Recent timeframe
response = await client.get("/memory/test/root?timeframe=1d")
assert response.status_code == 200
recent = GraphContext(**response.json())
# Longer timeframe
response = await client.get("/memory/test/root?timeframe=30d")
assert response.status_code == 200
older = GraphContext(**response.json())
assert len(older.related_results) >= len(recent.related_results)
@pytest.mark.asyncio
async def test_get_related_context_filters(client, test_graph):
"""Test filtering related content by relation type."""
response = await client.get("/memory/related/test/root")
assert response.status_code == 200
context = GraphContext(**response.json())
@pytest.mark.asyncio
async def test_not_found(client):
"""Test handling of non-existent paths."""
response = await client.get("/memory/test/does-not-exist")
assert response.status_code == 200
context = GraphContext(**response.json())
assert len(context.primary_results) == 0
assert len(context.related_results) == 0
@pytest.mark.asyncio
async def test_recent_activity(client, test_graph):
"""Test handling of non-existent paths."""
response = await client.get("/memory/recent")
assert response.status_code == 200
context = GraphContext(**response.json())
assert len(context.primary_results) > 0
assert len(context.related_results) > 0
@pytest.mark.asyncio
async def test_recent_activity_by_type(client, test_graph):
"""Test handling of non-existent paths."""
response = await client.get("/memory/recent?type=relation&type=observation")
assert response.status_code == 200
context = GraphContext(**response.json())
assert len(context.primary_results) > 0
for r in context.primary_results:
assert isinstance(r, RelationSummary | ObservationSummary)
assert len(context.related_results) > 0
+90
View File
@@ -0,0 +1,90 @@
"""Tests for resource router endpoints."""
from datetime import datetime, timezone
import pytest
from pathlib import Path
@pytest.mark.asyncio
async def test_get_resource_content(client, test_config, entity_repository):
"""Test getting content by permalink."""
# Create a test file
content = "# Test Content\n\nThis is a test file."
test_file = Path(test_config.home) / "test" / "test.md"
test_file.parent.mkdir(parents=True, exist_ok=True)
test_file.write_text(content)
# Create entity referencing the file
entity = await entity_repository.create(
{
"title": "Test Entity",
"entity_type": "test",
"permalink": "test/test",
"file_path": "test/test.md", # Relative to config.home
"content_type": "text/markdown",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
)
# Test getting the content
response = await client.get(f"/resource/{entity.permalink}")
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert response.text == content
async def test_get_resource_by_title(client, test_config, entity_repository):
"""Test getting content by permalink."""
# Create a test file
content = "# Test Content\n\nThis is a test file."
test_file = Path(test_config.home) / "test" / "test.md"
test_file.parent.mkdir(parents=True, exist_ok=True)
test_file.write_text(content)
# Create entity referencing the file
entity = await entity_repository.create(
{
"title": "Test Entity",
"entity_type": "test",
"permalink": "test/test",
"file_path": "test/test.md", # Relative to config.home
"content_type": "text/markdown",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
)
# Test getting the content
response = await client.get(f"/resource/{entity.title}")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_resource_missing_entity(client):
"""Test 404 when entity doesn't exist."""
response = await client.get("/resource/does/not/exist")
assert response.status_code == 404
assert "Entity not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_get_resource_missing_file(client, test_config, entity_repository):
"""Test 404 when file doesn't exist."""
# Create entity referencing non-existent file
entity = await entity_repository.create(
{
"title": "Missing File",
"entity_type": "test",
"permalink": "test/missing",
"file_path": "test/missing.md",
"content_type": "text/markdown",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
)
response = await client.get(f"/resource/{entity.permalink}")
assert response.status_code == 404
assert "File not found" in response.json()["detail"]
+177
View File
@@ -0,0 +1,177 @@
"""Tests for search router."""
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from sqlalchemy import text
from basic_memory import db
from basic_memory.schemas import Entity as EntitySchema
from basic_memory.schemas.search import SearchItemType, SearchResponse
@pytest_asyncio.fixture
async def indexed_entity(init_search_index, full_entity, search_service):
"""Create an entity and index it."""
await search_service.index_entity(full_entity)
return full_entity
@pytest.mark.asyncio
async def test_search_basic(client, indexed_entity):
"""Test basic text search."""
response = await client.post("/search/", json={"text": "searchable"})
assert response.status_code == 200
search_results = SearchResponse.model_validate(response.json())
assert len(search_results.results) == 3
found = False
for r in search_results.results:
if r.type == SearchItemType.ENTITY.value:
assert r.permalink == indexed_entity.permalink
found = True
assert found, "Expected to find indexed entity in results"
@pytest.mark.asyncio
async def test_search_with_type_filter(client, indexed_entity):
"""Test search with type filter."""
# Should find with correct type
response = await client.post(
"/search/", json={"text": "test", "types": [SearchItemType.ENTITY.value]}
)
assert response.status_code == 200
search_results = SearchResponse.model_validate(response.json())
# Should not find with wrong type
response = await client.post(
"/search/", json={"text": "test", "types": [SearchItemType.RELATION.value]}
)
assert response.status_code == 200
search_results = SearchResponse.model_validate(response.json())
@pytest.mark.asyncio
async def test_search_with_entity_type_filter(client, indexed_entity):
"""Test search with entity type filter."""
# Should find with correct entity type
response = await client.post("/search/", json={"text": "test", "entity_types": ["test"]})
assert response.status_code == 200
search_results = SearchResponse.model_validate(response.json())
assert len(search_results.results) == 1
# Should not find with wrong entity type
response = await client.post("/search/", json={"text": "test", "entity_types": ["note"]})
assert response.status_code == 200
search_results = SearchResponse.model_validate(response.json())
assert len(search_results.results) == 0
@pytest.mark.asyncio
async def test_search_with_date_filter(client, indexed_entity):
"""Test search with date filter."""
# Should find with past date
past_date = datetime(2020, 1, 1, tzinfo=timezone.utc)
response = await client.post(
"/search/", json={"text": "test", "after_date": past_date.isoformat()}
)
assert response.status_code == 200
search_results = SearchResponse.model_validate(response.json())
# Should not find with future date
future_date = datetime(2030, 1, 1, tzinfo=timezone.utc)
response = await client.post(
"/search/", json={"text": "test", "after_date": future_date.isoformat()}
)
assert response.status_code == 200
search_results = SearchResponse.model_validate(response.json())
assert len(search_results.results) == 0
@pytest.mark.skip("search scoring is not implemented yet")
@pytest.mark.asyncio
async def test_search_scoring(client, indexed_entity):
"""Test search result scoring."""
# Exact match should score higher
exact_response = await client.post("/search/", json={"text": "TestComponent"})
# Partial match should score lower
partial_response = await client.post("/search/", json={"text": "test"})
assert exact_response.status_code == 200
assert partial_response.status_code == 200
exact_result = SearchResponse.model_validate(exact_response.json())
partial_result = SearchResponse.model_validate(partial_response.json())
exact_score = exact_result.results[0].score
partial_score = partial_result.results[0].score
assert exact_score > partial_score
@pytest.mark.asyncio
async def test_search_empty(search_service, client):
"""Test search with no matches."""
response = await client.post("/search/", json={"text": "nonexistent"})
assert response.status_code == 200
search_result = SearchResponse.model_validate(response.json())
assert len(search_result.results) == 0
@pytest.mark.asyncio
async def test_reindex(client, search_service, entity_service, session_maker):
"""Test reindex endpoint."""
# Create test entity and document
await entity_service.create_entity(
EntitySchema(
title="TestEntity1",
folder="test",
entity_type="test",
),
)
# Clear search index
async with db.scoped_session(session_maker) as session:
await session.execute(text("DELETE FROM search_index"))
await session.commit()
# Verify nothing is searchable
response = await client.post("/search/", json={"text": "test"})
search_results = SearchResponse.model_validate(response.json())
assert len(search_results.results) == 0
# Trigger reindex
reindex_response = await client.post("/search/reindex")
assert reindex_response.status_code == 200
assert reindex_response.json()["status"] == "ok"
# Verify content is searchable again
search_response = await client.post("/search/", json={"text": "test"})
search_results = SearchResponse.model_validate(search_response.json())
assert len(search_results.results) == 1
@pytest.mark.asyncio
async def test_multiple_filters(client, indexed_entity):
"""Test search with multiple filters combined."""
response = await client.post(
"/search/",
json={
"text": "test",
"types": [SearchItemType.ENTITY.value],
"entity_types": ["test"],
"after_date": datetime(2020, 1, 1, tzinfo=timezone.utc).isoformat(),
},
)
assert response.status_code == 200
search_result = SearchResponse.model_validate(response.json())
assert len(search_result.results) == 1
result = search_result.results[0]
assert result.permalink == indexed_entity.permalink
assert result.type == SearchItemType.ENTITY.value
assert result.metadata["entity_type"] == "test"
+170
View File
@@ -0,0 +1,170 @@
# """Test status command functionality."""
#
# from io import StringIO
#
# import pytest
# from rich.console import Console
#
# from basic_memory.cli.commands.status import display_changes, run_status
# from basic_memory.sync.file_change_scanner import FileState
# from basic_memory.sync.utils import SyncReport
# from basic_memory.file_utils import compute_checksum
#
#
# @pytest.fixture
# def console():
# """Create test console that captures output."""
# output = StringIO()
# return Console(file=output), output
#
#
# @pytest.mark.asyncio
# async def test_display_no_changes(console):
# """Test display with no changes."""
# test_console, output = console
# changes = SyncReport()
# display_changes("Test Files", changes, verbose=False)
# assert "No changes" in output.getvalue()
#
#
# @pytest.mark.asyncio
# async def test_display_compact_changes(console):
# """Test compact display of changes."""
# test_console, output = console
# changes = SyncReport(
# new={"docs/new.md"},
# modified={"docs/mod.md"},
# deleted={"old/deleted.md"},
# moved={
# "new/location.md": FileState(
# permalink="new/location.md", checksum="abc123", moved_from="old/location.md"
# )
# },
# )
# display_changes("Test Files", changes, verbose=False)
# output_text = output.getvalue()
#
# # Check directory summaries
# assert "docs/ +1 new" in output_text.replace(" ", " ")
# assert "docs/ ~1 modified" in output_text.replace(" ", " ")
# assert "old/ -1 deleted" in output_text.replace(" ", " ")
# assert "new/ ->1 moved" in output_text.replace(" ", " ")
#
#
# @pytest.mark.asyncio
# async def test_display_verbose_changes(console):
# """Test verbose display of changes."""
# test_console, output = console
# changes = SyncReport(
# new={"docs/new.md"},
# modified={"docs/mod.md"},
# deleted={"old/deleted.md"},
# moved={
# "new/location.md": FileState(
# permalink="new/location.md",
# checksum="abc123def", # 8 chars for display
# moved_from="old/location.md",
# )
# },
# checksums={
# "docs/new.md": "def456789abcdef",
# "docs/mod.md": "ghi789abcdef123",
# },
# )
# display_changes("Test Files", changes, verbose=True)
# output_text = output.getvalue()
#
# # Verify sections
# assert "New Files" in output_text
# assert "Modified" in output_text
# assert "Deleted" in output_text
# assert "Moved" in output_text
#
# # Check file listings with checksums
# assert "new.md (def45678)" in output_text
# assert "mod.md (ghi78789)" in output_text
# assert "location.md (abc123de)" in output_text
#
#
# @pytest.mark.asyncio
# async def test_end_to_end_status(file_change_scanner, test_config, entity_repository):
# """Test complete status command with real files."""
# # Create test files in both knowledge and documents directories
# project_dir = test_config.home
#
# # Create knowledge files
# component_dir = project_dir / "component"
# component_dir.mkdir(exist_ok=True)
# component_path = component_dir / "test.md"
# component_path.write_text("test component")
#
# # Add some files to DB with different paths to test moves
# await entity_repository.create(
# {"permalink": "old/doc.md", "file_path": "old/doc.md", "checksum": "abc123"}
# )
#
# # Run status check
# await run_status(file_change_scanner, verbose=True)
#
# # Verify changes through sync service directly
# doc_changes = await file_change_scanner.find_knowledge_changes(project_dir)
# assert len(doc_changes.new) == 2 # test.md and nested.md
# assert "test.md" in doc_changes.new
# assert "subdir/nested.md" in doc_changes.new
# assert len(doc_changes.deleted) == 1 # old/doc.md
#
# knowledge_changes = await file_change_scanner.find_knowledge_changes(project_dir)
# assert len(knowledge_changes.new) == 1
# assert "component/test.md" in knowledge_changes.new
#
#
# @pytest.mark.asyncio
# async def test_status_with_case_changes(file_change_scanner, test_config, entity_repository):
# """Test status detection with case-sensitive path changes."""
# docs_dir = test_config.documents_dir
# docs_dir.mkdir(parents=True, exist_ok=True)
#
# # Create file with initial case
# content = "test content"
# original_path = "Test.md"
# orig_file = docs_dir / original_path
# orig_file.write_text(content)
# checksum = await compute_checksum(content)
#
# # Add to DB
# await entity_repository.create(
# {"permalink": original_path, "file_path": original_path, "checksum": checksum}
# )
#
# # Simulate case change in filesystem
# orig_file.rename(docs_dir / "test.md")
#
# # Check changes
# changes = await file_change_scanner.find_knowledge_changes(docs_dir)
# assert len(changes.moved) == 1
# assert "test.md" in changes.moved
# assert changes.moved["test.md"].moved_from == original_path
#
#
# @pytest.mark.asyncio
# async def test_status_with_spaces(file_change_scanner, test_config, entity_repository):
# """Test status handling files with spaces and special characters."""
# docs_dir = test_config.documents_dir
# docs_dir.mkdir(parents=True, exist_ok=True)
#
# # Create file with spaces
# content = "test content"
# path = "My Document.md"
# file_path = docs_dir / path
# file_path.write_text(content)
# checksum = await compute_checksum(content)
#
# # Add to DB with same path
# await entity_repository.create({"permalink": path, "file_path": path, "checksum": checksum})
#
# # Check changes
# changes = await file_change_scanner.find_knowledge_changes(docs_dir)
# assert not changes.modified # File unchanged
# assert not changes.moved # Path matches exactly
# assert not changes.deleted
# assert not changes.new
+395
View File
@@ -0,0 +1,395 @@
"""Common test fixtures."""
from typing import AsyncGenerator
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from loguru import logger
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine, async_sessionmaker
from basic_memory import db
from basic_memory.config import ProjectConfig
from basic_memory.db import DatabaseType
from basic_memory.markdown import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.models import Base
from basic_memory.models.knowledge import Entity, Observation, ObservationCategory, Relation
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.services import (
EntityService,
)
from basic_memory.services.file_service import FileService
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.sync import FileChangeScanner
from basic_memory.sync.sync_service import SyncService
from basic_memory.sync.watch_service import WatchService
@pytest_asyncio.fixture
def anyio_backend():
return "asyncio"
@pytest_asyncio.fixture
def test_config(tmp_path) -> ProjectConfig:
"""Test configuration using in-memory DB."""
config = ProjectConfig(
project="test-project",
)
config.home = tmp_path
(tmp_path / config.home.name).mkdir(parents=True, exist_ok=True)
logger.info(f"project config home: {config.home}")
return config
@pytest_asyncio.fixture(scope="function")
async def engine_factory(
test_config,
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
"""Create engine and session factory using in-memory SQLite database."""
async with db.engine_session_factory(
db_path=test_config.database_path, db_type=DatabaseType.MEMORY
) as (engine, session_maker):
# Initialize database
async with db.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)
yield engine, session_maker
@pytest_asyncio.fixture
async def session_maker(engine_factory) -> async_sessionmaker[AsyncSession]:
"""Get session maker for tests."""
_, session_maker = engine_factory
return session_maker
@pytest_asyncio.fixture(scope="function")
async def entity_repository(session_maker: async_sessionmaker[AsyncSession]) -> EntityRepository:
"""Create an EntityRepository instance."""
return EntityRepository(session_maker)
@pytest_asyncio.fixture(scope="function")
async def observation_repository(
session_maker: async_sessionmaker[AsyncSession],
) -> ObservationRepository:
"""Create an ObservationRepository instance."""
return ObservationRepository(session_maker)
@pytest_asyncio.fixture(scope="function")
async def relation_repository(
session_maker: async_sessionmaker[AsyncSession],
) -> RelationRepository:
"""Create a RelationRepository instance."""
return RelationRepository(session_maker)
@pytest_asyncio.fixture
async def entity_service(
entity_repository: EntityRepository,
observation_repository: ObservationRepository,
relation_repository: RelationRepository,
entity_parser: EntityParser,
file_service: FileService,
link_resolver: LinkResolver,
) -> EntityService:
"""Create EntityService."""
return EntityService(
entity_parser=entity_parser,
entity_repository=entity_repository,
observation_repository=observation_repository,
relation_repository=relation_repository,
file_service=file_service,
link_resolver=link_resolver,
)
@pytest.fixture
def file_service(test_config: ProjectConfig, markdown_processor: MarkdownProcessor) -> FileService:
"""Create FileService instance."""
return FileService(test_config.home, markdown_processor)
@pytest.fixture
def markdown_processor(entity_parser: EntityParser) -> MarkdownProcessor:
"""Create writer instance."""
return MarkdownProcessor(entity_parser)
@pytest.fixture
def link_resolver(entity_repository: EntityRepository, search_service: SearchService):
"""Create parser instance."""
return LinkResolver(entity_repository, search_service)
@pytest.fixture
def entity_parser(test_config):
"""Create parser instance."""
return EntityParser(test_config.home)
@pytest_asyncio.fixture
def file_change_scanner(entity_repository) -> FileChangeScanner:
"""Create FileChangeScanner instance."""
return FileChangeScanner(entity_repository)
@pytest_asyncio.fixture
async def sync_service(
file_change_scanner: FileChangeScanner,
entity_service: EntityService,
entity_parser: EntityParser,
entity_repository: EntityRepository,
relation_repository: RelationRepository,
search_service: SearchService,
) -> SyncService:
"""Create sync service for testing."""
return SyncService(
scanner=file_change_scanner,
entity_service=entity_service,
entity_repository=entity_repository,
relation_repository=relation_repository,
entity_parser=entity_parser,
search_service=search_service,
)
@pytest_asyncio.fixture
async def search_repository(session_maker):
"""Create SearchRepository instance"""
return SearchRepository(session_maker)
@pytest_asyncio.fixture(autouse=True)
async def init_search_index(search_service):
await search_service.init_search_index()
@pytest_asyncio.fixture
async def search_service(
search_repository: SearchRepository,
entity_repository: EntityRepository,
file_service: FileService,
) -> SearchService:
"""Create and initialize search service"""
service = SearchService(search_repository, entity_repository, file_service)
await service.init_search_index()
return service
@pytest_asyncio.fixture(scope="function")
async def sample_entity(entity_repository: EntityRepository) -> Entity:
"""Create a sample entity for testing."""
entity_data = {
"title": "Test Entity",
"entity_type": "test",
"permalink": "test/test-entity",
"file_path": "test/test_entity.md",
"content_type": "text/markdown",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
return await entity_repository.create(entity_data)
@pytest_asyncio.fixture
async def full_entity(sample_entity, entity_repository, file_service) -> Entity:
"""Create a search test entity."""
search_entity = await entity_repository.create(
{
"title": "Searchable Entity",
"entity_type": "test",
"permalink": "test/search-entity",
"file_path": "test/search_entity.md",
"content_type": "text/markdown",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
)
observations = [
Observation(
content="Tech note",
category=ObservationCategory.TECH,
),
Observation(
content="Design note",
category=ObservationCategory.DESIGN,
),
]
relations = [
Relation(
from_id=search_entity.id,
to_id=sample_entity.id,
to_name=sample_entity.title,
relation_type="out1",
),
Relation(
from_id=search_entity.id,
to_id=sample_entity.id,
to_name=sample_entity.title,
relation_type="out2",
),
]
search_entity.observations = observations
search_entity.outgoing_relations = relations
full_entity = await entity_repository.add(search_entity)
# write file
await file_service.write_entity_file(full_entity)
return full_entity
@pytest_asyncio.fixture
async def test_graph(
entity_repository, relation_repository, observation_repository, search_service, file_service
):
"""Create a test knowledge graph with entities, relations and observations."""
# Create some test entities
entities = [
Entity(
title="Root Entity",
entity_type="test",
permalink="test/root",
file_path="test/root.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
Entity(
title="Connected Entity 1",
entity_type="test",
permalink="test/connected1",
file_path="test/connected1.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
Entity(
title="Connected Entity 2",
entity_type="test",
permalink="test/connected2",
file_path="test/connected2.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
Entity(
title="Deep Entity",
entity_type="deep",
permalink="test/deep",
file_path="test/deep.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
Entity(
title="Deeper Entity",
entity_type="deeper",
permalink="test/deeper",
file_path="test/deeper.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
]
entities = await entity_repository.add_all(entities)
root, conn1, conn2, deep, deeper = entities
# Add some observations
root.observations = [
Observation(
content="Root note 1",
category=ObservationCategory.NOTE,
),
Observation(
content="Root tech note",
category=ObservationCategory.TECH,
),
]
conn1.observations = [
Observation(
content="Connected 1 note",
category=ObservationCategory.NOTE,
)
]
await observation_repository.add_all(root.observations)
await observation_repository.add_all(conn1.observations)
# Add relations
relations = [
# Direct connections to root
Relation(
from_id=root.id,
to_id=conn1.id,
to_name=conn1.title,
relation_type="connects_to",
),
Relation(
from_id=conn1.id,
to_id=conn2.id,
to_name=conn2.title,
relation_type="connected_to",
),
# Deep connection
Relation(
from_id=conn2.id,
to_id=deep.id,
to_name=deep.title,
relation_type="deep_connection",
),
# Deeper connection
Relation(
from_id=deep.id,
to_id=deeper.id,
to_name=deeper.title,
relation_type="deeper_connection",
),
]
# Save relations
related_entities = await relation_repository.add_all(relations)
# get latest
entities = await entity_repository.find_all()
# make sure we have files for entities
for entity in entities:
await file_service.write_entity_file(entity)
# Index everything for search
for entity in entities:
await search_service.index_entity(entity)
return {
"root": entities[0],
"connected1": conn1,
"connected2": conn2,
"deep": deep,
"observations": [e.observations for e in entities],
"relations": relations,
}
@pytest_asyncio.fixture
def watch_service(sync_service, file_service, test_config):
return WatchService(
sync_service=sync_service,
file_service=file_service,
config=test_config
)
+17
View File
@@ -0,0 +1,17 @@
"""Test file for experimenting with edit_file."""
def function_one():
"""First test function."""
print("Hello from function one")
# Some code here
x = 1 + 2
return x
def function_two():
"""Second test function."""
print("Hello from function two")
# Some more code
y = 3 * 4
return y
View File
+185
View File
@@ -0,0 +1,185 @@
"""Tests for entity markdown parsing."""
import os
from datetime import datetime, timedelta, UTC
from pathlib import Path
from textwrap import dedent
import pytest
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter, Relation
@pytest.fixture
def valid_entity_content():
"""A complete, valid entity file with all features."""
return dedent("""
---
title: Auth Service
type: component
permalink: auth_service
created: 2024-12-21T14:00:00Z
modified: 2024-12-21T14:00:00Z
tags: authentication, security, core
---
Core authentication service that handles user authentication.
some [[Random Link]]
another [[Random Link with Title|Titled Link]]
## Observations
- [design] Stateless authentication #security #architecture (JWT based)
- [feature] Mobile client support #mobile #oauth (Required for App Store)
- [tech] Caching layer #performance (Redis implementation)
## Relations
- implements [[OAuth Implementation]] (Core auth flows)
- uses [[Redis Cache]] (Token caching)
- specified_by [[Auth API Spec]] (OpenAPI spec)
""")
@pytest.mark.asyncio
async def test_parse_complete_file(test_config, entity_parser, valid_entity_content):
"""Test parsing a complete entity file with all features."""
test_file = test_config.home / "test_entity.md"
test_file.write_text(valid_entity_content)
entity = await entity_parser.parse_file(test_file)
# Verify entity structure
assert isinstance(entity, EntityMarkdown)
assert isinstance(entity.frontmatter, EntityFrontmatter)
assert isinstance(entity.content, str)
# Check frontmatter
assert entity.frontmatter.title == "Auth Service"
assert entity.frontmatter.type == "component"
assert entity.frontmatter.permalink == "auth_service"
assert set(entity.frontmatter.tags) == {"authentication", "security", "core"}
# Check content
assert "Core authentication service that handles user authentication." in entity.content
# Check observations
assert len(entity.observations) == 3
obs = entity.observations[0]
assert obs.category == "design"
assert obs.content == "Stateless authentication"
assert set(obs.tags or []) == {"security", "architecture"}
assert obs.context == "JWT based"
# Check relations
assert len(entity.relations) == 5
assert (
Relation(type="implements", target="OAuth Implementation", context="Core auth flows")
in entity.relations
), "missing [[OAuth Implementation]]"
assert (
Relation(type="uses", target="Redis Cache", context="Token caching")
in entity.relations
), "missing [[Redis Cache]]"
assert (
Relation(type="specified_by", target="Auth API Spec", context="OpenAPI spec")
in entity.relations
), "missing [[Auth API Spec]]"
# inline links in content
assert (
Relation(type="links to", target="Random Link", context=None) in entity.relations
), "missing [[Random Link]]"
assert (
Relation(type="links to", target="Random Link with Title|Titled Link", context=None)
in entity.relations
), "missing [[Random Link with Title|Titled Link]]"
@pytest.mark.asyncio
async def test_parse_minimal_file(test_config, entity_parser):
"""Test parsing a minimal valid entity file."""
content = dedent("""
---
type: component
tags: []
---
# Minimal Entity
## Observations
- [note] Basic observation #test
## Relations
- references [[Other Entity]]
""")
test_file = test_config.home / "minimal.md"
test_file.write_text(content)
entity = await entity_parser.parse_file(test_file)
assert entity.frontmatter.type == "component"
assert entity.frontmatter.permalink is None
assert len(entity.observations) == 1
assert len(entity.relations) == 1
assert entity.created is not None
assert entity.modified is not None
@pytest.mark.asyncio
async def test_error_handling(test_config, entity_parser):
"""Test error handling."""
# Missing file
with pytest.raises(FileNotFoundError):
await entity_parser.parse_file(Path("nonexistent.md"))
# Invalid file encoding
test_file = test_config.home / "binary.md"
with open(test_file, "wb") as f:
f.write(b"\x80\x81") # Invalid UTF-8
with pytest.raises(UnicodeDecodeError):
await entity_parser.parse_file(test_file)
@pytest.mark.asyncio
async def test_parse_file_without_section_headers(test_config, entity_parser):
"""Test parsing a minimal valid entity file."""
content = dedent("""
---
type: component
permalink: minimal_entity
status: draft
tags: []
---
# Minimal Entity
some text
some [[Random Link]]
- [note] Basic observation #test
- references [[Other Entity]]
""")
test_file = test_config.home / "minimal.md"
test_file.write_text(content)
entity = await entity_parser.parse_file(test_file)
assert entity.frontmatter.type == "component"
assert entity.frontmatter.permalink == "minimal_entity"
assert "some text\nsome [[Random Link]]" in entity.content
assert len(entity.observations) == 1
assert entity.observations[0].category == "note"
assert entity.observations[0].content == "Basic observation"
assert entity.observations[0].tags == ["test"]
assert len(entity.relations) == 2
assert entity.relations[0].type == "links to"
assert entity.relations[0].target == "Random Link"
assert entity.relations[1].type == "references"
assert entity.relations[1].target == "Other Entity"
+163
View File
@@ -0,0 +1,163 @@
"""Tests for markdown-it plugins."""
import pytest
from markdown_it import MarkdownIt
from basic_memory.markdown.plugins import observation_plugin, relation_plugin
def test_observation_plugin():
"""Test observation plugin parsing."""
md = MarkdownIt().use(observation_plugin)
# Basic observation
tokens = md.parse("- [design] Core feature #important #mvp")
obs_token = next(t for t in tokens if t.meta and 'observation' in t.meta)
obs = obs_token.meta['observation']
assert obs['category'] == 'design'
assert obs['content'] == 'Core feature'
assert set(obs['tags']) == {'important', 'mvp'}
assert obs['context'] is None
# With context
tokens = md.parse("- [feature] Authentication system #security (Required for MVP)")
obs_token = next(t for t in tokens if t.meta and 'observation' in t.meta)
obs = obs_token.meta['observation']
assert obs['category'] == 'feature'
assert obs['content'] == 'Authentication system'
assert set(obs['tags']) == {'security'}
assert obs['context'] == 'Required for MVP'
# Without category
tokens = md.parse("- Authentication system #security (Required for MVP)")
obs_token = next(t for t in tokens if t.meta and 'observation' in t.meta)
obs = obs_token.meta['observation']
assert obs['category'] is None
assert obs['content'] == 'Authentication system'
assert set(obs['tags']) == {'security'}
assert obs['context'] == 'Required for MVP'
def test_observation_edge_cases():
"""Test observation plugin edge cases."""
md = MarkdownIt().use(observation_plugin)
# Multiple word tags
tokens = md.parse("- [tech] Database #high-priority #needs-review")
obs_token = next(t for t in tokens if t.meta and 'observation' in t.meta)
obs = obs_token.meta['observation']
assert set(obs['tags']) == {'high-priority', 'needs-review'}
# Multiple word category
tokens = md.parse("- [user experience] Design #ux")
obs_token = next(t for t in tokens if t.meta and 'observation' in t.meta)
obs = obs_token.meta['observation']
assert obs['category'] == 'user experience'
# Parentheses in content
tokens = md.parse("- [code] Function (x) returns y #function")
obs_token = next(t for t in tokens if t.meta and 'observation' in t.meta)
obs = obs_token.meta['observation']
assert obs['content'] == 'Function (x) returns y'
assert obs['context'] is None
# Multiple hashtags together
tokens = md.parse("- [test] Feature #important#urgent#now")
obs_token = next(t for t in tokens if t.meta and 'observation' in t.meta)
obs = obs_token.meta['observation']
assert set(obs['tags']) == {'important', 'urgent', 'now'}
def test_relation_plugin():
"""Test relation plugin parsing."""
md = MarkdownIt().use(relation_plugin)
# Basic relation
tokens = md.parse("- implements [[Auth Service]]")
rel_token = next(t for t in tokens if t.meta and 'relations' in t.meta)
rel = rel_token.meta['relations'][0]
assert rel['type'] == 'implements'
assert rel['target'] == 'Auth Service'
assert rel['context'] is None
# With context
tokens = md.parse("- depends_on [[Database]] (Required for persistence)")
rel_token = next(t for t in tokens if t.meta and 'relations' in t.meta)
rel = rel_token.meta['relations'][0]
assert rel['type'] == 'depends_on'
assert rel['target'] == 'Database'
assert rel['context'] == 'Required for persistence'
def test_relation_edge_cases():
"""Test relation plugin edge cases."""
md = MarkdownIt().use(relation_plugin)
# Multiple word type
tokens = md.parse("- is used by [[Client App]] (Primary consumer)")
rel_token = next(t for t in tokens if t.meta and 'relations' in t.meta)
rel = rel_token.meta['relations'][0]
assert rel['type'] == 'is used by'
# Extra spaces
tokens = md.parse("- specifies [[Format]] (Documentation)")
rel_token = next(t for t in tokens if t.meta and 'relations' in t.meta)
rel = rel_token.meta['relations'][0]
assert rel['type'] == 'specifies'
assert rel['target'] == 'Format'
def test_inline_relations():
"""Test finding relations in regular content."""
md = MarkdownIt().use(relation_plugin)
# Single inline link
tokens = md.parse("This references [[Another Doc]].")
rel_token = next(t for t in tokens if t.meta and 'relations' in t.meta)
rel = rel_token.meta['relations'][0]
assert rel['type'] == 'links to'
assert rel['target'] == 'Another Doc'
# Multiple inline links
tokens = md.parse("Links to [[Doc1]] and [[Doc2]].")
rel_token = next(t for t in tokens if t.meta and 'relations' in t.meta)
rels = rel_token.meta['relations']
assert len(rels) == 2
assert {r['target'] for r in rels} == {'Doc1', 'Doc2'}
def test_combined_plugins():
"""Test both plugins working together."""
md = (MarkdownIt()
.use(observation_plugin)
.use(relation_plugin))
content = """# Document
Some text with a [[Link]].
## Observations
- [tech] Implements [[Feature]] #important
- [design] References [[AnotherDoc]] (For context)
"""
tokens = md.parse(content)
# Should find both observations and relations
observations = [t.meta['observation'] for t in tokens if t.meta and 'observation' in t.meta]
relations = [r for t in tokens if t.meta and 'relations' in t.meta for r in t.meta['relations']]
assert len(observations) == 2
assert any(o['category'] == 'tech' and 'important' in o['tags'] for o in observations)
assert any(o['category'] == 'design' and o['context'] == 'For context' for o in observations)
# Should find all relations (Feature, AnotherDoc, and Link)
assert len(relations) == 3
assert any(r['target'] == 'Link' and r['type'] == 'links to' for r in relations)
assert any(r['target'] == 'Feature' for r in relations)
assert any(r['target'] == 'AnotherDoc' for r in relations)
+186
View File
@@ -0,0 +1,186 @@
"""Tests for MarkdownProcessor.
Tests focus on the Read -> Modify -> Write pattern and content preservation.
"""
from datetime import datetime
from pathlib import Path
import pytest
from basic_memory.markdown.markdown_processor import MarkdownProcessor, DirtyFileError
from basic_memory.markdown.schemas import (
EntityMarkdown,
EntityFrontmatter,
Observation,
Relation,
)
@pytest.mark.asyncio
async def test_write_new_minimal_file(markdown_processor: MarkdownProcessor, tmp_path: Path):
"""Test creating new file with just title."""
path = tmp_path / "test.md"
# Create minimal markdown schema
metadata = {}
metadata["title"] = "Test Note"
metadata["type"] = "note"
metadata["permalink"] = "test"
metadata["created"] = datetime(2024, 1, 1)
metadata["modified"] = datetime(2024, 1, 1)
metadata["tags"] = ["test"]
markdown = EntityMarkdown(
frontmatter=EntityFrontmatter(
metadata=metadata,
),
content="",
)
# Write file
checksum = await markdown_processor.write_file(path, markdown)
# Read back and verify
content = path.read_text()
assert "---" in content # Has frontmatter
assert "type: note" in content
assert "permalink: test" in content
assert "# Test Note" in content # Added title
assert "tags:" in content
assert "- test" in content
# Should not have empty sections
assert "## Observations" not in content
assert "## Relations" not in content
@pytest.mark.asyncio
async def test_write_new_file_with_content(markdown_processor: MarkdownProcessor, tmp_path: Path):
"""Test creating new file with content and sections."""
path = tmp_path / "test.md"
# Create markdown with content and sections
markdown = EntityMarkdown(
frontmatter=EntityFrontmatter(
type="note",
permalink="test",
title="Test Note",
created=datetime(2024, 1, 1),
modified=datetime(2024, 1, 1),
),
content="# Custom Title\n\nMy content here.\nMultiple lines.",
observations=[
Observation(
content="Test observation",
category="tech",
tags=["test"],
context="test context",
),
],
relations=[
Relation(
type="relates_to",
target="other-note",
context="test relation",
),
],
)
# Write file
checksum = await markdown_processor.write_file(path, markdown)
# Read back and verify
content = path.read_text()
# Check content preserved exactly
assert "# Custom Title" in content
assert "My content here." in content
assert "Multiple lines." in content
# Check sections formatted correctly
assert "- [tech] Test observation #test (test context)" in content
assert "- relates_to [[other-note]] (test relation)" in content
@pytest.mark.asyncio
async def test_update_preserves_content(markdown_processor: MarkdownProcessor, tmp_path: Path):
"""Test that updating file preserves existing content."""
path = tmp_path / "test.md"
# Create initial file
initial = EntityMarkdown(
frontmatter=EntityFrontmatter(
type="note",
permalink="test",
title="Test Note",
created=datetime(2024, 1, 1),
modified=datetime(2024, 1, 1),
),
content="# My Note\n\nOriginal content here.",
observations=[
Observation(content="First observation", category="note"),
],
)
checksum = await markdown_processor.write_file(path, initial)
# Update with new observation
updated = EntityMarkdown(
frontmatter=initial.frontmatter,
content=initial.content, # Preserve original content
observations=[
initial.observations[0], # Keep original observation
Observation(content="Second observation", category="tech"), # Add new one
],
)
# Update file
new_checksum = await markdown_processor.write_file(path, updated, expected_checksum=checksum)
# Read back and verify
result = await markdown_processor.read_file(path)
# Original content preserved
assert "Original content here." in result.content
# Both observations present
assert len(result.observations) == 2
assert any(o.content == "First observation" for o in result.observations)
assert any(o.content == "Second observation" for o in result.observations)
@pytest.mark.asyncio
async def test_dirty_file_detection(markdown_processor: MarkdownProcessor, tmp_path: Path):
"""Test detection of file modifications."""
path = tmp_path / "test.md"
# Create initial file
initial = EntityMarkdown(
frontmatter=EntityFrontmatter(
type="note",
permalink="test",
title="Test Note",
created=datetime(2024, 1, 1),
modified=datetime(2024, 1, 1),
),
content="Initial content",
)
checksum = await markdown_processor.write_file(path, initial)
# Modify file directly
path.write_text(path.read_text() + "\nModified!")
# Try to update with old checksum
update = EntityMarkdown(
frontmatter=initial.frontmatter,
content="New content",
)
# Should raise DirtyFileError
with pytest.raises(DirtyFileError):
await markdown_processor.write_file(path, update, expected_checksum=checksum)
# Should succeed without checksum
new_checksum = await markdown_processor.write_file(path, update)
assert new_checksum != checksum
@@ -0,0 +1,137 @@
"""Tests for edge cases in observation parsing."""
from markdown_it import MarkdownIt
from basic_memory.markdown.plugins import observation_plugin, parse_observation
from basic_memory.markdown.schemas import Observation
def test_empty_input():
"""Test handling of empty input."""
md = MarkdownIt().use(observation_plugin)
tokens = md.parse("")
assert not any(t.meta and "observation" in t.meta for t in tokens)
tokens = md.parse(" ")
assert not any(t.meta and "observation" in t.meta for t in tokens)
tokens = md.parse("\n")
assert not any(t.meta and "observation" in t.meta for t in tokens)
def test_invalid_context():
"""Test handling of invalid context format."""
md = MarkdownIt().use(observation_plugin)
# Unclosed context
tokens = md.parse("- [test] Content (unclosed")
token = next(t for t in tokens if t.type == "inline")
obs = parse_observation(token)
assert obs["content"] == "Content (unclosed"
assert obs["context"] is None
# Multiple parens
tokens = md.parse("- [test] Content (with) extra) parens)")
token = next(t for t in tokens if t.type == "inline")
obs = parse_observation(token)
assert obs["content"] == "Content"
assert obs["context"] == "with) extra) parens"
def test_complex_format():
"""Test parsing complex observation formats."""
md = MarkdownIt().use(observation_plugin)
# Multiple hashtags together
tokens = md.parse("- [complex test] This is #tag1#tag2 with #tag3 content")
token = next(t for t in tokens if t.type == "inline")
obs = parse_observation(token)
assert obs["category"] == "complex test"
assert set(obs["tags"]) == {"tag1", "tag2", "tag3"}
assert obs["content"] == "This is with content"
# Pydantic model validation
observation = Observation.model_validate(obs)
assert observation.category == "complex test"
assert set(observation.tags) == {"tag1", "tag2", "tag3"}
assert observation.content == "This is with content"
def test_malformed_category():
"""Test handling of malformed category brackets."""
md = MarkdownIt().use(observation_plugin)
# Empty category
tokens = md.parse("- [] Empty category")
token = next(t for t in tokens if t.type == "inline")
observation = Observation.model_validate(parse_observation(token))
assert observation.category is None
assert observation.content == "Empty category"
# Missing close bracket
tokens = md.parse("- [test Content")
token = next(t for t in tokens if t.type == "inline")
observation = Observation.model_validate(parse_observation(token))
# Should treat whole thing as content
assert observation.category is None
assert "test Content" in observation.content
def test_no_category():
"""Test handling of malformed category brackets."""
md = MarkdownIt().use(observation_plugin)
# Empty category
tokens = md.parse("- No category")
token = next(t for t in tokens if t.type == "inline")
observation = Observation.model_validate(parse_observation(token))
assert observation.category is None
assert observation.content == "No category"
def test_whitespace_handling():
"""Test handling of various whitespace in content."""
md = MarkdownIt().use(observation_plugin)
# Various whitespace in content
test_chars = {" ": "space", "\t": "tab", "\r": "return"}
for char, name in test_chars.items():
content = f"- [test] Content{char}with{char}{name}"
tokens = md.parse(content)
token = next(t for t in tokens if t.type == "inline")
obs = parse_observation(token)
observation = Observation.model_validate(obs)
assert observation.content == f"Content with {name}"
def test_unicode_content():
"""Test handling of Unicode content."""
md = MarkdownIt().use(observation_plugin)
# Emoji
tokens = md.parse("- [test] Emoji test 👍 #emoji #test (Testing emoji)")
token = next(t for t in tokens if t.type == "inline")
obs = parse_observation(token)
assert "👍" in obs["content"]
assert "emoji" in obs["tags"]
# Non-Latin scripts
tokens = md.parse("- [中文] Chinese text 测试 #language (Script test)")
token = next(t for t in tokens if t.type == "inline")
obs = parse_observation(token)
assert obs["category"] == "中文"
assert "测试" in obs["content"]
# Mixed scripts and emoji
tokens = md.parse("- [test] Mixed 中文 and 👍 #mixed")
token = next(t for t in tokens if t.type == "inline")
obs = parse_observation(token)
assert "中文" in obs["content"]
assert "👍" in obs["content"]
# Model validation with Unicode
observation = Observation.model_validate(obs)
assert "中文" in observation.content
assert "👍" in observation.content
+184
View File
@@ -0,0 +1,184 @@
"""Tests for markdown parser edge cases."""
from pathlib import Path
from textwrap import dedent
import pytest
from basic_memory.markdown.entity_parser import EntityParser
@pytest.mark.asyncio
async def test_unicode_content(tmp_path):
"""Test handling of Unicode content including emoji and non-Latin scripts."""
content = dedent("""
---
type: test
id: test/unicode
created: 2024-12-21T14:00:00Z
modified: 2024-12-21T14:00:00Z
tags: [unicode, 测试]
---
# Unicode Test 🧪
## Observations
- [test] Emoji test 👍 #emoji #test (Testing emoji)
- [中文] Chinese text 测试 #language (Script test)
- [русский] Russian привет #language (More scripts)
- [note] Emoji in text 😀 #meta (Category test)
## Relations
- tested_by [[测试组件]] (Unicode test)
- depends_on [[компонент]] (Another test)
""")
test_file = tmp_path / "unicode.md"
test_file.write_text(content, encoding="utf-8")
parser = EntityParser(tmp_path)
entity = await parser.parse_file(test_file)
assert "测试" in entity.frontmatter.metadata["tags"]
assert "chinese" not in entity.frontmatter.metadata["tags"]
assert "🧪" in entity.content
# Verify Unicode in observations
assert any(o.content == "Emoji test 👍" for o in entity.observations)
assert any(o.category == "中文" for o in entity.observations)
assert any(o.category == "русский" for o in entity.observations)
# Verify Unicode in relations
assert any(r.target == "测试组件" for r in entity.relations)
assert any(r.target == "компонент" for r in entity.relations)
@pytest.mark.asyncio
async def test_empty_file(tmp_path):
"""Test handling of empty files."""
empty_file = tmp_path / "empty.md"
empty_file.write_text("")
parser = EntityParser(tmp_path)
entity = await parser.parse_file(empty_file)
assert entity.observations == []
assert entity.relations == []
@pytest.mark.asyncio
async def test_missing_sections(tmp_path):
"""Test handling of files with missing sections."""
content = dedent("""
---
type: test
id: test/missing
created: 2024-01-09
modified: 2024-01-09
tags: []
---
Just some content
with [[links]] but no sections
""")
test_file = tmp_path / "missing.md"
test_file.write_text(content)
parser = EntityParser(tmp_path)
entity = await parser.parse_file(test_file)
assert len(entity.relations) == 1
assert entity.relations[0].target == "links"
assert entity.relations[0].type == "links to"
@pytest.mark.asyncio
async def test_tasks_are_not_observations(tmp_path):
"""Test handling of plain observations without categories."""
content = dedent("""
---
type: test
id: test/missing
created: 2024-01-09
modified: 2024-01-09
tags: []
---
- [ ] one
-[ ] two
- [x] done
- [-] not done
""")
test_file = tmp_path / "missing.md"
test_file.write_text(content)
parser = EntityParser(tmp_path)
entity = await parser.parse_file(test_file)
assert len(entity.observations) == 0
@pytest.mark.asyncio
async def test_nested_content(tmp_path):
"""Test handling of deeply nested content."""
content = dedent("""
---
type: test
id: test/nested
created: 2024-01-09
modified: 2024-01-09
tags: []
---
# Test
## Level 1
- [test] Level 1 #test (First level)
- implements [[One]]
### Level 2
- [test] Level 2 #test (Second level)
- uses [[Two]]
#### Level 3
- [test] Level 3 #test (Third level)
- needs [[Three]]
""")
test_file = tmp_path / "nested.md"
test_file.write_text(content)
parser = EntityParser(tmp_path)
entity = await parser.parse_file(test_file)
# Should find all observations and relations regardless of nesting
assert len(entity.observations) == 3
assert len(entity.relations) == 3
assert {r.target for r in entity.relations} == {"One", "Two", "Three"}
@pytest.mark.asyncio
async def test_malformed_frontmatter(tmp_path):
"""Test handling of malformed frontmatter."""
# Missing fields
content = dedent("""
---
type: test
---
# Test
""")
test_file = tmp_path / "malformed.md"
test_file.write_text(content)
parser = EntityParser(tmp_path)
entity = await parser.parse_file(test_file)
assert entity.frontmatter.permalink is None
@pytest.mark.asyncio
async def test_file_not_found():
"""Test handling of non-existent files."""
parser = EntityParser(Path("/tmp"))
with pytest.raises(FileNotFoundError):
await parser.parse_file(Path("nonexistent.md"))
+124
View File
@@ -0,0 +1,124 @@
"""Tests for edge cases in relation parsing."""
from markdown_it import MarkdownIt
from basic_memory.markdown.plugins import relation_plugin, parse_relation, parse_inline_relations
from basic_memory.markdown.schemas import Relation
def test_empty_targets():
"""Test handling of empty targets."""
md = MarkdownIt().use(relation_plugin)
# Empty brackets
tokens = md.parse("- type [[]]")
token = next(t for t in tokens if t.type == "inline")
assert parse_relation(token) is None
# Only spaces
tokens = md.parse("- type [[ ]]")
token = next(t for t in tokens if t.type == "inline")
assert parse_relation(token) is None
# Whitespace in brackets
tokens = md.parse("- type [[ ]]")
token = next(t for t in tokens if t.type == "inline")
assert parse_relation(token) is None
def test_malformed_links():
"""Test handling of malformed wiki links."""
md = MarkdownIt().use(relation_plugin)
# Missing close brackets
tokens = md.parse("- type [[Target")
assert not any(t.meta and "relations" in t.meta for t in tokens)
# Missing open brackets
tokens = md.parse("- type Target]]")
assert not any(t.meta and "relations" in t.meta for t in tokens)
# Backwards brackets
tokens = md.parse("- type ]]Target[[")
assert not any(t.meta and "relations" in t.meta for t in tokens)
# Nested brackets
tokens = md.parse("- type [[Outer [[Inner]] ]]")
token = next(t for t in tokens if t.type == "inline")
rel = parse_relation(token)
assert rel is not None
assert "Outer" in rel["target"]
def test_context_handling():
"""Test handling of contexts."""
md = MarkdownIt().use(relation_plugin)
# Unclosed context
tokens = md.parse("- type [[Target]] (unclosed")
token = next(t for t in tokens if t.type == "inline")
rel = parse_relation(token)
assert rel["context"] is None
# Multiple parens
tokens = md.parse("- type [[Target]] (with (nested) parens)")
token = next(t for t in tokens if t.type == "inline")
rel = parse_relation(token)
assert rel["context"] == "with (nested) parens"
# Empty context
tokens = md.parse("- type [[Target]] ()")
token = next(t for t in tokens if t.type == "inline")
rel = parse_relation(token)
assert rel["context"] is None
def test_inline_relations():
"""Test inline relation detection."""
md = MarkdownIt().use(relation_plugin)
# Multiple links in text
text = "Text with [[Link1]] and [[Link2]] and [[Link3]]"
rels = parse_inline_relations(text)
assert len(rels) == 3
assert {r["target"] for r in rels} == {"Link1", "Link2", "Link3"}
# Links with surrounding text
text = "Before [[Target]] After"
rels = parse_inline_relations(text)
assert len(rels) == 1
assert rels[0]["target"] == "Target"
# Multiple links on same line
tokens = md.parse("[[One]] [[Two]] [[Three]]")
token = next(t for t in tokens if t.type == "inline")
assert len(token.meta["relations"]) == 3
def test_unicode_targets():
"""Test handling of Unicode in targets."""
md = MarkdownIt().use(relation_plugin)
# Unicode in target
tokens = md.parse("- type [[测试]]")
token = next(t for t in tokens if t.type == "inline")
rel = parse_relation(token)
assert rel["target"] == "测试"
# Unicode in type
tokens = md.parse("- 使用 [[Target]]")
token = next(t for t in tokens if t.type == "inline")
rel = parse_relation(token)
assert rel["type"] == "使用"
# Unicode in context
tokens = md.parse("- type [[Target]] (测试)")
token = next(t for t in tokens if t.type == "inline")
rel = parse_relation(token)
assert rel["context"] == "测试"
# Model validation with Unicode
relation = Relation.model_validate(rel)
assert relation.type == "type"
assert relation.target == "Target"
assert relation.context == "测试"
+53
View File
@@ -0,0 +1,53 @@
"""Tests for the MCP server implementation using FastAPI TestClient."""
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
from mcp.server import FastMCP
from basic_memory.api.app import app as fastapi_app
from basic_memory.deps import get_project_config, get_engine_factory
from basic_memory.services.search_service import SearchService
from basic_memory.mcp.server import mcp as mcp_server
@pytest.fixture
def mcp() -> FastMCP:
return mcp_server
@pytest_asyncio.fixture
def app(test_config, engine_factory) -> FastAPI:
"""Create test FastAPI application."""
app = fastapi_app
app.dependency_overrides[get_project_config] = lambda: test_config
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
return app
@pytest_asyncio.fixture
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
"""Create test client that both MCP and tests will use."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
yield client
@pytest_asyncio.fixture
def test_entity_data():
"""Sample data for creating a test entity."""
return {
"entities": [
{
"title": "Test Entity",
"entity_type": "test",
"summary": "", # Empty string instead of None
}
]
}
@pytest_asyncio.fixture(autouse=True)
async def init_search_index(search_service: SearchService):
await search_service.init_search_index()
+95
View File
@@ -0,0 +1,95 @@
"""Tests for AI edit tool."""
import pytest
from pathlib import Path
from basic_memory.mcp.tools.ai_edit import ai_edit
@pytest.fixture
def test_file(tmp_path):
"""Create a test file."""
content = '''"""Test file for edits."""
class TestClass:
def method_one(self):
x = 1
y = 2
return x + y
def method_two(self):
# TODO implement
pass
'''
path = tmp_path / "test.py"
path.write_text(content)
return path
@pytest.mark.asyncio
async def test_simple_replace(test_file):
"""Test basic text replacement."""
success = await ai_edit(
str(test_file),
[{
'oldText': 'return x + y',
'newText': 'total = x + y\nreturn total'
}]
)
assert success
content = test_file.read_text()
assert 'total = x + y' in content
assert 'return total' in content
@pytest.mark.asyncio
async def test_indented_replace(test_file):
"""Test replacing with indentation detection."""
success = await ai_edit(
str(test_file),
[{
'oldText': ' # TODO implement\n pass',
'newText': 'values = [1, 2, 3]\nreturn sum(values)',
'options': {'preserveIndentation': False}
}]
)
assert success
content = test_file.read_text()
assert ' values = [1, 2, 3]' in content
assert ' return sum(values)' in content
@pytest.mark.asyncio
async def test_specified_indent(test_file):
"""Test with explicit indentation."""
success = await ai_edit(
str(test_file),
[{
'oldText': ' def method_two(self):',
'newText': 'def method_two(self):',
'options': {'indent': 4, 'preserveIndentation': True}
}]
)
assert success
content = test_file.read_text()
assert ' def method_two(self):' in content
@pytest.mark.asyncio
async def test_no_match(test_file):
"""Test handling of non-existent text."""
success = await ai_edit(
str(test_file),
[{
'oldText': 'this text does not exist',
'newText': 'something else'
}]
)
# Should fail gracefully
assert not success
# File should be unchanged
assert test_file.read_text() == test_file.read_text()

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