basic-memory 0.12.2__py3-none-any.whl → 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (117) hide show
  1. basic_memory/__init__.py +2 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  5. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
  6. basic_memory/api/app.py +43 -13
  7. basic_memory/api/routers/__init__.py +4 -2
  8. basic_memory/api/routers/directory_router.py +63 -0
  9. basic_memory/api/routers/importer_router.py +152 -0
  10. basic_memory/api/routers/knowledge_router.py +139 -37
  11. basic_memory/api/routers/management_router.py +78 -0
  12. basic_memory/api/routers/memory_router.py +6 -62
  13. basic_memory/api/routers/project_router.py +234 -0
  14. basic_memory/api/routers/prompt_router.py +260 -0
  15. basic_memory/api/routers/search_router.py +3 -21
  16. basic_memory/api/routers/utils.py +130 -0
  17. basic_memory/api/template_loader.py +292 -0
  18. basic_memory/cli/app.py +20 -21
  19. basic_memory/cli/commands/__init__.py +2 -1
  20. basic_memory/cli/commands/auth.py +136 -0
  21. basic_memory/cli/commands/db.py +3 -3
  22. basic_memory/cli/commands/import_chatgpt.py +31 -207
  23. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  24. basic_memory/cli/commands/import_claude_projects.py +33 -143
  25. basic_memory/cli/commands/import_memory_json.py +26 -83
  26. basic_memory/cli/commands/mcp.py +71 -18
  27. basic_memory/cli/commands/project.py +102 -70
  28. basic_memory/cli/commands/status.py +19 -9
  29. basic_memory/cli/commands/sync.py +44 -58
  30. basic_memory/cli/commands/tool.py +6 -6
  31. basic_memory/cli/main.py +1 -5
  32. basic_memory/config.py +143 -87
  33. basic_memory/db.py +6 -4
  34. basic_memory/deps.py +227 -30
  35. basic_memory/importers/__init__.py +27 -0
  36. basic_memory/importers/base.py +79 -0
  37. basic_memory/importers/chatgpt_importer.py +222 -0
  38. basic_memory/importers/claude_conversations_importer.py +172 -0
  39. basic_memory/importers/claude_projects_importer.py +148 -0
  40. basic_memory/importers/memory_json_importer.py +93 -0
  41. basic_memory/importers/utils.py +58 -0
  42. basic_memory/markdown/entity_parser.py +5 -2
  43. basic_memory/mcp/auth_provider.py +270 -0
  44. basic_memory/mcp/external_auth_provider.py +321 -0
  45. basic_memory/mcp/project_session.py +103 -0
  46. basic_memory/mcp/prompts/__init__.py +2 -0
  47. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  48. basic_memory/mcp/prompts/recent_activity.py +20 -4
  49. basic_memory/mcp/prompts/search.py +14 -140
  50. basic_memory/mcp/prompts/sync_status.py +116 -0
  51. basic_memory/mcp/prompts/utils.py +3 -3
  52. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  53. basic_memory/mcp/server.py +86 -13
  54. basic_memory/mcp/supabase_auth_provider.py +463 -0
  55. basic_memory/mcp/tools/__init__.py +24 -0
  56. basic_memory/mcp/tools/build_context.py +43 -8
  57. basic_memory/mcp/tools/canvas.py +17 -3
  58. basic_memory/mcp/tools/delete_note.py +168 -5
  59. basic_memory/mcp/tools/edit_note.py +303 -0
  60. basic_memory/mcp/tools/list_directory.py +154 -0
  61. basic_memory/mcp/tools/move_note.py +299 -0
  62. basic_memory/mcp/tools/project_management.py +332 -0
  63. basic_memory/mcp/tools/read_content.py +15 -6
  64. basic_memory/mcp/tools/read_note.py +28 -9
  65. basic_memory/mcp/tools/recent_activity.py +47 -16
  66. basic_memory/mcp/tools/search.py +189 -8
  67. basic_memory/mcp/tools/sync_status.py +254 -0
  68. basic_memory/mcp/tools/utils.py +184 -12
  69. basic_memory/mcp/tools/view_note.py +66 -0
  70. basic_memory/mcp/tools/write_note.py +24 -17
  71. basic_memory/models/__init__.py +3 -2
  72. basic_memory/models/knowledge.py +16 -4
  73. basic_memory/models/project.py +78 -0
  74. basic_memory/models/search.py +8 -5
  75. basic_memory/repository/__init__.py +2 -0
  76. basic_memory/repository/entity_repository.py +8 -3
  77. basic_memory/repository/observation_repository.py +35 -3
  78. basic_memory/repository/project_info_repository.py +3 -2
  79. basic_memory/repository/project_repository.py +85 -0
  80. basic_memory/repository/relation_repository.py +8 -2
  81. basic_memory/repository/repository.py +107 -15
  82. basic_memory/repository/search_repository.py +192 -54
  83. basic_memory/schemas/__init__.py +6 -0
  84. basic_memory/schemas/base.py +33 -5
  85. basic_memory/schemas/directory.py +30 -0
  86. basic_memory/schemas/importer.py +34 -0
  87. basic_memory/schemas/memory.py +84 -13
  88. basic_memory/schemas/project_info.py +112 -2
  89. basic_memory/schemas/prompt.py +90 -0
  90. basic_memory/schemas/request.py +56 -2
  91. basic_memory/schemas/search.py +1 -1
  92. basic_memory/services/__init__.py +2 -1
  93. basic_memory/services/context_service.py +208 -95
  94. basic_memory/services/directory_service.py +167 -0
  95. basic_memory/services/entity_service.py +399 -6
  96. basic_memory/services/exceptions.py +6 -0
  97. basic_memory/services/file_service.py +14 -15
  98. basic_memory/services/initialization.py +170 -66
  99. basic_memory/services/link_resolver.py +35 -12
  100. basic_memory/services/migration_service.py +168 -0
  101. basic_memory/services/project_service.py +671 -0
  102. basic_memory/services/search_service.py +77 -2
  103. basic_memory/services/sync_status_service.py +181 -0
  104. basic_memory/sync/background_sync.py +25 -0
  105. basic_memory/sync/sync_service.py +102 -21
  106. basic_memory/sync/watch_service.py +63 -39
  107. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  108. basic_memory/templates/prompts/search.hbs +101 -0
  109. basic_memory/utils.py +67 -17
  110. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/METADATA +26 -4
  111. basic_memory-0.13.0.dist-info/RECORD +138 -0
  112. basic_memory/api/routers/project_info_router.py +0 -274
  113. basic_memory/mcp/main.py +0 -24
  114. basic_memory-0.12.2.dist-info/RECORD +0 -100
  115. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
  116. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
  117. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,21 +1,21 @@
