basic-memory 0.15.0__py3-none-any.whl → 0.15.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 (47) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/api/routers/directory_router.py +23 -2
  3. basic_memory/api/routers/project_router.py +1 -0
  4. basic_memory/cli/auth.py +2 -2
  5. basic_memory/cli/commands/command_utils.py +11 -28
  6. basic_memory/cli/commands/mcp.py +72 -67
  7. basic_memory/cli/commands/project.py +54 -49
  8. basic_memory/cli/commands/status.py +6 -15
  9. basic_memory/config.py +55 -9
  10. basic_memory/deps.py +7 -5
  11. basic_memory/ignore_utils.py +7 -7
  12. basic_memory/mcp/async_client.py +102 -4
  13. basic_memory/mcp/prompts/continue_conversation.py +16 -15
  14. basic_memory/mcp/prompts/search.py +12 -11
  15. basic_memory/mcp/resources/ai_assistant_guide.md +185 -453
  16. basic_memory/mcp/resources/project_info.py +9 -7
  17. basic_memory/mcp/tools/build_context.py +40 -39
  18. basic_memory/mcp/tools/canvas.py +21 -20
  19. basic_memory/mcp/tools/chatgpt_tools.py +11 -2
  20. basic_memory/mcp/tools/delete_note.py +22 -21
  21. basic_memory/mcp/tools/edit_note.py +105 -104
  22. basic_memory/mcp/tools/list_directory.py +98 -95
  23. basic_memory/mcp/tools/move_note.py +127 -125
  24. basic_memory/mcp/tools/project_management.py +101 -98
  25. basic_memory/mcp/tools/read_content.py +64 -63
  26. basic_memory/mcp/tools/read_note.py +88 -88
  27. basic_memory/mcp/tools/recent_activity.py +139 -135
  28. basic_memory/mcp/tools/search.py +27 -26
  29. basic_memory/mcp/tools/sync_status.py +133 -128
  30. basic_memory/mcp/tools/utils.py +0 -15
  31. basic_memory/mcp/tools/view_note.py +14 -28
  32. basic_memory/mcp/tools/write_note.py +97 -87
  33. basic_memory/repository/entity_repository.py +60 -0
  34. basic_memory/repository/repository.py +16 -3
  35. basic_memory/repository/search_repository.py +42 -0
  36. basic_memory/schemas/project_info.py +1 -1
  37. basic_memory/services/directory_service.py +124 -3
  38. basic_memory/services/entity_service.py +31 -9
  39. basic_memory/services/project_service.py +97 -10
  40. basic_memory/services/search_service.py +16 -8
  41. basic_memory/sync/sync_service.py +28 -13
  42. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/METADATA +51 -4
  43. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/RECORD +46 -47
  44. basic_memory/mcp/tools/headers.py +0 -44
  45. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  46. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  47. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -6,7 +6,7 @@ from loguru import logger
6
6
  from fastmcp import Context
7
7
 
8
8
  from basic_memory.config import ConfigManager
9
- from basic_memory.mcp.async_client import client
9
+ from basic_memory.mcp.async_client import get_client
10
10
  from basic_memory.mcp.server import mcp
11
11
  from basic_memory.mcp.project_context import get_active_project
12
12
  from basic_memory.services.sync_status_service import sync_status_tracker
