basic-memory 0.14.3__py3-none-any.whl → 0.15.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 (90) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/knowledge_router.py +25 -8
  5. basic_memory/api/routers/project_router.py +99 -4
  6. basic_memory/api/routers/resource_router.py +3 -3
  7. basic_memory/cli/app.py +9 -28
  8. basic_memory/cli/auth.py +277 -0
  9. basic_memory/cli/commands/cloud/__init__.py +5 -0
  10. basic_memory/cli/commands/cloud/api_client.py +112 -0
  11. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  12. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  13. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  14. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  15. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  16. basic_memory/cli/commands/command_utils.py +60 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +16 -4
  19. basic_memory/cli/commands/project.py +141 -145
  20. basic_memory/cli/commands/status.py +34 -22
  21. basic_memory/cli/commands/sync.py +45 -228
  22. basic_memory/cli/commands/tool.py +87 -16
  23. basic_memory/cli/main.py +1 -0
  24. basic_memory/config.py +96 -20
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +20 -3
  27. basic_memory/file_utils.py +89 -0
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/importers/chatgpt_importer.py +1 -1
  30. basic_memory/importers/utils.py +2 -2
  31. basic_memory/markdown/entity_parser.py +2 -2
  32. basic_memory/markdown/markdown_processor.py +2 -2
  33. basic_memory/markdown/plugins.py +39 -21
  34. basic_memory/markdown/utils.py +1 -1
  35. basic_memory/mcp/async_client.py +22 -10
  36. basic_memory/mcp/project_context.py +141 -0
  37. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  38. basic_memory/mcp/prompts/continue_conversation.py +1 -1
  39. basic_memory/mcp/prompts/recent_activity.py +116 -32
  40. basic_memory/mcp/prompts/search.py +1 -1
  41. basic_memory/mcp/prompts/utils.py +11 -4
  42. basic_memory/mcp/resources/ai_assistant_guide.md +179 -41
  43. basic_memory/mcp/resources/project_info.py +20 -6
  44. basic_memory/mcp/server.py +0 -37
  45. basic_memory/mcp/tools/__init__.py +5 -6
  46. basic_memory/mcp/tools/build_context.py +39 -19
  47. basic_memory/mcp/tools/canvas.py +19 -8
  48. basic_memory/mcp/tools/chatgpt_tools.py +178 -0
  49. basic_memory/mcp/tools/delete_note.py +67 -34
  50. basic_memory/mcp/tools/edit_note.py +55 -39
  51. basic_memory/mcp/tools/headers.py +44 -0
  52. basic_memory/mcp/tools/list_directory.py +18 -8
  53. basic_memory/mcp/tools/move_note.py +119 -41
  54. basic_memory/mcp/tools/project_management.py +77 -229
  55. basic_memory/mcp/tools/read_content.py +28 -12
  56. basic_memory/mcp/tools/read_note.py +97 -57
  57. basic_memory/mcp/tools/recent_activity.py +441 -42
  58. basic_memory/mcp/tools/search.py +82 -70
  59. basic_memory/mcp/tools/sync_status.py +5 -4
  60. basic_memory/mcp/tools/utils.py +19 -0
  61. basic_memory/mcp/tools/view_note.py +31 -6
  62. basic_memory/mcp/tools/write_note.py +65 -14
  63. basic_memory/models/knowledge.py +19 -2
  64. basic_memory/models/project.py +6 -2
  65. basic_memory/repository/entity_repository.py +31 -84
  66. basic_memory/repository/project_repository.py +1 -1
  67. basic_memory/repository/relation_repository.py +13 -0
  68. basic_memory/repository/repository.py +2 -2
  69. basic_memory/repository/search_repository.py +9 -3
  70. basic_memory/schemas/__init__.py +6 -0
  71. basic_memory/schemas/base.py +70 -12
  72. basic_memory/schemas/cloud.py +46 -0
  73. basic_memory/schemas/memory.py +99 -18
  74. basic_memory/schemas/project_info.py +9 -10
  75. basic_memory/schemas/sync_report.py +48 -0
  76. basic_memory/services/context_service.py +35 -11
  77. basic_memory/services/directory_service.py +7 -0
  78. basic_memory/services/entity_service.py +82 -52
  79. basic_memory/services/initialization.py +30 -11
  80. basic_memory/services/project_service.py +23 -33
  81. basic_memory/sync/sync_service.py +148 -24
  82. basic_memory/sync/watch_service.py +128 -44
  83. basic_memory/utils.py +181 -109
  84. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/METADATA +26 -96
  85. basic_memory-0.15.0.dist-info/RECORD +147 -0
  86. basic_memory/mcp/project_session.py +0 -120
  87. basic_memory-0.14.3.dist-info/RECORD +0 -132
  88. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/WHEEL +0 -0
  89. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/entry_points.txt +0 -0
  90. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -8,34 +8,50 @@ from markdown_it.token import Token
