basic-memory 0.2.12__py3-none-any.whl → 0.16.1__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (149) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +63 -31
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +165 -28
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +28 -67
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +219 -14
  23. basic_memory/api/routers/search_router.py +21 -13
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +52 -1
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +26 -7
  41. basic_memory/cli/commands/import_chatgpt.py +83 -0
  42. basic_memory/cli/commands/import_claude_conversations.py +86 -0
  43. basic_memory/cli/commands/import_claude_projects.py +85 -0
  44. basic_memory/cli/commands/import_memory_json.py +35 -92
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +47 -30
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +13 -6
  50. basic_memory/config.py +481 -22
  51. basic_memory/db.py +192 -32
  52. basic_memory/deps.py +252 -22
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -14
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +437 -59
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +188 -23
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +57 -3
  115. basic_memory/schemas/response.py +9 -1
  116. basic_memory/schemas/search.py +33 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +251 -106
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +595 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +50 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +172 -34
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1176 -96
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +388 -28
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -203
  140. basic_memory/mcp/tools/knowledge.py +0 -56
  141. basic_memory/mcp/tools/memory.py +0 -151
  142. basic_memory/mcp/tools/notes.py +0 -122
  143. basic_memory/schemas/discovery.py +0 -28
  144. basic_memory/sync/file_change_scanner.py +0 -158
  145. basic_memory/sync/utils.py +0 -34
  146. basic_memory-0.2.12.dist-info/METADATA +0 -291
  147. basic_memory-0.2.12.dist-info/RECORD +0 -78
  148. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  149. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -2,11 +2,16 @@
2
2
 
3
3
  import hashlib
4
4
  from pathlib import Path
5
- from typing import Dict, Any
5
+ import re
6
+ from typing import Any, Dict, Union
6
7
 
8
+ import aiofiles
7
9
  import yaml
10
+ import frontmatter
8
11
  from loguru import logger
9
12
 
13
+ from basic_memory.utils import FilePath
14
+
10
15
 
11
16
  class FileError(Exception):
12
17
  """Base exception for file operations."""
@@ -26,12 +31,12 @@ class ParseError(FileError):
26
31
  pass
27
32
 
28
33
 
29
- async def compute_checksum(content: str) -> str:
34
+ async def compute_checksum(content: Union[str, bytes]) -> str:
30
35
  """
31
36
  Compute SHA-256 checksum of content.
32
37
 
33
38
  Args:
34
- content: Text content to hash
39
+ content: Content to hash (either text string or bytes)
35
40
 
36
41
  Returns:
37
42
  SHA-256 hex digest
@@ -40,48 +45,42 @@ async def compute_checksum(content: str) -> str:
40
45
  FileError: If checksum computation fails
41
46
  """
42
47
  try:
43
- return hashlib.sha256(content.encode()).hexdigest()
48
+ if isinstance(content, str):
49
+ content = content.encode()
50
+ return hashlib.sha256(content).hexdigest()
44
51
  except Exception as e: # pragma: no cover
45
52
  logger.error(f"Failed to compute checksum: {e}")
46
53
  raise FileError(f"Failed to compute checksum: {e}")
47
54
 
48
55
 
49
- async def ensure_directory(path: Path) -> None:
50
- """
51
- Ensure directory exists, creating if necessary.
52
-
53
- Args:
54
- path: Directory path to ensure
55
-
56
- Raises:
57
- FileWriteError: If directory creation fails
58
- """
59
- try:
60
- path.mkdir(parents=True, exist_ok=True)
61
- except Exception as e: # pragma: no cover
62
- logger.error(f"Failed to create directory: {path}: {e}")
63
- raise FileWriteError(f"Failed to create directory {path}: {e}")
64
-
65
-
66
- async def write_file_atomic(path: Path, content: str) -> None:
56
+ async def write_file_atomic(path: FilePath, content: str) -> None:
67
57
  """
68
58
  Write file with atomic operation using temporary file.
69
59
 
60
+ Uses aiofiles for true async I/O (non-blocking).
61
+
70
62
  Args:
71
- path: Target file path
63
+ path: Target file path (Path or string)
72
64
  content: Content to write
73
65
 
74
66
  Raises:
75
67
  FileWriteError: If write operation fails
76
68
  """