@@ -95,162 +95,167 @@ async def sync_status(project: Optional[str] = None, context: Context | None = N
95
95
  """
96
96
  logger.info("MCP tool call tool=sync_status")
97
97
 
98
- status_lines = []
98
+ async with get_client() as client:
99
+ status_lines = []
99
100
 
100
- try:
101
- from basic_memory.services.sync_status_service import sync_status_tracker
102
-
103
- # Get overall summary
104
- summary = sync_status_tracker.get_summary()
105
- is_ready = sync_status_tracker.is_ready
106
-
107
- # Header
108
- status_lines.extend(
109
- [
110
- "# Basic Memory Sync Status",
111
- "",
112
- f"**Current Status**: {summary}",
113
- f"**System Ready**: {'✅ Yes' if is_ready else '🔄 Processing'}",
114
- "",
115
- ]
116
- )
117
-
118
- if is_ready:
101
+ try:
102
+ from basic_memory.services.sync_status_service import sync_status_tracker
103
+
104
+ # Get overall summary
105
+ summary = sync_status_tracker.get_summary()
106
+ is_ready = sync_status_tracker.is_ready
107
+
108
+ # Header
119
109
  status_lines.extend(
120
110
  [
121
- " **All sync operations completed**",
111
+ "# Basic Memory Sync Status",
122
112
  "",
123
- "- File indexing is complete",
124
- "- Knowledge graphs are up to date",
125
- "- All Basic Memory tools are fully operational",
113
+ f"**Current Status**: {summary}",
114
+ f"**System Ready**: {'✅ Yes' if is_ready else '🔄 Processing'}",
126
115
  "",
127
- "Your knowledge base is ready for use!",
128
116
  ]
129
117
  )
130
118
 
131
- # Show all projects status even when ready
132
- status_lines.extend(_get_all_projects_status())
133
- else:
134
- # System is still processing - show both active and all projects
135
- all_sync_projects = sync_status_tracker.get_all_projects()
136
-
137
- active_projects = [
138
- p for p in all_sync_projects.values() if p.status.value in ["scanning", "syncing"]
139
- ]
140
- failed_projects = [p for p in all_sync_projects.values() if p.status.value == "failed"]
141
-
142
- if active_projects:
119
+ if is_ready:
143
120
  status_lines.extend(
144
121
  [
145
- "🔄 **File synchronization in progress**",
122
+ " **All sync operations completed**",
146
123
  "",
147
- "Basic Memory is automatically processing all configured projects and building knowledge graphs.",
148
- "This typically takes 1-3 minutes depending on the amount of content.",
124
+ "- File indexing is complete",
125
+ "- Knowledge graphs are up to date",
126
+ "- All Basic Memory tools are fully operational",
149
127
  "",
150
- "**Currently Processing:**",
128
+ "Your knowledge base is ready for use!",
151
129
  ]
152
130
  )
153
131
 
154
- for project_status in active_projects:
155
- progress = ""
156
- if project_status.files_total > 0:
157
- progress_pct = (
158
- project_status.files_processed / project_status.files_total
159
- ) * 100
160
- progress = f" ({project_status.files_processed}/{project_status.files_total}, {progress_pct:.0f}%)"
132
+ # Show all projects status even when ready
133
+ status_lines.extend(_get_all_projects_status())
134
+ else:
135
+ # System is still processing - show both active and all projects
136
+ all_sync_projects = sync_status_tracker.get_all_projects()
161
137
 
162
- status_lines.append(
163
- f"- **{project_status.project_name}**: {project_status.message}{progress}"
164
- )
165
-
166
- status_lines.extend(
167
- [
168
- "",
169
- "**What's happening:**",
170
- "- Scanning and indexing markdown files",
171
- "- Building entity and relationship graphs",
172
- "- Setting up full-text search indexes",
173
- "- Processing file changes and updates",
174
- "",
175
- "**What you can do:**",
176
- "- Wait for automatic processing to complete - no action needed",
177
- "- Use this tool again to check progress",
178
- "- Simple operations may work already",
179
- "- All projects will be available once sync finishes",
180
- ]
181
- )
182
-
183
- # Handle failed projects (independent of active projects)
184
- if failed_projects:
185
- status_lines.extend(["", "❌ **Some projects failed to sync:**", ""])
138
+ active_projects = [
139
+ p
140
+ for p in all_sync_projects.values()
141
+ if p.status.value in ["scanning", "syncing"]
142
+ ]
143
+ failed_projects = [
144
+ p for p in all_sync_projects.values() if p.status.value == "failed"
145
+ ]
186
146
 
187
- for project_status in failed_projects:
188
- status_lines.append(
189
- f"- **{project_status.project_name}**: {project_status.error or 'Unknown error'}"
147
+ if active_projects:
148
+ status_lines.extend(
149
+ [
150
+ "🔄 **File synchronization in progress**",
151
+ "",
152
+ "Basic Memory is automatically processing all configured projects and building knowledge graphs.",
153
+ "This typically takes 1-3 minutes depending on the amount of content.",
154
+ "",
155
+ "**Currently Processing:**",
156
+ ]
190
157
  )
191
158
 
192
- status_lines.extend(
193
- [
194
- "",
195
- "**Next steps:**",
196
- "1. Check the logs for detailed error information",
197
- "2. Ensure file permissions allow read/write access",
198
- "3. Try restarting the MCP server",
199
- "4. If issues persist, consider filing a support issue",
200
- ]
201
- )
202
- elif not active_projects:
203
- # No active or failed projects - must be pending
204
- status_lines.extend(
205
- [
206
- "⏳ **Sync operations pending**",
207
- "",
208
- "File synchronization has been queued but hasn't started yet.",
209
- "This usually resolves automatically within a few seconds.",
210
- ]
211
- )
159
+ for project_status in active_projects:
160
+ progress = ""
161
+ if project_status.files_total > 0:
162
+ progress_pct = (
163
+ project_status.files_processed / project_status.files_total
164
+ ) * 100
165
+ progress = f" ({project_status.files_processed}/{project_status.files_total}, {progress_pct:.0f}%)"
166
+
167
+ status_lines.append(
168
+ f"- **{project_status.project_name}**: {project_status.message}{progress}"
169
+ )
170
+
171
+ status_lines.extend(
172
+ [
173
+ "",
174
+ "**What's happening:**",
175
+ "- Scanning and indexing markdown files",
176
+ "- Building entity and relationship graphs",
177
+ "- Settings up full-text search indexes",
178
+ "- Processing file changes and updates",
179
+ "",
180
+ "**What you can do:**",
181
+ "- Wait for automatic processing to complete - no action needed",
182
+ "- Use this tool again to check progress",
183
+ "- Simple operations may work already",
184
+ "- All projects will be available once sync finishes",
185
+ ]
186
+ )
212
187
 
213
- # Add comprehensive project status for all configured projects
214
- all_projects_status = _get_all_projects_status()
215
- if all_projects_status:
216
- status_lines.extend(all_projects_status)
188
+ # Handle failed projects (independent of active projects)
189
+ if failed_projects:
190
+ status_lines.extend(["", "❌ **Some projects failed to sync:**", ""])
191
+
192
+ for project_status in failed_projects:
193
+ status_lines.append(
194
+ f"- **{project_status.project_name}**: {project_status.error or 'Unknown error'}"
195
+ )
196
+
197
+ status_lines.extend(
198
+ [
199
+ "",
200
+ "**Next steps:**",
201
+ "1. Check the logs for detailed error information",
202
+ "2. Ensure file permissions allow read/write access",
203
+ "3. Try restarting the MCP server",
204
+ "4. If issues persist, consider filing a support issue",
205
+ ]
206
+ )
207
+ elif not active_projects:
208
+ # No active or failed projects - must be pending
209
+ status_lines.extend(
210
+ [
211
+ "⏳ **Sync operations pending**",
212
+ "",
213
+ "File synchronization has been queued but hasn't started yet.",
214
+ "This usually resolves automatically within a few seconds.",
215
+ ]
216
+ )
217
217
 
218
- # Add explanation about automatic syncing if there are unsynced projects
219
- unsynced_count = sum(1 for line in all_projects_status if "⏳" in line)
220
- if unsynced_count > 0 and not is_ready:
221
- status_lines.extend(
222
- [
223
- "",
224
- "**Note**: All configured projects will be automatically synced during startup.",
225
- ]
226
- )
218
+ # Add comprehensive project status for all configured projects
219
+ all_projects_status = _get_all_projects_status()
220
+ if all_projects_status:
221
+ status_lines.extend(all_projects_status)
222
+
223
+ # Add explanation about automatic syncing if there are unsynced projects
224
+ unsynced_count = sum(1 for line in all_projects_status if "⏳" in line)
225
+ if unsynced_count > 0 and not is_ready:
226
+ status_lines.extend(
227
+ [
228
+ "",
229
+ "**Note**: All configured projects will be automatically synced during startup.",
230
+ ]
231
+ )
227
232
 
228
- # Add project context if provided
229
- if project:
230
- try:
231
- active_project = await get_active_project(client, project, context)
232
- status_lines.extend(
233
- [
234
- "",
235
- "---",
236
- "",
237
- f"**Active Project**: {active_project.name}",
238
- f"**Project Path**: {active_project.home}",
239
- ]
240
- )
241
- except Exception as e:
242
- logger.debug(f"Could not get project info: {e}")
233
+ # Add project context if provided
234
+ if project:
235
+ try:
236
+ active_project = await get_active_project(client, project, context)
237
+ status_lines.extend(
238
+ [
239
+ "",
240
+ "---",
241
+ "",
242
+ f"**Active Project**: {active_project.name}",
243
+ f"**Project Path**: {active_project.home}",
244
+ ]
245
+ )
246
+ except Exception as e:
247
+ logger.debug(f"Could not get project info: {e}")
243
248
 
244
- return "\n".join(status_lines)
249
+ return "\n".join(status_lines)
245
250
 
246
- except Exception as e:
247
- return f"""# Sync Status - Error
251
+ except Exception as e:
252
+ return f"""# Sync Status - Error
248
253
 
249
254
  ❌ **Unable to check sync status**: {str(e)}
250
255
 
251
256
  **Troubleshooting:**
252
257
  - The system may still be starting up
253
- - Try waiting a few seconds and checking again
258
+ - Try waiting a few seconds and checking again
254
259
  - Check logs for detailed error information
255
260
  - Consider restarting if the issue persists
256
261
  """
@@ -23,8 +23,6 @@ from httpx._types import (
23
23
  from loguru import logger
24
24
  from mcp.server.fastmcp.exceptions import ToolError
25
25
 
26
- from basic_memory.mcp.tools.headers import inject_auth_header
27
-
28
26
 
29
27
  def get_error_message(
30
28
  status_code: int, url: URL | str, method: str, msg: Optional[str] = None
@@ -110,7 +108,6 @@ async def call_get(
110
108
  logger.debug(f"Calling GET '{url}' params: '{params}'")
111
109
  error_message = None
112
110
 
113
- headers = inject_auth_header(headers)
114
111
  try:
115
112
  response = await client.get(
116
113
  url,
@@ -196,9 +193,6 @@ async def call_put(
196
193
  logger.debug(f"Calling PUT '{url}'")
197
194
  error_message = None
198
195
 
199
- # Inject JWT from FastMCP context if available
200
- headers = inject_auth_header(headers)
201
-
202
196
  try:
203
197
  response = await client.put(
204
198
  url,
@@ -288,9 +282,6 @@ async def call_patch(
288
282
  """
289
283
  logger.debug(f"Calling PATCH '{url}'")
290
284
 
291
- # Inject JWT from FastMCP context if available
292
- headers = inject_auth_header(headers)
293
-
294
285
  try:
295
286
  response = await client.patch(
296
287
  url,
@@ -396,9 +387,6 @@ async def call_post(
396
387
  logger.debug(f"Calling POST '{url}'")
397
388
  error_message = None
398
389
 
399
- # Inject JWT from FastMCP context if available
400
- headers = inject_auth_header(headers)
401
-
402
390
  try:
403
391
  response = await client.post(
404
392
  url=url,
@@ -481,9 +469,6 @@ async def call_delete(
481
469
  logger.debug(f"Calling DELETE '{url}'")
482
470
  error_message = None
483
471
 
484
- # Inject JWT from FastMCP context if available
485
- headers = inject_auth_header(headers)
486
-
487
472
  try:
488
473
  response = await client.delete(
489
474
  url=url,
@@ -22,14 +22,10 @@ async def view_note(
22
22
  ) -> str:
23
23
  """View a markdown note as a formatted artifact.
24
24
 
25
- This tool reads a note using the same logic as read_note but displays the content
26
- as a markdown artifact for better viewing experience in Claude Desktop.
25
+ This tool reads a note using the same logic as read_note but instructs Claude
26
+ to display the content as a markdown artifact in the Claude Desktop app.
27
27
  Project parameter optional with server resolution.
28
28
 
29
- After calling this tool, create an artifact using the returned content to display
30
- the note in a readable format. The tool returns the note content that should be
31
- used to create a markdown artifact.
32
-
33
29
  Args:
34
30
  identifier: The title or permalink of the note to view
35
31
  project: Project name to read from. Optional - server will resolve using hierarchy.
@@ -39,7 +35,7 @@ async def view_note(
39
35
  context: Optional FastMCP context for performance caching.
40
36
 
41
37
  Returns:
42
- The note content as a markdown artifact with a confirmation message.
38
+ Instructions for Claude to create a markdown artifact with the note content.
43
39
 
44
40
  Examples:
45
41
  # View a note by title
@@ -66,26 +62,16 @@ async def view_note(
66
62
 
67
63
  # Check if this is an error message (note not found)
68
64
  if "# Note Not Found" in content:
69
- return content # Return error message directly instead of creating artifact
70
-
71
- # Extract title from content if possible
72
- title = identifier
73
- lines = content.split("\n")
74
- for line in lines:
75
- if line.startswith("title:"):
76
- # Extract title from frontmatter
77
- title = line.split("title:", 1)[1].strip().strip('"').strip("'")
78
- break
79
- elif line.startswith("# "):
80
- # Extract title from first heading
81
- title = line[2:].strip()
82
- break
83
-
84
- # Return the artifact XML as part of the response
85
- artifact = f'<artifact identifier="note-{hash(identifier) & 0x7FFFFFFF}" type="text/markdown" title="{title}">\n{content}\n</artifact>'
65
+ return content # Return error message directly
86
66
 
67
+ # Return instructions for Claude to create an artifact
87
68
  return dedent(f"""
88
- <instructions>
89
- Create an artifact using the returned content to display the note in a readable format.
90
- </instructions>
91
- {artifact}\n\n✅ Note displayed as artifact: **{title}**""")
69
+ Note retrieved: "{identifier}"
70
+
71
+ Display this note as a markdown artifact for the user.
72
+
73
+ Content:
74
+ ---
75
+ {content}
76
+ ---
77
+ """).strip()
@@ -4,7 +4,7 @@ from typing import List, Union, Optional
4
4
 
5
5
  from loguru import logger
6
6
 
7
- from basic_memory.mcp.async_client import client
7
+ from basic_memory.mcp.async_client import get_client
8
8
  from basic_memory.mcp.project_context import get_active_project, add_project_metadata
9
9
  from basic_memory.mcp.server import mcp
10
10
  from basic_memory.mcp.tools.utils import call_put
@@ -63,7 +63,8 @@ async def write_note(
63
63
  title: The title of the note
64
64
  content: Markdown content for the note, can include observations and relations
65
65
  folder: Folder path relative to project root where the file should be saved.
66
- Use forward slashes (/) as separators. Examples: "notes", "projects/2025", "research/ml"
66
+ Use forward slashes (/) as separators. Use "/" or "" to write to project root.
67
+ Examples: "notes", "projects/2025", "research/ml", "/" (root)
67
68
  project: Project name to write to. Optional - server will resolve using the
68
69
  hierarchy above. If unknown, use list_memory_projects() to discover
69
70
  available projects.
@@ -117,92 +118,101 @@ async def write_note(
117
118
  HTTPError: If project doesn't exist or is inaccessible
118
119
  SecurityError: If folder path attempts path traversal
119
120
  """
120
- logger.info(
121
- f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
122
- )
123
-
124
- # Get and validate the project (supports optional project parameter)
125
- active_project = await get_active_project(client, project, context)
126
-
127
- # Validate folder path to prevent path traversal attacks
128
- project_path = active_project.home
129
- if folder and not validate_project_path(folder, project_path):
130
- logger.warning(
131
- "Attempted path traversal attack blocked", folder=folder, project=active_project.name
121
+ async with get_client() as client:
122
+ logger.info(
123
+ f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
132
124
  )
133
- return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
134
-
135
- # Check migration status and wait briefly if needed
136
- from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
137
-
138
- migration_status = await wait_for_migration_or_return_status(
139
- timeout=5.0, project_name=active_project.name
140
- )
141
- if migration_status: # pragma: no cover
142
- return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
143
-
144
- # Process tags using the helper function
145
- tag_list = parse_tags(tags)
146
- # Create the entity request
147
- metadata = {"tags": tag_list} if tag_list else None
148
- entity = Entity(
149
- title=title,
150
- folder=folder,
151
- entity_type=entity_type,
152
- content_type="text/markdown",
153
- content=content,
154
- entity_metadata=metadata,
155
- )
156
- project_url = active_project.permalink
157
-
158
- # Create or update via knowledge API
159
- logger.debug(f"Creating entity via API permalink={entity.permalink}")
160
- url = f"{project_url}/knowledge/entities/{entity.permalink}"
161
- response = await call_put(client, url, json=entity.model_dump())
162
- result = EntityResponse.model_validate(response.json())
163
-
164
- # Format semantic summary based on status code
165
- action = "Created" if response.status_code == 201 else "Updated"
166
- summary = [
167
- f"# {action} note",
168
- f"project: {active_project.name}",
169
- f"file_path: {result.file_path}",
170
- f"permalink: {result.permalink}",
171
- f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
172
- ]
173
-
174
- # Count observations by category
175
- categories = {}
176
- if result.observations:
177
- for obs in result.observations:
178
- categories[obs.category] = categories.get(obs.category, 0) + 1
179
-
180
- summary.append("\n## Observations")
181
- for category, count in sorted(categories.items()):
182
- summary.append(f"- {category}: {count}")
183
-
184
- # Count resolved/unresolved relations
185
- unresolved = 0
186
- resolved = 0
187
- if result.relations:
188
- unresolved = sum(1 for r in result.relations if not r.to_id)
189
- resolved = len(result.relations) - unresolved
190
-
191
- summary.append("\n## Relations")
192
- summary.append(f"- Resolved: {resolved}")
193
- if unresolved:
194
- summary.append(f"- Unresolved: {unresolved}")
195
- summary.append("\nNote: Unresolved relations point to entities that don't exist yet.")
196
- summary.append(
197
- "They will be automatically resolved when target entities are created or during sync operations."
125
+
126
+ # Get and validate the project (supports optional project parameter)
127
+ active_project = await get_active_project(client, project, context)
128
+
129
+ # Normalize "/" to empty string for root folder (must happen before validation)
130
+ if folder == "/":
131
+ folder = ""
132
+
133
+ # Validate folder path to prevent path traversal attacks
134
+ project_path = active_project.home
135
+ if folder and not validate_project_path(folder, project_path):
136
+ logger.warning(
137
+ "Attempted path traversal attack blocked",
138
+ folder=folder,
139
+ project=active_project.name,
198
140
  )
141
+ return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
199
142
 
200
- if tag_list:
201
- summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
143
+ # Check migration status and wait briefly if needed
144
+ from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
202
145
 
203
- # Log the response with structured data
204
- logger.info(
205
- f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved} status_code={response.status_code}"
206
- )
207
- result = "\n".join(summary)
208
- return add_project_metadata(result, active_project.name)
146
+ migration_status = await wait_for_migration_or_return_status(
147
+ timeout=5.0, project_name=active_project.name
148
+ )
149
+ if migration_status: # pragma: no cover
150
+ return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
151
+
152
+ # Process tags using the helper function
153
+ tag_list = parse_tags(tags)
154
+ # Create the entity request
155
+ metadata = {"tags": tag_list} if tag_list else None
156
+ entity = Entity(
157
+ title=title,
158
+ folder=folder,
159
+ entity_type=entity_type,
160
+ content_type="text/markdown",
161
+ content=content,
162
+ entity_metadata=metadata,
163
+ )
164
+ project_url = active_project.permalink
165
+
166
+ # Create or update via knowledge API
167
+ logger.debug(f"Creating entity via API permalink={entity.permalink}")
168
+ url = f"{project_url}/knowledge/entities/{entity.permalink}"
169
+ response = await call_put(client, url, json=entity.model_dump())
170
+ result = EntityResponse.model_validate(response.json())
171
+
172
+ # Format semantic summary based on status code
173
+ action = "Created" if response.status_code == 201 else "Updated"
174
+ summary = [
175
+ f"# {action} note",
176
+ f"project: {active_project.name}",
177
+ f"file_path: {result.file_path}",
178
+ f"permalink: {result.permalink}",
179
+ f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
180
+ ]
181
+
182
+ # Count observations by category
183
+ categories = {}
184
+ if result.observations:
185
+ for obs in result.observations:
186
+ categories[obs.category] = categories.get(obs.category, 0) + 1
187
+
188
+ summary.append("\n## Observations")
189
+ for category, count in sorted(categories.items()):
190
+ summary.append(f"- {category}: {count}")
191
+
192
+ # Count resolved/unresolved relations
193
+ unresolved = 0
194
+ resolved = 0
195
+ if result.relations:
196
+ unresolved = sum(1 for r in result.relations if not r.to_id)
197
+ resolved = len(result.relations) - unresolved
198
+
199
+ summary.append("\n## Relations")
200
+ summary.append(f"- Resolved: {resolved}")
201
+ if unresolved:
202
+ summary.append(f"- Unresolved: {unresolved}")
203
+ summary.append(
204
+ "\nNote: Unresolved relations point to entities that don't exist yet."
205
+ )
206
+ summary.append(
207
+ "They will be automatically resolved when target entities are created or during sync operations."
208
+ )
209
+
210
+ if tag_list:
211
+ summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
212
+
213
+ # Log the response with structured data
214
+ logger.info(
215
+ f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved} status_code={response.status_code}"
216
+ )
217
+ result = "\n".join(summary)
218
+ return add_project_metadata(result, active_project.name)