8
8
  # Observation handling functions
9
9
  def is_observation(token: Token) -> bool:
10
10
  """Check if token looks like our observation format."""
11
+ import re
12
+
11
13
  if token.type != "inline": # pragma: no cover
12
14
  return False
13
-
14
- content = token.content.strip()
15
+ # Use token.tag which contains the actual content for test tokens, fallback to content
16
+ content = (token.tag or token.content).strip()
15
17
  if not content: # pragma: no cover
16
18
  return False
17
-
18
19
  # if it's a markdown_task, return false
19
20
  if content.startswith("[ ]") or content.startswith("[x]") or content.startswith("[-]"):
20
21
  return False
21
22
 
22
- has_category = content.startswith("[") and "]" in content
23
+ # Exclude markdown links: [text](url)
24
+ if re.match(r"^\[.*?\]\(.*?\)$", content):
25
+ return False
26
+
27
+ # Exclude wiki links: [[text]]
28
+ if re.match(r"^\[\[.*?\]\]$", content):
29
+ return False
30
+
31
+ # Check for proper observation format: [category] content
32
+ match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
23
33
  has_tags = "#" in content
24
- return has_category or has_tags
34
+ return bool(match) or has_tags
25
35
 
26
36
 
27
37
  def parse_observation(token: Token) -> Dict[str, Any]:
28
38
  """Extract observation parts from token."""
29
- # Strip bullet point if present
30
- content = token.content.strip()
39
+ import re
40
+
41
+ # Use token.tag which contains the actual content for test tokens, fallback to content
42
+ content = (token.tag or token.content).strip()
31
43
 
32
- # Parse [category]
44
+ # Parse [category] with regex
45
+ match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
33
46
  category = None
34
- if content.startswith("["):
35
- end = content.find("]")
36
- if end != -1:
37
- category = content[1:end].strip() or None # Convert empty to None
38
- content = content[end + 1 :].strip()
47
+ if match:
48
+ category = match.group(1).strip()
49
+ content = match.group(2).strip()
50
+ else:
51
+ # Handle empty brackets [] followed by content
52
+ empty_match = re.match(r"^\[\]\s+(.+)", content)
53
+ if empty_match:
54
+ content = empty_match.group(1).strip()
39
55
 
40
56
  # Parse (context)
41
57
  context = None
@@ -50,9 +66,7 @@ def parse_observation(token: Token) -> Dict[str, Any]:
50
66
  parts = content.split()
51
67
  for part in parts:
52
68
  if part.startswith("#"):
53
- # Handle multiple #tags stuck together
54
69
  if "#" in part[1:]:
55
- # Split on # but keep non-empty tags
56
70
  subtags = [t for t in part.split("#") if t]
57
71
  tags.extend(subtags)
58
72
  else:
@@ -72,14 +86,16 @@ def is_explicit_relation(token: Token) -> bool:
72
86
  if token.type != "inline": # pragma: no cover
73
87
  return False
74
88
 
75
- content = token.content.strip()
89
+ # Use token.tag which contains the actual content for test tokens, fallback to content
90
+ content = (token.tag or token.content).strip()
76
91
  return "[[" in content and "]]" in content
77
92
 
78
93
 
79
94
  def parse_relation(token: Token) -> Dict[str, Any] | None:
80
95
  """Extract relation parts from token."""
81
96
  # Remove bullet point if present
82
- content = token.content.strip()
97
+ # Use token.tag which contains the actual content for test tokens, fallback to content
98
+ content = (token.tag or token.content).strip()
83
99
 
84
100
  # Extract [[target]]
85
101
  target = None