1
1
  """Watch service for Basic Memory."""
2
2
 
3
+ import asyncio
3
4
  import os
5
+ from collections import defaultdict
4
6
  from datetime import datetime
5
7
  from pathlib import Path
6
8
  from typing import List, Optional, Set
7
9
 
8
- from basic_memory.config import ProjectConfig
9
- from basic_memory.services.file_service import FileService
10
- from basic_memory.sync.sync_service import SyncService
10
+ from basic_memory.config import BasicMemoryConfig, WATCH_STATUS_JSON
11
+ from basic_memory.models import Project
12
+ from basic_memory.repository import ProjectRepository
11
13
  from loguru import logger
12
14
  from pydantic import BaseModel
13
15
  from rich.console import Console
14
16
  from watchfiles import awatch
15
17
  from watchfiles.main import FileChange, Change
16
18
 
17
- WATCH_STATUS_JSON = "watch-status.json"
18
-
19
19
 
20
20
  class WatchEvent(BaseModel):
21
21
  timestamp: datetime
@@ -72,16 +72,14 @@ class WatchServiceState(BaseModel):
72
72
  class WatchService:
73
73
  def __init__(
74
74
  self,
75
- sync_service: SyncService,
76
- file_service: FileService,
77
- config: ProjectConfig,
75
+ app_config: BasicMemoryConfig,
76
+ project_repository: ProjectRepository,
78
77
  quiet: bool = False,
79
78
  ):
80
- self.sync_service = sync_service
81
- self.file_service = file_service
82
- self.config = config
79
+ self.app_config = app_config
80
+ self.project_repository = project_repository
83
81
  self.state = WatchServiceState()