77
- temp_path = path.with_suffix(".tmp")
69
+ # Convert string to Path if needed
70
+ path_obj = Path(path) if isinstance(path, str) else path
71
+ temp_path = path_obj.with_suffix(".tmp")
72
+
78
73
  try:
79
- temp_path.write_text(content)
80
- temp_path.replace(path)
81
- logger.debug(f"wrote file: {path}")
74
+ # Use aiofiles for non-blocking write
75
+ async with aiofiles.open(temp_path, mode="w", encoding="utf-8") as f:
76
+ await f.write(content)
77
+
78
+ # Atomic rename (this is fast, doesn't need async)
79
+ temp_path.replace(path_obj)
80
+ logger.debug("Wrote file atomically", path=str(path_obj), content_length=len(content))
82
81
  except Exception as e: # pragma: no cover
83
82
  temp_path.unlink(missing_ok=True)
84
- logger.error(f"Failed to write file: {path}: {e}")
83
+ logger.error("Failed to write file", path=str(path_obj), error=str(e))
85
84
  raise FileWriteError(f"Failed to write file {path}: {e}")
86
85
 
87
86
 
@@ -95,6 +94,9 @@ def has_frontmatter(content: str) -> bool:
95
94
  Returns:
96
95
  True if content has valid frontmatter markers (---), False otherwise
97
96
  """
97
+ if not content:
98
+ return False
99
+
98
100
  content = content.strip()
99
101
  if not content.startswith("---"):
100
102
  return False
@@ -171,44 +173,97 @@ def remove_frontmatter(content: str) -> str:
171
173
  return parts[2].strip()
172
174
 
173
175
 
174
- async def update_frontmatter(path: Path, updates: Dict[str, Any]) -> str:
175
- """Update frontmatter fields in a file while preserving all content.
176
-
177
- Only modifies the frontmatter section, leaving all content untouched.
178
- Creates frontmatter section if none exists.
179
- Returns checksum of updated file.
176
+ def dump_frontmatter(post: frontmatter.Post) -> str:
177
+ """
178
+ Serialize frontmatter.Post to markdown with Obsidian-compatible YAML format.
179
+
180
+ This function ensures that:
181
+ 1. Tags are formatted as YAML lists instead of JSON arrays
182
+ 2. String values are properly quoted to handle special characters (colons, etc.)
183
+
184
+ Good (Obsidian compatible):
185
+ ---
186
+ title: "L2 Governance Core (Split: Core)"
187
+ tags:
188
+ - system
189
+ - overview
190
+ - reference
191
+ ---
192
+
193
+ Bad (causes parsing errors):
194
+ ---
195
+ title: L2 Governance Core (Split: Core) # Unquoted colon breaks YAML
196
+ tags: ["system", "overview", "reference"]
197
+ ---
180
198
 
181
199
  Args:
182
- path: Path to markdown file
183
- updates: Dict of frontmatter fields to update
200
+ post: frontmatter.Post object to serialize
184
201
 
185
202
  Returns:
186
- Checksum of updated file
203
+ String containing markdown with properly formatted YAML frontmatter
204
+ """
205
+ if not post.metadata:
206
+ # No frontmatter, just return content
207
+ return post.content
208
+
209
+ # Serialize YAML with block style for lists
210
+ # SafeDumper automatically quotes values with special characters (colons, etc.)
211
+ yaml_str = yaml.dump(
212
+ post.metadata,
213
+ sort_keys=False,
214
+ allow_unicode=True,
215
+ default_flow_style=False,
216
+ Dumper=yaml.SafeDumper,
217
+ )
218
+
219
+ # Construct the final markdown with frontmatter
220
+ if post.content:
221
+ return f"---\n{yaml_str}---\n\n{post.content}"
222
+ else:
223
+ return f"---\n{yaml_str}---\n"
224
+
225
+
226
+ def sanitize_for_filename(text: str, replacement: str = "-") -> str:
227
+ """
228
+ Sanitize string to be safe for use as a note title
229
+ Replaces path separators and other problematic characters
230
+ with hyphens.
231
+ """
232
+ # replace both POSIX and Windows path separators
233
+ text = re.sub(r"[/\\]", replacement, text)
187
234
 