@@ -213,10 +229,12 @@ def relation_plugin(md: MarkdownIt) -> None:
213
229
  token.meta["relations"] = [rel]
214
230
 
215
231
  # Always check for inline links in any text
216
- elif "[[" in token.content:
217
- rels = parse_inline_relations(token.content)
218
- if rels:
219
- token.meta["relations"] = token.meta.get("relations", []) + rels
232
+ else:
233
+ content = token.tag or token.content
234
+ if "[[" in content:
235
+ rels = parse_inline_relations(content)
236
+ if rels:
237
+ token.meta["relations"] = token.meta.get("relations", []) + rels
220
238
 
221
239
  # Add the rule after inline processing
222
240
  md.core.ruler.after("inline", "relations", relation_rule)
@@ -41,7 +41,7 @@ def entity_model_from_markdown(
41
41
  # Only update permalink if it exists in frontmatter, otherwise preserve existing
42
42
  if markdown.frontmatter.permalink is not None:
43
43
  model.permalink = markdown.frontmatter.permalink
44
- model.file_path = str(file_path)
44
+ model.file_path = file_path.as_posix()
45
45
  model.content_type = "text/markdown"
46
46
  model.created_at = markdown.created
47
47
  model.updated_at = markdown.modified
@@ -1,4 +1,4 @@
1
- from httpx import ASGITransport, AsyncClient
1
+ from httpx import ASGITransport, AsyncClient, Timeout
2
2
  from loguru import logger
3
3
 
4
4
  from basic_memory.api.app import app as fastapi_app
@@ -9,19 +9,31 @@ def create_client() -> AsyncClient:
9
9
  """Create an HTTP client based on configuration.
10
10
 
11
11
  Returns:
12
- AsyncClient configured for either local ASGI or remote HTTP transport
12
+ AsyncClient configured for either local ASGI or remote proxy
13
13
  """
14
14
  config_manager = ConfigManager()
15
- config = config_manager.load_config()
15
+ config = config_manager.config
16
16
 
17
- if config.api_url:
18
- # Use HTTP transport for remote API
19
- logger.info(f"Creating HTTP client for remote Basic Memory API: {config.api_url}")
20
- return AsyncClient(base_url=config.api_url)
17
+ # Configure timeout for longer operations like write_note
18
+ # Default httpx timeout is 5 seconds which is too short for file operations
19
+ timeout = Timeout(
20
+ connect=10.0, # 10 seconds for connection
21
+ read=30.0, # 30 seconds for reading response
22
+ write=30.0, # 30 seconds for writing request
23
+ pool=30.0, # 30 seconds for connection pool
24
+ )
25
+
26
+ if config.cloud_mode_enabled:
27
+ # Use HTTP transport to proxy endpoint
28
+ proxy_base_url = f"{config.cloud_host}/proxy"
29
+ logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
30
+ return AsyncClient(base_url=proxy_base_url, timeout=timeout)
21
31
  else:
22
- # Use ASGI transport for local API
23
- logger.debug("Creating ASGI client for local Basic Memory API")
24
- return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test")
32
+ # Default: use ASGI transport for local API (development mode)
33
+ logger.info("Creating ASGI client for local Basic Memory API")
34
+ return AsyncClient(
35
+ transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
36
+ )
25
37
 
26
38
 
27
39
  # Create shared async client
@@ -0,0 +1,141 @@
1
+ """Project context utilities for Basic Memory MCP server.
2
+
3
+ Provides project lookup utilities for MCP tools.
4
+ Handles project validation and context management in one place.
5
+ """
6
+
7
+ import os
8
+ from typing import Optional, List
9
+ from httpx import AsyncClient
10
+ from httpx._types import (
11
+ HeaderTypes,
12
+ )
13
+ from loguru import logger
14
+ from fastmcp import Context
15
+
16
+ from basic_memory.config import ConfigManager
17
+ from basic_memory.mcp.tools.utils import call_get
18
+ from basic_memory.schemas.project_info import ProjectItem, ProjectList
19
+ from basic_memory.utils import generate_permalink
20
+
21
+
22
+ async def resolve_project_parameter(project: Optional[str] = None) -> Optional[str]:
23
+ """Resolve project parameter using three-tier hierarchy.
24
+
25
+ if config.cloud_mode:
26
+ project is required
27
+ else:
28
+ Resolution order:
29
+ 1. Single Project Mode (--project cli arg, or BASIC_MEMORY_MCP_PROJECT env var) - highest priority
30
+ 2. Explicit project parameter - medium priority
31
+ 3. Default project if default_project_mode=true - lowest priority
32
+
33
+ Args:
34
+ project: Optional explicit project parameter
35
+
36
+ Returns:
37
+ Resolved project name or None if no resolution possible
38
+ """
39
+
40
+ config = ConfigManager().config
41
+ # if cloud_mode, project is required
42
+ if config.cloud_mode:
43
+ if project:
44
+ logger.debug(f"project: {project}, cloud_mode: {config.cloud_mode}")
45
+ return project
46
+ else:
47
+ raise ValueError("No project specified. Project is required for cloud mode.")
48
+
49
+ # Priority 1: CLI constraint overrides everything (--project arg sets env var)
50
+ constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
51
+ if constrained_project:
52
+ logger.debug(f"Using CLI constrained project: {constrained_project}")
53
+ return constrained_project
54
+
55
+ # Priority 2: Explicit project parameter
56
+ if project:
57
+ logger.debug(f"Using explicit project parameter: {project}")
58
+ return project
59
+
60
+ # Priority 3: Default project mode
61
+ if config.default_project_mode:
62
+ logger.debug(f"Using default project from config: {config.default_project}")
63
+ return config.default_project
64
+
65
+ # No resolution possible
66
+ return None
67
+
68
+
69
+ async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = None) -> List[str]:
70
+ response = await call_get(client, "/projects/projects", headers=headers)
71
+ project_list = ProjectList.model_validate(response.json())
72
+ return [project.name for project in project_list.projects]
73
+
74
+
75
+ async def get_active_project(
76
+ client: AsyncClient,
77
+ project: Optional[str] = None,
78
+ context: Optional[Context] = None,
79
+ headers: HeaderTypes | None = None,
80
+ ) -> ProjectItem:
81
+ """Get and validate project, setting it in context if available.
82
+
83
+ Args:
84
+ client: HTTP client for API calls
85
+ project: Optional project name (resolved using hierarchy)
86
+ context: Optional FastMCP context to cache the result
87
+
88
+ Returns:
89
+ The validated project item
90
+
91
+ Raises:
92
+ ValueError: If no project can be resolved
93
+ HTTPError: If project doesn't exist or is inaccessible
94
+ """
95
+ resolved_project = await resolve_project_parameter(project)
96
+ if not resolved_project:
97
+ project_names = await get_project_names(client, headers)
98
+ raise ValueError(
99
+ "No project specified. "
100
+ "Either set 'default_project_mode=true' in config, or use 'project' argument.\n"
101
+ f"Available projects: {project_names}"
102
+ )
103
+
104
+ project = resolved_project
105
+
106
+ # Check if already cached in context
107
+ if context:
108
+ cached_project = context.get_state("active_project")
109
+ if cached_project and cached_project.name == project:
110
+ logger.debug(f"Using cached project from context: {project}")
111
+ return cached_project
112
+
113
+ # Validate project exists by calling API
114
+ logger.debug(f"Validating project: {project}")
115
+ permalink = generate_permalink(project)
116
+ response = await call_get(client, f"/{permalink}/project/item", headers=headers)
117
+ active_project = ProjectItem.model_validate(response.json())
118
+
119
+ # Cache in context if available
120
+ if context:
121
+ context.set_state("active_project", active_project)
122
+ logger.debug(f"Cached project in context: {project}")
123
+
124
+ logger.debug(f"Validated project: {active_project.name}")
125
+ return active_project
126
+
127
+
128
+ def add_project_metadata(result: str, project_name: str) -> str:
129
+ """Add project context as metadata footer for assistant session tracking.
130
+
131
+ Provides clear project context to help the assistant remember which
132
+ project is being used throughout the conversation session.
133
+
134
+ Args:
135
+ result: The tool result string
136
+ project_name: The project name that was used
137
+
138
+ Returns:
139
+ Result with project session tracking metadata
140
+ """
141
+ return f"{result}\n\n[Session: Using project '{project_name}']"
@@ -1,5 +1,6 @@
1
1
  from pathlib import Path