84
- self.status_path = config.home / ".basic-memory" / WATCH_STATUS_JSON
82
+ self.status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON
85
83
  self.status_path.parent.mkdir(parents=True, exist_ok=True)
86
84
 
87
85
  # quiet mode for mcp so it doesn't mess up stdout
@@ -89,10 +87,14 @@ class WatchService:
89
87
 
90
88
  async def run(self): # pragma: no cover
91
89
  """Watch for file changes and sync them"""
90
+
91
+ projects = await self.project_repository.get_active_projects()
92
+ project_paths = [project.path for project in projects]
93
+
92
94
  logger.info(
93
95
  "Watch service started",
94
- f"directory={str(self.config.home)}",
95
- f"debounce_ms={self.config.sync_delay}",
96
+ f"directories={project_paths}",
97
+ f"debounce_ms={self.app_config.sync_delay}",
96
98
  f"pid={os.getpid()}",
97
99
  )
98
100
 
@@ -102,15 +104,30 @@ class WatchService:
102
104
 
103
105
  try:
104
106
  async for changes in awatch(
105
- self.config.home,
106
- debounce=self.config.sync_delay,
107
+ *project_paths,
108
+ debounce=self.app_config.sync_delay,
107
109
  watch_filter=self.filter_changes,
108
110
  recursive=True,
109
111
  ):
110
- await self.handle_changes(self.config.home, changes)
112
+ # group changes by project
113
+ project_changes = defaultdict(list)
114
+ for change, path in changes:
115
+ for project in projects:
116
+ if self.is_project_path(project, path):
117
+ project_changes[project].append((change, path))
118
+ break
119
+
120
+ # create coroutines to handle changes
121
+ change_handlers = [
122
+ self.handle_changes(project, changes) # pyright: ignore
123
+ for project, changes in project_changes.items()
124
+ ]
125
+
126
+ # process changes
127
+ await asyncio.gather(*change_handlers)
111
128
 
112
129
  except Exception as e:
113
- logger.exception("Watch service error", error=str(e), directory=str(self.config.home))
130
+ logger.exception("Watch service error", error=str(e))
114
131
 
115
132
  self.state.record_error(str(e))
116
133
  await self.write_status()
@@ -119,7 +136,6 @@ class WatchService:
119
136
  finally:
120
137
  logger.info(
121
138
  "Watch service stopped",
122
- f"directory={str(self.config.home)}",
123
139
  f"runtime_seconds={int((datetime.now() - self.state.start_time).total_seconds())}",
124
140
  )
125
141
 
@@ -132,15 +148,9 @@ class WatchService:
132
148
  Returns:
133
149
  True if the file should be watched, False if it should be ignored