188
- Raises:
189
- FileError: If file operations fail
190
- ParseError: If frontmatter parsing fails
235
+ # replace some other problematic chars
236
+ text = re.sub(r'[<>:"|?*]', replacement, text)
237
+
238
+ # compress multiple, repeated replacements
239
+ text = re.sub(f"{re.escape(replacement)}+", replacement, text)
240
+
241
+ return text.strip(replacement)
242
+
243
+
244
+ def sanitize_for_folder(folder: str) -> str:
191
245
  """
192
- try:
193
- # Read current content
194
- content = path.read_text()
246
+ Sanitize folder path to be safe for use in file system paths.
247
+ Removes leading/trailing whitespace, compresses multiple slashes,
248
+ and removes special characters except for /, -, and _.
249
+ """
250
+ if not folder:
251
+ return ""
195
252
 
196
- # Parse current frontmatter
197
- current_fm = {}
198
- if has_frontmatter(content):
199
- current_fm = parse_frontmatter(content)
200
- content = remove_frontmatter(content)
253
+ sanitized = folder.strip()
201
254
 
202
- # Update frontmatter
203
- new_fm = {**current_fm, **updates}
255
+ if sanitized.startswith("./"):
256
+ sanitized = sanitized[2:]
204
257
 
205
- # Write new file with updated frontmatter
206
- yaml_fm = yaml.dump(new_fm, sort_keys=False)
207
- final_content = f"---\n{yaml_fm}---\n\n{content.strip()}"
258
+ # ensure no special characters (except for a few that are allowed)
259
+ sanitized = "".join(
260
+ c for c in sanitized if c.isalnum() or c in (".", " ", "-", "_", "\\", "/")
261
+ ).rstrip()
208
262
 
209
- await write_file_atomic(path, final_content)
210
- return await compute_checksum(final_content)
263
+ # compress multiple, repeated instances of path separators
264
+ sanitized = re.sub(r"[\\/]+", "/", sanitized)
211
265
 