2
2
 
3
+ from basic_memory.config import ConfigManager
3
4
  from basic_memory.mcp.server import mcp
4
5
  from loguru import logger
5
6
 
@@ -12,14 +13,58 @@ from loguru import logger
12
13
  def ai_assistant_guide() -> str:
13
14
  """Return a concise guide on Basic Memory tools and how to use them.
14
15
 
15
- Args:
16
- focus: Optional area to focus on ("writing", "context", "search", etc.)
16
+ Dynamically adapts instructions based on configuration:
17
+ - Default project mode: Simplified instructions with automatic project
18
+ - Regular mode: Project discovery and selection guidance
19
+ - CLI constraint mode: Single project constraint information
17
20
 
18
21
  Returns:
19
22
  A focused guide on Basic Memory usage.
20
23
  """
21
24
  logger.info("Loading AI assistant guide resource")
25
+
26
+ # Load base guide content
22
27
  guide_doc = Path(__file__).parent.parent / "resources" / "ai_assistant_guide.md"
23
28
  content = guide_doc.read_text(encoding="utf-8")
24
- logger.info(f"Loaded AI assistant guide ({len(content)} chars)")
25
- return content
29
+
30
+ # Check configuration for mode-specific instructions
31
+ config = ConfigManager().config
32
+
33
+ # Add mode-specific header
34
+ mode_info = ""
35
+ if config.default_project_mode:
36
+ mode_info = f"""
37
+ # 🎯 Default Project Mode Active
38
+
39
+ **Current Configuration**: All operations automatically use project '{config.default_project}'
40
+
41
+ **Simplified Usage**: You don't need to specify the project parameter in tool calls.
42
+ - `write_note(title="Note", content="...", folder="docs")` ✅
43
+ - Project parameter is optional and will default to '{config.default_project}'
44
+ - To use a different project, explicitly specify: `project="other-project"`
45
+
46
+ ────────────────────────────────────────
47
+
48
+ """
49
+ else:
50
+ mode_info = """
51
+ # 🔧 Multi-Project Mode Active
52
+
53
+ **Current Configuration**: Project parameter required for all operations
54
+
55
+ **Project Discovery Required**: Use these tools to select a project:
56
+ - `list_memory_projects()` - See all available projects
57
+ - `recent_activity()` - Get project activity and recommendations
58
+ - Remember the user's project choice throughout the conversation
59
+
60
+ ────────────────────────────────────────
61
+
62
+ """
63
+
64
+ # Prepend mode info to the guide
65
+ enhanced_content = mode_info + content
66
+
67
+ logger.info(
68
+ f"Loaded AI assistant guide ({len(enhanced_content)} chars) with mode: {'default_project' if config.default_project_mode else 'multi_project'}"
69
+ )
70
+ return enhanced_content
@@ -18,7 +18,7 @@ from basic_memory.schemas.prompt import ContinueConversationRequest
18
18
 