134
150
  """
135
- # Skip if path is invalid
136
- try:
137
- relative_path = Path(path).relative_to(self.config.home)
138
- except ValueError:
139
- # This is a defensive check for paths outside our home directory
140
- return False
141
151
 
142
152
  # Skip hidden directories and files
143
- path_parts = relative_path.parts
153
+ path_parts = Path(path).parts
144
154
  for part in path_parts:
145
155
  if part.startswith("."):
146
156
  return False
@@ -155,14 +165,30 @@ class WatchService:
155
165
  """Write current state to status file"""
156
166
  self.status_path.write_text(WatchServiceState.model_dump_json(self.state, indent=2))
157
167
 
158
- async def handle_changes(self, directory: Path, changes: Set[FileChange]):
168
+ def is_project_path(self, project: Project, path):
169
+ """
170
+ Checks if path is a subdirectory or file within a project
171
+ """
172
+ project_path = Path(project.path).resolve()
173
+ sub_path = Path(path).resolve()
174
+ return project_path in sub_path.parents
175
+
176
+ async def handle_changes(self, project: Project, changes: Set[FileChange]) -> None:
159
177
  """Process a batch of file changes"""
160
178
  import time
161
179
  from typing import List, Set
162
180
 
163
- start_time = time.time()
181
+ # Lazily initialize sync service for project changes
182
+ from basic_memory.cli.commands.sync import get_sync_service
183
+
184
+ sync_service = await get_sync_service(project)
185
+ file_service = sync_service.file_service
164
186
 
165
- logger.info(f"Processing file changes, change_count={len(changes)}, directory={directory}")
187
+ start_time = time.time()
188
+ directory = Path(project.path).resolve()
189
+ logger.info(
190
+ f"Processing project: {project.name} changes, change_count={len(changes)}, directory={directory}"
191
+ )
166
192
 
167
193
  # Group changes by type
168
194
  adds: List[str] = []
@@ -190,7 +216,7 @@ class WatchService:
190
216
 
191
217
  # because of our atomic writes on updates, an add may be an existing file
192
218
  for added_path in adds: # pragma: no cover TODO add test
193
- entity = await self.sync_service.entity_repository.get_by_file_path(added_path)
219
+ entity = await sync_service.entity_repository.get_by_file_path(added_path)
194
220
  if entity is not None:
195
221
  logger.debug(f"Existing file will be processed as modified, path={added_path}")
196
222
  adds.remove(added_path)
@@ -218,9 +244,7 @@ class WatchService:
218
244
  continue # pragma: no cover
219
245
 
220
246
  # Skip directories for deleted paths (based on entity type in db)
221
- deleted_entity = await self.sync_service.entity_repository.get_by_file_path(
222
- deleted_path
223
- )
247
+ deleted_entity = await sync_service.entity_repository.get_by_file_path(deleted_path)
224
248
  if deleted_entity is None:
225
249
  # If this was a directory, it wouldn't have an entity
226
250
  logger.debug("Skipping unknown path for move detection", path=deleted_path)
@@ -229,10 +253,10 @@ class WatchService:
229
253
  if added_path != deleted_path:
230
254
  # Compare checksums to detect moves
231
255
  try:
232
- added_checksum = await self.file_service.compute_checksum(added_path)
256
+ added_checksum = await file_service.compute_checksum(added_path)
233
257
 
234
258
  if deleted_entity and deleted_entity.checksum == added_checksum:
235
- await self.sync_service.handle_move(deleted_path, added_path)
259
+ await sync_service.handle_move(deleted_path, added_path)
236
260
  self.state.add_event(
237
261
  path=f"{deleted_path} -> {added_path}",
238
262
  action="moved",
@@ -261,7 +285,7 @@ class WatchService:
261
285
  for path in deletes:
262
286
  if path not in processed:
263
287
  logger.debug("Processing deleted file", path=path)
264
- await self.sync_service.handle_delete(path)
288
+ await sync_service.handle_delete(path)
265
289
  self.state.add_event(path=path, action="deleted", status="success")
266
290
  self.console.print(f"[red]✕[/red] {path}")
267
291
  logger.info(f"deleted: {path}")
@@ -281,7 +305,7 @@ class WatchService:
281
305
  continue # pragma: no cover
282
306
 
283
307
  logger.debug(f"Processing new file, path={path}")
284
- entity, checksum = await self.sync_service.sync_file(path, new=True)
308
+ entity, checksum = await sync_service.sync_file(path, new=True)
285
309
  if checksum:
286
310
  self.state.add_event(
287
311
  path=path, action="new", status="success", checksum=checksum
@@ -314,7 +338,7 @@ class WatchService:
314
338
  continue
315
339
 
316
340
  logger.debug(f"Processing modified file: path={path}")
317
- entity, checksum = await self.sync_service.sync_file(path, new=False)
341
+ entity, checksum = await sync_service.sync_file(path, new=False)
318
342
  self.state.add_event(
319
343
  path=path, action="modified", status="success", checksum=checksum
320
344
  )
@@ -335,7 +359,7 @@ class WatchService:
335
359
  repeat_count = 0
336
360
  modify_count += 1
337
361
 
338
- logger.debug(
362
+ logger.debug( # pragma: no cover
339
363
  "Modified file processed, "
340
364
  f"path={path} "
341
365
  f"entity_id={entity.id if entity else None} "
@@ -0,0 +1,110 @@
1
+ # Continuing conversation on: {{ topic }}
2
+
3
+ This is a memory retrieval session.
4
+
5
+ Please use the available basic-memory tools to gather relevant context before responding. Start by executing one of the suggested commands below to retrieve content.
6
+
7
+ > **Knowledge Capture Recommendation:** As you continue this conversation, actively look for opportunities to record new information, decisions, or insights that emerge. Use `write_note()` to document important context.
8
+
9
+ Here's what I found from previous conversations:
10
+
11
+ {{#if has_results}}
12
+ {{#each hierarchical_results}}
13
+ <memory>
14
+ --- memory://{{ primary_result.permalink }}
15
+
16
+ ## {{ primary_result.title }}
17
+ - **Type**: {{ primary_result.type }}
18
+ - **Created**: {{date primary_result.created_at "%Y-%m-%d %H:%M"}}
19
+
20
+ {{#if primary_result.content}}
21
+ **Excerpt**:
22
+ <excerpt>
23
+ {{ primary_result.content }}
24
+ </excerpt>
25
+ {{/if}}
26
+
27
+ {{#if observations}}
28
+ ## Observations
29
+ {{#each observations}}
30
+ <observation>
31
+ - [{{ category }}] {{ content }}
32
+ </observation>
33
+ {{/each}}
34
+ {{/if}}
35
+
36
+ You can read this document with: `read_note("{{ primary_result.permalink }}")`
37
+
38
+ {{#if related_results}}
39
+ ## Related Context
40
+
41
+ {{#each related_results}}
42
+ <related>
43
+ - type: **{{ type }}**
44
+ - title: {{ title }}
45
+
46
+ {{#if permalink}}
47
+ You can view this document with: `read_note("{{ permalink }}")`
48
+ {{else}}
49
+ You can view this file with: `read_file("{{ file_path }}")`
50
+ {{/if}}
51
+ </related>
52
+ {{/each}}
53
+ {{/if}}
54
+
55
+ </memory>
56
+ {{/each}}
57
+ {{else}}
58
+ The supplied query did not return any information specifically on this topic.
59
+
60
+ ## Opportunity to Capture New Knowledge!
61
+
62
+ This is an excellent chance to start documenting this topic:
63
+
64
+ ```python
65
+ await write_note(
66
+ title="{{ topic }}",
67
+ content=f'''
68
+ # {{ topic }}
69
+
70
+ ## Overview
71
+ [Summary of what we know about {{ topic }}]
72
+
73
+ ## Key Points
74
+ [Main aspects or components of {{ topic }}]
75
+
76
+ ## Observations
77
+ - [category] [First important observation about {{ topic }}]
78
+ - [category] [Second observation about {{ topic }}]
79
+
80
+ ## Relations
81
+ - relates_to [[Related Topic]]
82
+ - part_of [[Broader Context]]
83
+ '''
84
+ )
85
+ ```
86
+
87
+ ## Other Options
88
+
89
+ Please use the available basic-memory tools to gather relevant context before responding.
90
+ You can also:
91
+ - Try a different search term
92
+ - Check recent activity with `recent_activity(timeframe="1w")`
93
+ {{/if}}
94
+ ## Next Steps
95
+ <instructions>
96
+ You can:
97
+ - Explore more with: `search_notes("{{ topic }}")`
98
+ - See what's changed: `recent_activity(timeframe="{{default timeframe "7d"}}")`
99
+ - **Record new learnings or decisions from this conversation:** `write_note(folder="[Chose a folder]" title="[Create a meaningful title]", content="[Content with observations and relations]")`
100
+
101
+ ## Knowledge Capture Recommendation
102
+
103
+ As you continue this conversation, **actively look for opportunities to:**
104
+ 1. Record key information, decisions, or insights that emerge
105
+ 2. Link new knowledge to existing topics
106
+ 3. Suggest capturing important context when appropriate
107
+ 4. Create forward references to topics that might be created later
108
+
109
+ Remember that capturing knowledge during conversations is one of the most valuable aspects of Basic Memory.
110
+ </instructions>
@@ -0,0 +1,101 @@
1
+ # Search Results for: "{{ query }}"{{#if timeframe}} (after {{ timeframe }}){{/if}}
2
+
3
+ This is a memory search session.
4
+ Please use the available basic-memory tools to gather relevant context before responding.
5
+ I found {{ result_count }} result(s) that match your query.
6
+
7
+ {{#if has_results}}
8
+ Here are the most relevant results:
9
+
10
+ {{#each results}}
11
+ {{#if_cond (lt @index 5)}}
12
+ {{#dedent}}
13
+ ## {{math @index "+" 1}}. {{ title }}
14
+ - **Type**: {{ type.value }}
15
+ {{#if metadata.created_at}}
16
+ - **Created**: {{date metadata.created_at "%Y-%m-%d %H:%M"}}
17
+ {{/if}}
18
+ - **Relevance Score**: {{round score 2}}
19
+
20
+ {{#if content}}
21
+ - **Excerpt**:
22
+ {{ content }}
23
+ {{/if}}
24
+
25
+ {{#if permalink}}
26
+ You can view this content with: `read_note("{{ permalink }}")`
27
+ Or explore its context with: `build_context("memory://{{ permalink }}")`
28
+ {{else}}
29
+ You can view this file with: `read_file("{{ file_path }}")`
30
+ {{/if}}
31
+ {{/dedent}}
32
+ {{/if_cond}}
33
+ {{/each}}
34
+
35
+ ## Next Steps
36
+
37
+ You can:
38
+ - Refine your search: `search_notes("{{ query }} AND additional_term")`
39
+ - Exclude terms: `search_notes("{{ query }} NOT exclude_term")`
40
+ - View more results: `search_notes("{{ query }}", after_date=None)`
41
+ - Check recent activity: `recent_activity()`
42
+
43
+ ## Synthesize and Capture Knowledge
44
+
45
+ Consider creating a new note that synthesizes what you've learned:
46
+
47
+ ```python
48
+ await write_note(
49
+ title="Synthesis of {{capitalize query}} Information",
50
+ content='''
51
+ # Synthesis of {{capitalize query}} Information
52
+
53
+ ## Overview
54
+ [Synthesis of the search results and your conversation]
55
+
56
+ ## Key Insights
57
+ [Summary of main points learned from these results]
58
+
59
+ ## Observations
60
+ - [insight] [Important observation from search results]
61
+ - [connection] [How this connects to other topics]
62
+
63
+ ## Relations
64
+ - relates_to [[{{#if results.length}}{{#if results.0.title}}{{results.0.title}}{{else}}Related Topic{{/if}}{{else}}Related Topic{{/if}}]]
65
+ - extends [[Another Relevant Topic]]
66
+ '''
67
+ )
68
+ ```
69
+
70
+ Remember that capturing synthesized knowledge is one of the most valuable features of Basic Memory.
71
+ {{else}}
72
+ I couldn't find any results for this query.
73
+
74
+ ## Opportunity to Capture Knowledge!
75
+
76
+ This is an excellent opportunity to create new knowledge on this topic. Consider:
77
+
78
+ ```python
79
+ await write_note(
80
+ title="{{capitalize query}}",
81
+ content='''
82
+ # {{capitalize query}}
83
+
84
+ ## Overview
85
+ [Summary of what we've discussed about {{ query }}]
86
+
87
+ ## Observations
88
+ - [category] [First observation about {{ query }}]
89
+ - [category] [Second observation about {{ query }}]
90
+
91
+ ## Relations
92
+ - relates_to [[Other Relevant Topic]]
93
+ '''
94
+ )
95
+ ```
96
+
97
+ ## Other Suggestions
98
+ - Try a different search term
99
+ - Broaden your search criteria
100
+ - Check recent activity with `recent_activity(timeframe="1w")`
101
+ {{/if}}
basic_memory/utils.py CHANGED
@@ -5,11 +5,11 @@ import os
5
5
  import logging
6
6
  import re
7
7
  import sys
8
+ import unicodedata
8
9
  from pathlib import Path
9
- from typing import Optional, Protocol, Union, runtime_checkable, List
10
+ from typing import Optional, Protocol, Union, runtime_checkable, List, Any
10
11
 
11
12
  from loguru import logger
12
- from unidecode import unidecode
13
13
 
14
14
 
15
15
  @runtime_checkable
@@ -27,23 +27,23 @@ FilePath = Union[Path, str]
27
27
  logging.getLogger("opentelemetry.sdk.metrics._internal.instrument").setLevel(logging.ERROR)
28
28
 
29
29
 
30
- def generate_permalink(file_path: Union[Path, str, PathLike]) -> str:
31
- """Generate a stable permalink from a file path.
32
-
33
- Args:
34
- file_path: Original file path (str, Path, or PathLike)
30
+ def generate_permalink(file_path: Union[Path, str, Any]) -> str:
31
+ """
32
+ Generate a permalink from a file path.
35
33
 
36
34
  Returns:
37
35
  Normalized permalink that matches validation rules. Converts spaces and underscores
38
- to hyphens for consistency.
36
+ to hyphens for consistency. Preserves non-ASCII characters like Chinese.
39
37
 
40
38
  Examples:
41
39
  >>> generate_permalink("docs/My Feature.md")
42
40
  'docs/my-feature'
43
- >>> generate_permalink("specs/API (v2).md")
41
+ >>> generate_permalink("specs/API_v2.md")
44
42
  'specs/api-v2'
45
43
  >>> generate_permalink("design/unified_model_refactor.md")
46
44
  'design/unified-model-refactor'
45
+ >>> generate_permalink("中文/测试文档.md")
46
+ '中文/测试文档'
47
47
  """