212
- except Exception as e: # pragma: no cover
213
- logger.error(f"Failed to update frontmatter in {path}: {e}")
214
- raise FileError(f"Failed to update frontmatter: {e}")
266
+ # trim any leading/trailing path separators
267
+ sanitized = sanitized.strip("\\/")
268
+
269
+ return sanitized
@@ -0,0 +1,297 @@
1
+ """Utilities for handling .gitignore patterns and file filtering."""
2
+
3
+ import fnmatch
4
+ from pathlib import Path
5
+ from typing import Set
6
+
7
+
8
+ # Common directories and patterns to ignore by default
9
+ # These are used as fallback if .bmignore doesn't exist
10
+ DEFAULT_IGNORE_PATTERNS = {
11
+ # Hidden files (files starting with dot)
12
+ ".*",
13
+ # Basic Memory internal files
14
+ "*.db",
15
+ "*.db-shm",
16
+ "*.db-wal",
17
+ "config.json",
18
+ # Version control
19
+ ".git",
20
+ ".svn",
21
+ # Python
22
+ "__pycache__",
23
+ "*.pyc",
24
+ "*.pyo",
25
+ "*.pyd",
26
+ ".pytest_cache",
27
+ ".coverage",
28
+ "*.egg-info",
29
+ ".tox",
30
+ ".mypy_cache",
31
+ ".ruff_cache",
32
+ # Virtual environments
33
+ ".venv",
34
+ "venv",
35
+ "env",
36
+ ".env",
37
+ # Node.js
38
+ "node_modules",
39
+ # Build artifacts
40
+ "build",
41
+ "dist",
42
+ ".cache",
43
+ # IDE
44
+ ".idea",
45
+ ".vscode",
46
+ # OS files
47
+ ".DS_Store",
48
+ "Thumbs.db",
49
+ "desktop.ini",
50
+ # Obsidian
51
+ ".obsidian",
52
+ # Temporary files
53
+ "*.tmp",
54
+ "*.swp",
55
+ "*.swo",
56
+ "*~",
57
+ }
58
+
59
+
60
+ def get_bmignore_path() -> Path:
61
+ """Get path to .bmignore file.
62
+
63
+ Returns:
64
+ Path to ~/.basic-memory/.bmignore
65
+ """
66
+ return Path.home() / ".basic-memory" / ".bmignore"
67
+
68
+
69
+ def create_default_bmignore() -> None:
70
+ """Create default .bmignore file if it doesn't exist.
71
+
72
+ This ensures users have a file they can customize for all Basic Memory operations.
73
+ """
74
+ bmignore_path = get_bmignore_path()
75
+
76
+ if bmignore_path.exists():
77
+ return
78
+
79
+ bmignore_path.parent.mkdir(parents=True, exist_ok=True)
80
+ bmignore_path.write_text("""# Basic Memory Ignore Patterns
81
+ # This file is used by both 'bm cloud upload', 'bm cloud bisync', and file sync
82
+ # Patterns use standard gitignore-style syntax
83
+
84
+ # Hidden files (files starting with dot)
85
+ .*
86
+
87
+ # Basic Memory internal files (includes test databases)
88
+ *.db
89
+ *.db-shm
90
+ *.db-wal
91
+ config.json
92
+
93
+ # Version control
94
+ .git
95
+ .svn
96
+
97
+ # Python
98
+ __pycache__
99
+ *.pyc
100
+ *.pyo
101
+ *.pyd
102
+ .pytest_cache
103
+ .coverage
104
+ *.egg-info
105
+ .tox
106
+ .mypy_cache
107
+ .ruff_cache
108
+
109
+ # Virtual environments
110
+ .venv
111
+ venv
112
+ env
113
+ .env
114
+
115
+ # Node.js
116
+ node_modules
117
+
118
+ # Build artifacts
119
+ build
120
+ dist
121
+ .cache
122
+
123
+ # IDE
124
+ .idea
125
+ .vscode
126
+
127
+ # OS files
128
+ .DS_Store
129
+ Thumbs.db
130
+ desktop.ini
131
+
132
+ # Obsidian
133
+ .obsidian
134
+
135
+ # Temporary files
136
+ *.tmp
137
+ *.swp
138
+ *.swo
139
+ *~
140
+ """)
141
+
142
+
143
+ def load_bmignore_patterns() -> Set[str]:
144
+ """Load patterns from .bmignore file.
145
+
146
+ Returns:
147
+ Set of patterns from .bmignore, or DEFAULT_IGNORE_PATTERNS if file doesn't exist
148
+ """
149
+ bmignore_path = get_bmignore_path()
150
+
151
+ # Create default file if it doesn't exist
152
+ if not bmignore_path.exists():
153
+ create_default_bmignore()
154
+
155
+ patterns = set()
156
+
157
+ try:
158
+ with bmignore_path.open("r", encoding="utf-8") as f:
159
+ for line in f:
160
+ line = line.strip()
161
+ # Skip empty lines and comments
162
+ if line and not line.startswith("#"):
163
+ patterns.add(line)
164
+ except Exception:
165
+ # If we can't read .bmignore, fall back to defaults
166
+ return set(DEFAULT_IGNORE_PATTERNS)
167
+
168
+ # If no patterns were loaded, use defaults
169
+ if not patterns:
170
+ return set(DEFAULT_IGNORE_PATTERNS)
171
+
172
+ return patterns
173
+
174
+
175
+ def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[str]:
176
+ """Load gitignore patterns from .gitignore file and .bmignore.
177
+
178
+ Combines patterns from:
179
+ 1. ~/.basic-memory/.bmignore (user's global ignore patterns)
180
+ 2. {base_path}/.gitignore (project-specific patterns, if use_gitignore=True)
181
+
182
+ Args:
183
+ base_path: The base directory to search for .gitignore file
184
+ use_gitignore: If False, only load patterns from .bmignore (default: True)
185
+
186
+ Returns:
187
+ Set of patterns to ignore
188
+ """
189
+ # Start with patterns from .bmignore
190
+ patterns = load_bmignore_patterns()
191
+
192
+ if use_gitignore:
193
+ gitignore_file = base_path / ".gitignore"
194
+ if gitignore_file.exists():
195
+ try:
196
+ with gitignore_file.open("r", encoding="utf-8") as f:
197
+ for line in f:
198
+ line = line.strip()
199
+ # Skip empty lines and comments
200
+ if line and not line.startswith("#"):
201
+ patterns.add(line)
202
+ except Exception:
203
+ # If we can't read .gitignore, just use default patterns
204
+ pass
205
+
206
+ return patterns
207
+
208
+
209
+ def should_ignore_path(file_path: Path, base_path: Path, ignore_patterns: Set[str]) -> bool:
210
+ """Check if a file path should be ignored based on gitignore patterns.
211
+
212
+ Args:
213
+ file_path: The file path to check
214
+ base_path: The base directory for relative path calculation
215
+ ignore_patterns: Set of patterns to match against
216
+
217
+ Returns:
218
+ True if the path should be ignored, False otherwise
219
+ """
220
+ # Get the relative path from base
221
+ try:
222
+ relative_path = file_path.relative_to(base_path)
223
+ relative_str = str(relative_path)
224
+ relative_posix = relative_path.as_posix() # Use forward slashes for matching
225
+
226
+ # Check each pattern
227
+ for pattern in ignore_patterns:
228
+ # Handle patterns starting with / (root relative)
229
+ if pattern.startswith("/"):
230
+ root_pattern = pattern[1:] # Remove leading /
231
+
232
+ # For directory patterns ending with /
233
+ if root_pattern.endswith("/"):
234
+ dir_name = root_pattern[:-1] # Remove trailing /
235
+ # Check if the first part of the path matches the directory name
236
+ if len(relative_path.parts) > 0 and relative_path.parts[0] == dir_name:
237
+ return True
238
+ else:
239
+ # Regular root-relative pattern
240
+ if fnmatch.fnmatch(relative_posix, root_pattern):
241
+ return True
242
+ continue
243
+
244
+ # Handle directory patterns (ending with /)
245
+ if pattern.endswith("/"):
246
+ dir_name = pattern[:-1] # Remove trailing /
247
+ # Check if any path part matches the directory name
248
+ if dir_name in relative_path.parts:
249
+ return True
250
+ continue
251
+
252
+ # Direct name match (e.g., ".git", "node_modules")
253
+ if pattern in relative_path.parts:
254
+ return True
255
+
256
+ # Check if any individual path part matches the glob pattern
257
+ # This handles cases like ".*" matching ".hidden.md" in "concept/.hidden.md"
258
+ for part in relative_path.parts:
259
+ if fnmatch.fnmatch(part, pattern):
260
+ return True
261
+
262
+ # Glob pattern match on full path
263
+ if fnmatch.fnmatch(relative_posix, pattern) or fnmatch.fnmatch(relative_str, pattern):
264
+ return True
265
+
266
+ return False
267
+ except ValueError:
268
+ # If we can't get relative path, don't ignore
269
+ return False
270
+
271
+
272
+ def filter_files(
273
+ files: list[Path], base_path: Path, ignore_patterns: Set[str] | None = None
274
+ ) -> tuple[list[Path], int]:
275
+ """Filter a list of files based on gitignore patterns.
276
+
277
+ Args:
278
+ files: List of file paths to filter
279
+ base_path: The base directory for relative path calculation
280
+ ignore_patterns: Set of patterns to ignore. If None, loads from .gitignore
281
+
282
+ Returns:
283
+ Tuple of (filtered_files, ignored_count)
284
+ """
285
+ if ignore_patterns is None:
286
+ ignore_patterns = load_gitignore_patterns(base_path)
287
+
288
+ filtered_files = []
289
+ ignored_count = 0
290
+
291
+ for file_path in files:
292
+ if should_ignore_path(file_path, base_path, ignore_patterns):
293
+ ignored_count += 1
294
+ else:
295
+ filtered_files.append(file_path)
296
+
297
+ return filtered_files, ignored_count
@@ -0,0 +1,27 @@
1
+ """Import services for Basic Memory."""
2
+
3
+ from basic_memory.importers.base import Importer
4
+ from basic_memory.importers.chatgpt_importer import ChatGPTImporter
5
+ from basic_memory.importers.claude_conversations_importer import (
6
+ ClaudeConversationsImporter,
7
+ )
8
+ from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
9
+ from basic_memory.importers.memory_json_importer import MemoryJsonImporter
10
+ from basic_memory.schemas.importer import (
11
+ ChatImportResult,
12
+ EntityImportResult,
13
+ ImportResult,
14
+ ProjectImportResult,
15
+ )
16
+
17
+ __all__ = [
18
+ "Importer",
19
+ "ChatGPTImporter",
20
+ "ClaudeConversationsImporter",
21
+ "ClaudeProjectsImporter",
22
+ "MemoryJsonImporter",
23
+ "ImportResult",
24
+ "ChatImportResult",
25
+ "EntityImportResult",
26
+ "ProjectImportResult",
27
+ ]
@@ -0,0 +1,79 @@
1
+ """Base import service for Basic Memory."""
2
+
3
+ import logging
4
+ from abc import abstractmethod
5
+ from pathlib import Path
6
+ from typing import Any, Optional, TypeVar
7
+
8
+ from basic_memory.markdown.markdown_processor import MarkdownProcessor
9
+ from basic_memory.markdown.schemas import EntityMarkdown
10
+ from basic_memory.schemas.importer import ImportResult
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ T = TypeVar("T", bound=ImportResult)
15
+
16
+
17
+ class Importer[T: ImportResult]:
18
+ """Base class for all import services."""
19
+
20
+ def __init__(self, base_path: Path, markdown_processor: MarkdownProcessor):
21
+ """Initialize the import service.
22
+
23
+ Args:
24
+ markdown_processor: MarkdownProcessor instance for writing markdown files.
25
+ """
26
+ self.base_path = base_path.resolve() # Get absolute path
27
+ self.markdown_processor = markdown_processor
28
+
29
+ @abstractmethod
30
+ async def import_data(self, source_data, destination_folder: str, **kwargs: Any) -> T:
31
+ """Import data from source file to destination folder.
32
+
33
+ Args:
34
+ source_path: Path to the source file.
35
+ destination_folder: Destination folder within the project.
36
+ **kwargs: Additional keyword arguments for specific import types.
37
+
38
+ Returns:
39
+ ImportResult containing statistics and status of the import.
40
+ """
41
+ pass # pragma: no cover
42
+
43
+ async def write_entity(self, entity: EntityMarkdown, file_path: Path) -> None:
44
+ """Write entity to file using markdown processor.
45
+
46
+ Args:
47
+ entity: EntityMarkdown instance to write.
48
+ file_path: Path to write the entity to.
49
+ """
50
+ await self.markdown_processor.write_file(file_path, entity)
51
+
52
+ def ensure_folder_exists(self, folder: str) -> Path:
53
+ """Ensure folder exists, create if it doesn't.
54
+
55
+ Args:
56
+ base_path: Base path of the project.
57
+ folder: Folder name or path within the project.
58
+
59
+ Returns:
60
+ Path to the folder.
61
+ """
62
+ folder_path = self.base_path / folder
63
+ folder_path.mkdir(parents=True, exist_ok=True)
64
+ return folder_path
65
+
66
+ @abstractmethod
67
+ def handle_error(
68
+ self, message: str, error: Optional[Exception] = None
69
+ ) -> T: # pragma: no cover
70
+ """Handle errors during import.
71
+
72
+ Args:
73
+ message: Error message.
74
+ error: Optional exception that caused the error.
75
+
76
+ Returns:
77
+ ImportResult with error information.
78
+ """
79
+ pass