19
19
 
20
20
  @mcp.prompt(
21
- name="Continue Conversation",
21
+ name="continue_conversation",
22
22
  description="Continue a previous conversation",
23
23
  )
24
24
  async def continue_conversation(
@@ -3,7 +3,7 @@
3
3
  These prompts help users see what has changed in their knowledge base recently.
4
4
  """
5
5
 
6
- from typing import Annotated
6
+ from typing import Annotated, Optional
7
7
 
8
8
  from loguru import logger
9
9
  from pydantic import Field
@@ -12,49 +12,83 @@ from basic_memory.mcp.prompts.utils import format_prompt_context, PromptContext,
12
12
  from basic_memory.mcp.server import mcp
13
13
  from basic_memory.mcp.tools.recent_activity import recent_activity
14
14
  from basic_memory.schemas.base import TimeFrame
15
+ from basic_memory.schemas.memory import GraphContext, ProjectActivitySummary
15
16
  from basic_memory.schemas.search import SearchItemType
16
17
 
17
18
 
18
19
  @mcp.prompt(
19
- name="Share Recent Activity",
20
- description="Get recent activity from across the knowledge base",
20
+ name="recent_activity",
21
+ description="Get recent activity from a specific project or across all projects",
21
22
  )
22
23
  async def recent_activity_prompt(
23
24
  timeframe: Annotated[
24
25
  TimeFrame,
25
26
  Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
26
27
  ] = "7d",
28
+ project: Annotated[
29
+ Optional[str],
30
+ Field(
31
+ description="Specific project to get activity from (None for discovery across all projects)"
32
+ ),
33
+ ] = None,
27
34
  ) -> str:
28
- """Get recent activity from across the knowledge base.
35
+ """Get recent activity from a specific project or across all projects.
29
36
 
30
- This prompt helps you see what's changed recently in the knowledge base,
31
- showing new or updated documents and related information.
37
+ This prompt helps you see what's changed recently in the knowledge base.
38
+ In discovery mode (project=None), it shows activity across all projects.
39
+ In project-specific mode, it shows detailed activity for one project.
32
40
 
33
41
  Args:
34
42
  timeframe: How far back to look for activity (e.g. '1d', '1 week')
43
+ project: Specific project to get activity from (None for discovery across all projects)
35
44
 
36
45
  Returns:
37
46
  Formatted summary of recent activity
38
47
  """
39
- logger.info(f"Getting recent activity, timeframe: {timeframe}")
48
+ logger.info(f"Getting recent activity, timeframe: {timeframe}, project: {project}")
40
49
 
41
- recent = await recent_activity.fn(timeframe=timeframe, type=[SearchItemType.ENTITY])
50
+ recent = await recent_activity.fn(
51
+ project=project, timeframe=timeframe, type=[SearchItemType.ENTITY]
52
+ )
42
53
 
43
54
  # Extract primary results from the hierarchical structure
44
55
  primary_results = []
45
56
  related_results = []
46
57
 
47
- if recent.results:
48
- # Take up to 5 primary results
49
- for item in recent.results[:5]:
50
- primary_results.append(item.primary_result)
51
- # Add up to 2 related results per primary item
52
- if item.related_results:
53
- related_results.extend(item.related_results[:2])
58
+ if isinstance(recent, ProjectActivitySummary):
59
+ # Discovery mode - extract results from all projects
60
+ for _, project_activity in recent.projects.items():
61
+ if project_activity.activity.results:
62
+ # Take up to 2 primary results per project
63
+ for item in project_activity.activity.results[:2]:
64
+ primary_results.append(item.primary_result)
65
+ # Add up to 1 related result per primary item
66
+ if item.related_results:
67
+ related_results.extend(item.related_results[:1])
68
+
69
+ # Limit total results for readability
70
+ primary_results = primary_results[:8]
71
+ related_results = related_results[:6]
72
+
73
+ elif isinstance(recent, GraphContext):
74
+ # Project-specific mode - use existing logic
75
+ if recent.results:
76
+ # Take up to 5 primary results
77
+ for item in recent.results[:5]:
78
+ primary_results.append(item.primary_result)
79
+ # Add up to 2 related results per primary item
80
+ if item.related_results:
81
+ related_results.extend(item.related_results[:2])
82
+
83
+ # Set topic based on mode
84
+ if project:
85
+ topic = f"Recent Activity in {project} ({timeframe})"
86
+ else:
87
+ topic = f"Recent Activity Across All Projects ({timeframe})"
54
88
 
55
89
  prompt_context = format_prompt_context(
56
90
  PromptContext(
57
- topic=f"Recent Activity from ({timeframe})",
91
+ topic=topic,
58
92
  timeframe=timeframe,
59
93
  results=[
60
94
  PromptContextItem(
@@ -65,40 +99,90 @@ async def recent_activity_prompt(
65
99
  )
66
100
  )
67
101
 
68
- # Add suggestions for summarizing recent activity
102
+ # Add mode-specific suggestions
69
103
  first_title = "Recent Topic"
70
104
  if primary_results and len(primary_results) > 0:
71
105
  first_title = primary_results[0].title
72
106
 
73
- capture_suggestions = f"""
107
+ if project:
108
+ # Project-specific suggestions
109
+ capture_suggestions = f"""
74
110
  ## Opportunity to Capture Activity Summary
75
-
76
- Consider creating a summary note of recent activity:
77
-
111
+
112
+ Consider creating a summary note of recent activity in {project}:
113
+
78
114
  ```python
79
115
  await write_note(
116
+ "{project}",
80
117
  title="Activity Summary {timeframe}",
81
118
  content='''
82
- # Activity Summary for {timeframe}
83
-
119
+ # Activity Summary for {project} ({timeframe})
120
+
84
121
  ## Overview
85
- [Summary of key changes and developments over this period]
86
-
122
+ [Summary of key changes and developments in this project over this period]
123
+
87
124
  ## Key Updates
88
- [List main updates and their significance]
89
-
125
+ [List main updates and their significance within this project]
126
+
90
127
  ## Observations
91
128
  - [trend] [Observation about patterns in recent activity]
92
129
  - [insight] [Connection between different activities]
93
-
130
+
94
131
  ## Relations
95
132
  - summarizes [[{first_title}]]
96
- - relates_to [[Project Overview]]
97
- '''
133
+ - relates_to [[{project} Overview]]
134
+ ''',
135
+ folder="summaries"
98
136
  )
99
137
  ```
100
-
101
- Summarizing periodic activity helps create high-level insights and connections between topics.
138
+
139
+ Summarizing periodic activity helps create high-level insights and connections within the project.
140
+ """
141
+ else:
142
+ # Discovery mode suggestions
143
+ project_count = len(recent.projects) if isinstance(recent, ProjectActivitySummary) else 0
144
+ most_active = (
145
+ getattr(recent.summary, "most_active_project", "Unknown")
146
+ if isinstance(recent, ProjectActivitySummary)
147
+ else "Unknown"
148
+ )
149
+
150
+ capture_suggestions = f"""
151
+ ## Cross-Project Activity Discovery
152
+
153
+ Found activity across {project_count} projects. Most active: **{most_active}**
154
+
155
+ Consider creating a cross-project summary:
156
+
157
+ ```python
158
+ await write_note(
159
+ "{most_active if most_active != "Unknown" else "main"}",
160
+ title="Cross-Project Activity Summary {timeframe}",
161
+ content='''
162
+ # Cross-Project Activity Summary ({timeframe})
163
+
164
+ ## Overview
165
+ Activity found across {project_count} projects, with {most_active} showing the most activity.
166
+
167
+ ## Key Developments
168
+ [Summarize important changes across all projects]
169
+
170
+ ## Project Insights
171
+ [Note patterns or connections between projects]
172
+
173
+ ## Observations
174
+ - [trend] [Cross-project patterns observed]
175
+ - [insight] [Connections between different project activities]
176
+
177
+ ## Relations
178
+ - summarizes [[{first_title}]]
179
+ - relates_to [[Project Portfolio Overview]]
180
+ ''',
181
+ folder="summaries"
182
+ )
183
+ ```
184
+
185
+ Cross-project summaries help identify broader trends and project interconnections.
102
186
  """
103
187
 
104
188
  return prompt_context + capture_suggestions
@@ -17,7 +17,7 @@ from basic_memory.schemas.prompt import SearchPromptRequest
17
17
 
18
18
 
19
19
  @mcp.prompt(
20
- name="Search Knowledge Base",
20
+ name="search_knowledge_base",
21
21
  description="Search across all content in basic-memory",
22
22
  )
23
23
  async def search_prompt(
@@ -103,10 +103,17 @@ def format_prompt_context(context: PromptContext) -> str:
103
103
 
104
104
  added_permalinks.add(primary_permalink)
105
105
 
106
- memory_url = normalize_memory_url(primary_permalink)
106
+ # Use permalink if available, otherwise use file_path
107
+ if primary_permalink:
108
+ memory_url = normalize_memory_url(primary_permalink)
109
+ read_command = f'read_note("{primary_permalink}")'
110
+ else:
111
+ memory_url = f"file://{primary.file_path}"
112
+ read_command = f'read_file("{primary.file_path}")'
113
+
107
114
  section = dedent(f"""
108
115
  --- {memory_url}
109
-
116
+
110
117
  ## {primary.title}
111
118
  - **Type**: {primary.type}
112
119
  """)
@@ -121,8 +128,8 @@ def format_prompt_context(context: PromptContext) -> str:
121
128
  section += f"\n**Excerpt**:\n{content}\n"
122
129
 
123
130
  section += dedent(f"""
124
-
125
- You can read this document with: `read_note("{primary_permalink}")`
131
+
132
+ You can read this document with: `{read_command}`
126
133
  """)
127
134
  sections.append(section)
128
135