48
48
  # Convert Path to string if needed
49
49
  path_str = str(file_path)
@@ -51,24 +51,74 @@ def generate_permalink(file_path: Union[Path, str, PathLike]) -> str:
51
51
  # Remove extension
52
52
  base = os.path.splitext(path_str)[0]
53
53
 
54
- # Transliterate unicode to ascii
55
- ascii_text = unidecode(base)
54
+ # Create a transliteration mapping for specific characters
55
+ transliteration_map = {
56
+ "ø": "o", # Handle Søren -> soren
57
+ "å": "a", # Handle Kierkegård -> kierkegard
58
+ "ü": "u", # Handle Müller -> muller
59
+ "é": "e", # Handle Café -> cafe
60
+ "è": "e", # Handle Mère -> mere
61
+ "ê": "e", # Handle Fête -> fete
62
+ "à": "a", # Handle À la mode -> a la mode
63
+ "ç": "c", # Handle Façade -> facade
64
+ "ñ": "n", # Handle Niño -> nino
65
+ "ö": "o", # Handle Björk -> bjork
66
+ "ä": "a", # Handle Häagen -> haagen
67
+ # Add more mappings as needed
68
+ }
69
+
70
+ # Process character by character, transliterating Latin characters with diacritics
71
+ result = ""
72
+ for char in base:
73
+ # Direct mapping for known characters
74
+ if char.lower() in transliteration_map:
75
+ result += transliteration_map[char.lower()]
76
+ # General case using Unicode normalization
77
+ elif unicodedata.category(char).startswith("L") and ord(char) > 127:
78
+ # Decompose the character (e.g., ü -> u + combining diaeresis)
79
+ decomposed = unicodedata.normalize("NFD", char)
80
+ # If decomposition produced multiple characters and first one is ASCII
81
+ if len(decomposed) > 1 and ord(decomposed[0]) < 128:
82
+ # Keep only the base character
83
+ result += decomposed[0].lower()
84
+ else:
85
+ # For non-Latin scripts like Chinese, preserve the character
86
+ result += char
87
+ else:
88
+ # Add the character as is
89
+ result += char
90
+
91
+ # Handle special punctuation cases for apostrophes
92
+ result = result.replace("'", "")
56
93
 
57
94
  # Insert dash between camelCase
58
- ascii_text = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", ascii_text)
95
+ # This regex finds boundaries between lowercase and uppercase letters
96
+ result = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", result)
97
+
98
+ # Insert dash between Chinese and Latin character boundaries
99
+ # This is needed for cases like "中文English" -> "中文-english"
100
+ result = re.sub(r"([\u4e00-\u9fff])([a-zA-Z])", r"\1-\2", result)
101
+ result = re.sub(r"([a-zA-Z])([\u4e00-\u9fff])", r"\1-\2", result)
59
102
 
60
- # Convert to lowercase
61
- lower_text = ascii_text.lower()
103
+ # Convert ASCII letters to lowercase, preserve non-ASCII characters
104
+ lower_text = "".join(c.lower() if c.isascii() and c.isalpha() else c for c in result)
62
105
 
63
- # replace underscores with hyphens
106
+ # Replace underscores with hyphens
64
107
  text_with_hyphens = lower_text.replace("_", "-")
65
108
 
66
- # Replace remaining invalid chars with hyphens
67
- clean_text = re.sub(r"[^a-z0-9/\-]", "-", text_with_hyphens)
109
+ # Replace spaces and unsafe ASCII characters with hyphens, but preserve non-ASCII characters
110
+ # Include common Chinese character ranges and other non-ASCII characters
111
+ clean_text = re.sub(
112
+ r"[^a-z0-9\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf/\-]", "-", text_with_hyphens
113
+ )
68
114
 
69
115
  # Collapse multiple hyphens
70
116
  clean_text = re.sub(r"-+", "-", clean_text)
71
117
 
118
+ # Remove hyphens between adjacent Chinese characters only
119
+ # This handles cases like "你好-世界" -> "你好世界"
120
+ clean_text = re.sub(r"([\u4e00-\u9fff])-([\u4e00-\u9fff])", r"\1\2", clean_text)
121
+
72
122
  # Clean each path segment
73
123
  segments = clean_text.split("/")
74
124
  clean_segments = [s.strip("-") for s in segments]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-memory
3
- Version: 0.12.2
3
+ Version: 0.13.0
4
4
  Summary: Local-first knowledge management combining Zettelkasten with knowledge graphs
5
5
  Project-URL: Homepage, https://github.com/basicmachines-co/basic-memory
6
6
  Project-URL: Repository, https://github.com/basicmachines-co/basic-memory
@@ -13,18 +13,22 @@ Requires-Dist: aiosqlite>=0.20.0
13
13
  Requires-Dist: alembic>=1.14.1
14
14
  Requires-Dist: dateparser>=1.2.0
15
15
  Requires-Dist: fastapi[standard]>=0.115.8
16
+ Requires-Dist: fastmcp>=2.3.4
16
17
  Requires-Dist: greenlet>=3.1.1
17
18
  Requires-Dist: icecream>=2.1.3
18
19
  Requires-Dist: loguru>=0.7.3
19
20
  Requires-Dist: markdown-it-py>=3.0.0
20
21
  Requires-Dist: mcp>=1.2.0
21
22
  Requires-Dist: pillow>=11.1.0
23
+ Requires-Dist: pybars3>=0.9.7
22
24
  Requires-Dist: pydantic-settings>=2.6.1
23
25
  Requires-Dist: pydantic[email,timezone]>=2.10.3
26
+ Requires-Dist: pyjwt>=2.10.1
24
27
  Requires-Dist: pyright>=1.1.390
28
+ Requires-Dist: pytest-aio>=1.9.0
29
+ Requires-Dist: python-dotenv>=1.1.0
25
30
  Requires-Dist: python-frontmatter>=1.1.0
26
31
  Requires-Dist: pyyaml>=6.0.1
27
- Requires-Dist: qasync>=0.27.1
28
32
  Requires-Dist: rich>=13.9.4
29
33
  Requires-Dist: sqlalchemy>=2.0.0
30
34
  Requires-Dist: typer>=0.9.0
@@ -367,9 +371,9 @@ config:
367
371
  "command": "uvx",
368
372
  "args": [
369
373
  "basic-memory",
370
- "mcp",
371
374
  "--project",
372
- "your-project-name"
375
+ "your-project-name",
376
+ "mcp"
373
377
  ]
374
378
  }
375
379
  }
@@ -410,6 +414,24 @@ See the [Documentation](https://memory.basicmachines.co/) for more info, includi
410
414
  - [Managing multiple Projects](https://memory.basicmachines.co/docs/cli-reference#project)
411
415
  - [Importing data from OpenAI/Claude Projects](https://memory.basicmachines.co/docs/cli-reference#import)
412
416
 
417
+ ## Installation Options
418
+
419
+ ### Stable Release
420
+ ```bash
421
+ pip install basic-memory
422
+ ```
423
+
424
+ ### Beta/Pre-releases
425
+ ```bash
426
+ pip install basic-memory --pre
427
+ ```
428
+
429
+ ### Development Builds
430
+ Development versions are automatically published on every commit to main with versions like `0.12.4.dev26+468a22f`:
431
+ ```bash
432
+ pip install basic-memory --pre --force-reinstall
433
+ ```
434
+
413
435
  ## License
414
436
 
415
437
  AGPL-3.0