basic-memory 0.7.0__py3-none-any.whl → 0.17.4__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.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +130 -20
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +87 -20
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/__init__.py +4 -1
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +180 -23
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +9 -64
- basic_memory/api/routers/project_router.py +460 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +136 -11
- basic_memory/api/routers/search_router.py +5 -5
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +80 -10
- basic_memory/cli/auth.py +300 -0
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +127 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +240 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +99 -0
- basic_memory/cli/commands/db.py +87 -12
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +47 -223
- basic_memory/cli/commands/import_claude_conversations.py +48 -171
- basic_memory/cli/commands/import_claude_projects.py +53 -160
- basic_memory/cli/commands/import_memory_json.py +55 -111
- basic_memory/cli/commands/mcp.py +67 -11
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +52 -34
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +14 -6
- basic_memory/config.py +580 -26
- basic_memory/db.py +285 -28
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +16 -185
- basic_memory/file_utils.py +318 -54
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +100 -0
- basic_memory/importers/chatgpt_importer.py +245 -0
- basic_memory/importers/claude_conversations_importer.py +192 -0
- basic_memory/importers/claude_projects_importer.py +184 -0
- basic_memory/importers/memory_json_importer.py +128 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/entity_parser.py +182 -23
- basic_memory/markdown/markdown_processor.py +70 -7
- basic_memory/markdown/plugins.py +43 -23
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +38 -14
- basic_memory/mcp/async_client.py +135 -4
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +155 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +61 -9
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +249 -0
- basic_memory/mcp/tools/edit_note.py +325 -0
- basic_memory/mcp/tools/list_directory.py +157 -0
- basic_memory/mcp/tools/move_note.py +549 -0
- basic_memory/mcp/tools/project_management.py +204 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +265 -0
- basic_memory/mcp/tools/recent_activity.py +528 -0
- basic_memory/mcp/tools/search.py +377 -24
- basic_memory/mcp/tools/utils.py +402 -16
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +82 -17
- basic_memory/models/project.py +93 -0
- basic_memory/models/search.py +68 -8
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +437 -8
- basic_memory/repository/observation_repository.py +36 -3
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +140 -0
- basic_memory/repository/relation_repository.py +79 -4
- basic_memory/repository/repository.py +148 -29
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +79 -268
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +131 -12
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +31 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +194 -25
- basic_memory/schemas/project_info.py +213 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/response.py +85 -28
- basic_memory/schemas/search.py +36 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +451 -138
- basic_memory/services/directory_service.py +310 -0
- basic_memory/services/entity_service.py +636 -71
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +402 -33
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +888 -0
- basic_memory/services/search_service.py +232 -37
- basic_memory/sync/__init__.py +4 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +1200 -109
- basic_memory/sync/watch_service.py +432 -135
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +407 -54
- basic_memory-0.17.4.dist-info/METADATA +617 -0
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -206
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Write note tool for Basic Memory MCP server."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Union, Optional
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from basic_memory.mcp.async_client import get_client
|
|
8
|
+
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
|
9
|
+
from basic_memory.mcp.server import mcp
|
|
10
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
11
|
+
from fastmcp import Context
|
|
12
|
+
from basic_memory.schemas.base import Entity
|
|
13
|
+
from basic_memory.utils import parse_tags, validate_project_path
|
|
14
|
+
|
|
15
|
+
# Define TagType as a Union that can accept either a string or a list of strings or None
|
|
16
|
+
TagType = Union[List[str], str, None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@mcp.tool(
|
|
20
|
+
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
|
|
21
|
+
)
|
|
22
|
+
async def write_note(
|
|
23
|
+
title: str,
|
|
24
|
+
content: str,
|
|
25
|
+
folder: str,
|
|
26
|
+
project: Optional[str] = None,
|
|
27
|
+
tags: list[str] | str | None = None,
|
|
28
|
+
note_type: str = "note",
|
|
29
|
+
context: Context | None = None,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Write a markdown note to the knowledge base.
|
|
32
|
+
|
|
33
|
+
Creates or updates a markdown note with semantic observations and relations.
|
|
34
|
+
|
|
35
|
+
Project Resolution:
|
|
36
|
+
Server resolves projects in this order: Single Project Mode → project parameter → default project.
|
|
37
|
+
If project unknown, use list_memory_projects() or recent_activity() first.
|
|
38
|
+
|
|
39
|
+
The content can include semantic observations and relations using markdown syntax:
|
|
40
|
+
|
|
41
|
+
Observations format:
|
|
42
|
+
`- [category] Observation text #tag1 #tag2 (optional context)`
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
`- [design] Files are the source of truth #architecture (All state comes from files)`
|
|
46
|
+
`- [tech] Using SQLite for storage #implementation`
|
|
47
|
+
`- [note] Need to add error handling #todo`
|
|
48
|
+
|
|
49
|
+
Relations format:
|
|
50
|
+
- Explicit: `- relation_type [[Entity]] (optional context)`
|
|
51
|
+
- Inline: Any `[[Entity]]` reference creates a relation
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
`- depends_on [[Content Parser]] (Need for semantic extraction)`
|
|
55
|
+
`- implements [[Search Spec]] (Initial implementation)`
|
|
56
|
+
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
title: The title of the note
|
|
60
|
+
content: Markdown content for the note, can include observations and relations
|
|
61
|
+
folder: Folder path relative to project root where the file should be saved.
|
|
62
|
+
Use forward slashes (/) as separators. Use "/" or "" to write to project root.
|
|
63
|
+
Examples: "notes", "projects/2025", "research/ml", "/" (root)
|
|
64
|
+
project: Project name to write to. Optional - server will resolve using the
|
|
65
|
+
hierarchy above. If unknown, use list_memory_projects() to discover
|
|
66
|
+
available projects.
|
|
67
|
+
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
|
|
68
|
+
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
|
|
69
|
+
note_type: Type of note to create (stored in frontmatter). Defaults to "note".
|
|
70
|
+
Can be "guide", "report", "config", "person", etc.
|
|
71
|
+
context: Optional FastMCP context for performance caching.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A markdown formatted summary of the semantic content, including:
|
|
75
|
+
- Creation/update status with project name
|
|
76
|
+
- File path and checksum
|
|
77
|
+
- Observation counts by category
|
|
78
|
+
- Relation counts (resolved/unresolved)
|
|
79
|
+
- Tags if present
|
|
80
|
+
- Session tracking metadata for project awareness
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
# Assistant flow when project is unknown
|
|
84
|
+
# 1. list_memory_projects() -> Ask user which project
|
|
85
|
+
# 2. User: "Use my-research"
|
|
86
|
+
# 3. write_note(...) and remember "my-research" for session
|
|
87
|
+
|
|
88
|
+
# Create a simple note
|
|
89
|
+
write_note(
|
|
90
|
+
project="my-research",
|
|
91
|
+
title="Meeting Notes",
|
|
92
|
+
folder="meetings",
|
|
93
|
+
content="# Weekly Standup\\n\\n- [decision] Use SQLite for storage #tech"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Create a note with tags and note type
|
|
97
|
+
write_note(
|
|
98
|
+
project="work-project",
|
|
99
|
+
title="API Design",
|
|
100
|
+
folder="specs",
|
|
101
|
+
content="# REST API Specification\\n\\n- implements [[Authentication]]",
|
|
102
|
+
tags=["api", "design"],
|
|
103
|
+
note_type="guide"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Update existing note (same title/folder)
|
|
107
|
+
write_note(
|
|
108
|
+
project="my-research",
|
|
109
|
+
title="Meeting Notes",
|
|
110
|
+
folder="meetings",
|
|
111
|
+
content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
HTTPError: If project doesn't exist or is inaccessible
|
|
116
|
+
SecurityError: If folder path attempts path traversal
|
|
117
|
+
"""
|
|
118
|
+
track_mcp_tool("write_note")
|
|
119
|
+
async with get_client() as client:
|
|
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
|
+
# Normalize "/" to empty string for root folder (must happen before validation)
|
|
128
|
+
if folder == "/":
|
|
129
|
+
folder = ""
|
|
130
|
+
|
|
131
|
+
# Validate folder path to prevent path traversal attacks
|
|
132
|
+
project_path = active_project.home
|
|
133
|
+
if folder and not validate_project_path(folder, project_path):
|
|
134
|
+
logger.warning(
|
|
135
|
+
"Attempted path traversal attack blocked",
|
|
136
|
+
folder=folder,
|
|
137
|
+
project=active_project.name,
|
|
138
|
+
)
|
|
139
|
+
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
|
|
140
|
+
|
|
141
|
+
# Process tags using the helper function
|
|
142
|
+
tag_list = parse_tags(tags)
|
|
143
|
+
# Create the entity request
|
|
144
|
+
metadata = {"tags": tag_list} if tag_list else None
|
|
145
|
+
entity = Entity(
|
|
146
|
+
title=title,
|
|
147
|
+
folder=folder,
|
|
148
|
+
entity_type=note_type,
|
|
149
|
+
content_type="text/markdown",
|
|
150
|
+
content=content,
|
|
151
|
+
entity_metadata=metadata,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Import here to avoid circular import
|
|
155
|
+
from basic_memory.mcp.clients import KnowledgeClient
|
|
156
|
+
|
|
157
|
+
# Use typed KnowledgeClient for API calls
|
|
158
|
+
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
|
159
|
+
|
|
160
|
+
# Try to create the entity first (optimistic create)
|
|
161
|
+
logger.debug(f"Attempting to create entity permalink={entity.permalink}")
|
|
162
|
+
action = "Created" # Default to created
|
|
163
|
+
try:
|
|
164
|
+
result = await knowledge_client.create_entity(entity.model_dump())
|
|
165
|
+
action = "Created"
|
|
166
|
+
except Exception as e:
|
|
167
|
+
# If creation failed due to conflict (already exists), try to update
|
|
168
|
+
if (
|
|
169
|
+
"409" in str(e)
|
|
170
|
+
or "conflict" in str(e).lower()
|
|
171
|
+
or "already exists" in str(e).lower()
|
|
172
|
+
):
|
|
173
|
+
logger.debug(f"Entity exists, updating instead permalink={entity.permalink}")
|
|
174
|
+
try:
|
|
175
|
+
if not entity.permalink:
|
|
176
|
+
raise ValueError("Entity permalink is required for updates") # pragma: no cover
|
|
177
|
+
entity_id = await knowledge_client.resolve_entity(entity.permalink)
|
|
178
|
+
result = await knowledge_client.update_entity(entity_id, entity.model_dump())
|
|
179
|
+
action = "Updated"
|
|
180
|
+
except Exception as update_error: # pragma: no cover
|
|
181
|
+
# Re-raise the original error if update also fails
|
|
182
|
+
raise e from update_error # pragma: no cover
|
|
183
|
+
else:
|
|
184
|
+
# Re-raise if it's not a conflict error
|
|
185
|
+
raise # pragma: no cover
|
|
186
|
+
summary = [
|
|
187
|
+
f"# {action} note",
|
|
188
|
+
f"project: {active_project.name}",
|
|
189
|
+
f"file_path: {result.file_path}",
|
|
190
|
+
f"permalink: {result.permalink}",
|
|
191
|
+
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
# Count observations by category
|
|
195
|
+
categories = {}
|
|
196
|
+
if result.observations:
|
|
197
|
+
for obs in result.observations:
|
|
198
|
+
categories[obs.category] = categories.get(obs.category, 0) + 1
|
|
199
|
+
|
|
200
|
+
summary.append("\n## Observations")
|
|
201
|
+
for category, count in sorted(categories.items()):
|
|
202
|
+
summary.append(f"- {category}: {count}")
|
|
203
|
+
|
|
204
|
+
# Count resolved/unresolved relations
|
|
205
|
+
unresolved = 0
|
|
206
|
+
resolved = 0
|
|
207
|
+
if result.relations:
|
|
208
|
+
unresolved = sum(1 for r in result.relations if not r.to_id)
|
|
209
|
+
resolved = len(result.relations) - unresolved
|
|
210
|
+
|
|
211
|
+
summary.append("\n## Relations")
|
|
212
|
+
summary.append(f"- Resolved: {resolved}")
|
|
213
|
+
if unresolved:
|
|
214
|
+
summary.append(f"- Unresolved: {unresolved}")
|
|
215
|
+
summary.append(
|
|
216
|
+
"\nNote: Unresolved relations point to entities that don't exist yet."
|
|
217
|
+
)
|
|
218
|
+
summary.append(
|
|
219
|
+
"They will be automatically resolved when target entities are created or during sync operations."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if tag_list:
|
|
223
|
+
summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
|
|
224
|
+
|
|
225
|
+
# Log the response with structured data
|
|
226
|
+
logger.info(
|
|
227
|
+
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}"
|
|
228
|
+
)
|
|
229
|
+
summary_result = "\n".join(summary)
|
|
230
|
+
return add_project_metadata(summary_result, active_project.name)
|
basic_memory/models/__init__.py
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
import basic_memory
|
|
4
4
|
from basic_memory.models.base import Base
|
|
5
5
|
from basic_memory.models.knowledge import Entity, Observation, Relation
|
|
6
|
-
|
|
7
|
-
SCHEMA_VERSION = basic_memory.__version__ + "-" + "003"
|
|
6
|
+
from basic_memory.models.project import Project
|
|
8
7
|
|
|
9
8
|
__all__ = [
|
|
10
9
|
"Base",
|
|
11
10
|
"Entity",
|
|
12
11
|
"Observation",
|
|
13
12
|
"Relation",
|
|
13
|
+
"Project",
|
|
14
|
+
"basic_memory",
|
|
14
15
|
]
|
basic_memory/models/knowledge.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Knowledge graph models."""
|
|
2
2
|
|
|
3
|
+
import uuid
|
|
3
4
|
from datetime import datetime
|
|
5
|
+
from basic_memory.utils import ensure_timezone_aware
|
|
4
6
|
from typing import Optional
|
|
5
7
|
|
|
6
8
|
from sqlalchemy import (
|
|
@@ -12,11 +14,12 @@ from sqlalchemy import (
|
|
|
12
14
|
DateTime,
|
|
13
15
|
Index,
|
|
14
16
|
JSON,
|
|
17
|
+
Float,
|
|
18
|
+
text,
|
|
15
19
|
)
|
|
16
20
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
17
21
|
|
|
18
22
|
from basic_memory.models.base import Base
|
|
19
|
-
|
|
20
23
|
from basic_memory.utils import generate_permalink
|
|
21
24
|
|
|
22
25
|
|
|
@@ -28,36 +31,73 @@ class Entity(Base):
|
|
|
28
31
|
- Maps to a file on disk
|
|
29
32
|
- Maintains a checksum for change detection
|
|
30
33
|
- Tracks both source file and semantic properties
|
|
34
|
+
- Belongs to a specific project
|
|
31
35
|
"""
|
|
32
36
|
|
|
33
37
|
__tablename__ = "entity"
|
|
34
38
|
__table_args__ = (
|
|
35
|
-
|
|
39
|
+
# Regular indexes
|
|
36
40
|
Index("ix_entity_type", "entity_type"),
|
|
37
41
|
Index("ix_entity_title", "title"),
|
|
42
|
+
Index("ix_entity_external_id", "external_id", unique=True),
|
|
38
43
|
Index("ix_entity_created_at", "created_at"), # For timeline queries
|
|
39
44
|
Index("ix_entity_updated_at", "updated_at"), # For timeline queries
|
|
45
|
+
Index("ix_entity_project_id", "project_id"), # For project filtering
|
|
46
|
+
# Project-specific uniqueness constraints
|
|
47
|
+
Index(
|
|
48
|
+
"uix_entity_permalink_project",
|
|
49
|
+
"permalink",
|
|
50
|
+
"project_id",
|
|
51
|
+
unique=True,
|
|
52
|
+
sqlite_where=text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
|
53
|
+
),
|
|
54
|
+
Index(
|
|
55
|
+
"uix_entity_file_path_project",
|
|
56
|
+
"file_path",
|
|
57
|
+
"project_id",
|
|
58
|
+
unique=True,
|
|
59
|
+
),
|
|
40
60
|
)
|
|
41
61
|
|
|
42
62
|
# Core identity
|
|
43
63
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
64
|
+
# External UUID for API references - stable identifier that won't change
|
|
65
|
+
external_id: Mapped[str] = mapped_column(
|
|
66
|
+
String, unique=True, default=lambda: str(uuid.uuid4())
|
|
67
|
+
)
|
|
44
68
|
title: Mapped[str] = mapped_column(String)
|
|
45
69
|
entity_type: Mapped[str] = mapped_column(String)
|
|
46
70
|
entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
47
71
|
content_type: Mapped[str] = mapped_column(String)
|
|
48
72
|
|
|
49
|
-
#
|
|
50
|
-
|
|
73
|
+
# Project reference
|
|
74
|
+
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), nullable=False)
|
|
75
|
+
|
|
76
|
+
# Normalized path for URIs - required for markdown files only
|
|
77
|
+
permalink: Mapped[Optional[str]] = mapped_column(String, nullable=True, index=True)
|
|
51
78
|
# Actual filesystem relative path
|
|
52
|
-
file_path: Mapped[str] = mapped_column(String,
|
|
79
|
+
file_path: Mapped[str] = mapped_column(String, index=True)
|
|
53
80
|
# checksum of file
|
|
54
81
|
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
55
82
|
|
|
83
|
+
# File metadata for sync
|
|
84
|
+
# mtime: file modification timestamp (Unix epoch float) for change detection
|
|
85
|
+
mtime: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
|
86
|
+
# size: file size in bytes for quick change detection
|
|
87
|
+
size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
88
|
+
|
|
56
89
|
# Metadata and tracking
|
|
57
|
-
created_at: Mapped[datetime] = mapped_column(
|
|
58
|
-
|
|
90
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
91
|
+
DateTime(timezone=True), default=lambda: datetime.now().astimezone()
|
|
92
|
+
)
|
|
93
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
94
|
+
DateTime(timezone=True),
|
|
95
|
+
default=lambda: datetime.now().astimezone(),
|
|
96
|
+
onupdate=lambda: datetime.now().astimezone(),
|
|
97
|
+
)
|
|
59
98
|
|
|
60
99
|
# Relationships
|
|
100
|
+
project = relationship("Project", back_populates="entities")
|
|
61
101
|
observations = relationship(
|
|
62
102
|
"Observation", back_populates="entity", cascade="all, delete-orphan"
|
|
63
103
|
)
|
|
@@ -79,8 +119,23 @@ class Entity(Base):
|
|
|
79
119
|
"""Get all relations (incoming and outgoing) for this entity."""
|
|
80
120
|
return self.incoming_relations + self.outgoing_relations
|
|
81
121
|
|
|
122
|
+
@property
|
|
123
|
+
def is_markdown(self):
|
|
124
|
+
"""Check if the entity is a markdown file."""
|
|
125
|
+
return self.content_type == "text/markdown"
|
|
126
|
+
|
|
127
|
+
def __getattribute__(self, name):
|
|
128
|
+
"""Override attribute access to ensure datetime fields are timezone-aware."""
|
|
129
|
+
value = super().__getattribute__(name)
|
|
130
|
+
|
|
131
|
+
# Ensure datetime fields are timezone-aware
|
|
132
|
+
if name in ("created_at", "updated_at") and isinstance(value, datetime):
|
|
133
|
+
return ensure_timezone_aware(value)
|
|
134
|
+
|
|
135
|
+
return value
|
|
136
|
+
|
|
82
137
|
def __repr__(self) -> str:
|
|
83
|
-
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
|
|
138
|
+
return f"Entity(id={self.id}, external_id='{self.external_id}', name='{self.title}', type='{self.entity_type}', checksum='{self.checksum}')"
|
|
84
139
|
|
|
85
140
|
|
|
86
141
|
class Observation(Base):
|
|
@@ -96,6 +151,7 @@ class Observation(Base):
|
|
|
96
151
|
)
|
|
97
152
|
|
|
98
153
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
154
|
+
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
|
|
99
155
|
entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
|
|
100
156
|
content: Mapped[str] = mapped_column(Text)
|
|
101
157
|
category: Mapped[str] = mapped_column(String, nullable=False, default="note")
|
|
@@ -113,9 +169,14 @@ class Observation(Base):
|
|
|
113
169
|
|
|
114
170
|
We can construct these because observations are always defined in
|
|
115
171
|
and owned by a single entity.
|
|
172
|
+
|
|
173
|
+
Content is truncated to 200 chars to stay under PostgreSQL's
|
|
174
|
+
btree index limit of 2704 bytes.
|
|
116
175
|
"""
|
|
176
|
+
# Truncate content to avoid exceeding PostgreSQL's btree index limit
|
|
177
|
+
content_for_permalink = self.content[:200] if len(self.content) > 200 else self.content
|
|
117
178
|
return generate_permalink(
|
|
118
|
-
f"{self.entity.permalink}/observations/{self.category}/{
|
|
179
|
+
f"{self.entity.permalink}/observations/{self.category}/{content_for_permalink}"
|
|
119
180
|
)
|
|
120
181
|
|
|
121
182
|
def __repr__(self) -> str: # pragma: no cover
|
|
@@ -127,13 +188,17 @@ class Relation(Base):
|
|
|
127
188
|
|
|
128
189
|
__tablename__ = "relation"
|
|
129
190
|
__table_args__ = (
|
|
130
|
-
UniqueConstraint("from_id", "to_id", "relation_type", name="
|
|
191
|
+
UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation_from_id_to_id"),
|
|
192
|
+
UniqueConstraint(
|
|
193
|
+
"from_id", "to_name", "relation_type", name="uix_relation_from_id_to_name"
|
|
194
|
+
),
|
|
131
195
|
Index("ix_relation_type", "relation_type"),
|
|
132
196
|
Index("ix_relation_from_id", "from_id"), # Add FK indexes
|
|
133
197
|
Index("ix_relation_to_id", "to_id"),
|
|
134
198
|
)
|
|
135
199
|
|
|
136
200
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
201
|
+
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
|
|
137
202
|
from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
|
|
138
203
|
to_id: Mapped[Optional[int]] = mapped_column(
|
|
139
204
|
Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True
|
|
@@ -155,13 +220,13 @@ class Relation(Base):
|
|
|
155
220
|
Format: source/relation_type/target
|
|
156
221
|
Example: "specs/search/implements/features/search-ui"
|
|
157
222
|
"""
|
|
223
|
+
# Only create permalinks when both source and target have permalinks
|
|
224
|
+
from_permalink = self.from_entity.permalink or self.from_entity.file_path
|
|
225
|
+
|
|
158
226
|
if self.to_entity:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return generate_permalink(
|
|
163
|
-
f"{self.from_entity.permalink}/{self.relation_type}/{self.to_name}"
|
|
164
|
-
)
|
|
227
|
+
to_permalink = self.to_entity.permalink or self.to_entity.file_path
|
|
228
|
+
return generate_permalink(f"{from_permalink}/{self.relation_type}/{to_permalink}")
|
|
229
|
+
return generate_permalink(f"{from_permalink}/{self.relation_type}/{self.to_name}")
|
|
165
230
|
|
|
166
231
|
def __repr__(self) -> str:
|
|
167
|
-
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')"
|
|
232
|
+
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')" # pragma: no cover
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Project model for Basic Memory."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, UTC
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import (
|
|
8
|
+
Integer,
|
|
9
|
+
String,
|
|
10
|
+
Text,
|
|
11
|
+
Boolean,
|
|
12
|
+
DateTime,
|
|
13
|
+
Float,
|
|
14
|
+
Index,
|
|
15
|
+
event,
|
|
16
|
+
)
|
|
17
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
18
|
+
|
|
19
|
+
from basic_memory.models.base import Base
|
|
20
|
+
from basic_memory.utils import generate_permalink
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Project(Base):
|
|
24
|
+
"""Project model for Basic Memory.
|
|
25
|
+
|
|
26
|
+
A project represents a collection of knowledge entities that are grouped together.
|
|
27
|
+
Projects are stored in the app-level database and provide context for all knowledge
|
|
28
|
+
operations.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__tablename__ = "project"
|
|
32
|
+
__table_args__ = (
|
|
33
|
+
# Regular indexes
|
|
34
|
+
Index("ix_project_name", "name", unique=True),
|
|
35
|
+
Index("ix_project_permalink", "permalink", unique=True),
|
|
36
|
+
Index("ix_project_external_id", "external_id", unique=True),
|
|
37
|
+
Index("ix_project_path", "path"),
|
|
38
|
+
Index("ix_project_created_at", "created_at"),
|
|
39
|
+
Index("ix_project_updated_at", "updated_at"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Core identity
|
|
43
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
44
|
+
# External UUID for API references - stable identifier that won't change
|
|
45
|
+
external_id: Mapped[str] = mapped_column(
|
|
46
|
+
String, unique=True, default=lambda: str(uuid.uuid4())
|
|
47
|
+
)
|
|
48
|
+
name: Mapped[str] = mapped_column(String, unique=True)
|
|
49
|
+
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
50
|
+
|
|
51
|
+
# URL-friendly identifier generated from name
|
|
52
|
+
permalink: Mapped[str] = mapped_column(String, unique=True)
|
|
53
|
+
|
|
54
|
+
# Filesystem path to project directory
|
|
55
|
+
path: Mapped[str] = mapped_column(String)
|
|
56
|
+
|
|
57
|
+
# Status flags
|
|
58
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
59
|
+
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
|
|
60
|
+
|
|
61
|
+
# Timestamps
|
|
62
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
63
|
+
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
|
64
|
+
)
|
|
65
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
66
|
+
DateTime(timezone=True),
|
|
67
|
+
default=lambda: datetime.now(UTC),
|
|
68
|
+
onupdate=lambda: datetime.now(UTC),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Sync optimization - scan watermark tracking
|
|
72
|
+
last_scan_timestamp: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
|
73
|
+
last_file_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
74
|
+
|
|
75
|
+
# Define relationships to entities, observations, and relations
|
|
76
|
+
# These relationships will be established once we add project_id to those models
|
|
77
|
+
entities = relationship("Entity", back_populates="project", cascade="all, delete-orphan")
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
80
|
+
return f"Project(id={self.id}, external_id='{self.external_id}', name='{self.name}', permalink='{self.permalink}', path='{self.path}')"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@event.listens_for(Project, "before_insert")
|
|
84
|
+
@event.listens_for(Project, "before_update")
|
|
85
|
+
def set_project_permalink(mapper, connection, project):
|
|
86
|
+
"""Generate URL-friendly permalink for the project if needed.
|
|
87
|
+
|
|
88
|
+
This event listener ensures the permalink is always derived from the name,
|
|
89
|
+
even if the name changes.
|
|
90
|
+
"""
|
|
91
|
+
# If the name changed or permalink is empty, regenerate permalink
|
|
92
|
+
if not project.permalink or project.permalink != generate_permalink(project.name):
|
|
93
|
+
project.permalink = generate_permalink(project.name)
|
basic_memory/models/search.py
CHANGED
|
@@ -1,32 +1,92 @@
|
|
|
1
|
-
"""Search
|
|
1
|
+
"""Search DDL statements for SQLite and Postgres.
|
|
2
|
+
|
|
3
|
+
The search_index table is created via raw DDL, not ORM models, because:
|
|
4
|
+
- SQLite uses FTS5 virtual tables (cannot be represented as ORM)
|
|
5
|
+
- Postgres uses composite primary keys and generated tsvector columns
|
|
6
|
+
- Both backends use raw SQL for all search operations via SearchIndexRow dataclass
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
from sqlalchemy import DDL
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
|
|
12
|
+
# Define Postgres search_index table with composite primary key and tsvector
|
|
13
|
+
# This DDL matches the Alembic migration schema (314f1ea54dc4)
|
|
14
|
+
# Used by tests to create the table without running full migrations
|
|
15
|
+
# NOTE: Split into separate DDL statements because asyncpg doesn't support
|
|
16
|
+
# multiple statements in a single execute call.
|
|
17
|
+
CREATE_POSTGRES_SEARCH_INDEX_TABLE = DDL("""
|
|
18
|
+
CREATE TABLE IF NOT EXISTS search_index (
|
|
19
|
+
id INTEGER NOT NULL,
|
|
20
|
+
project_id INTEGER NOT NULL,
|
|
21
|
+
title TEXT,
|
|
22
|
+
content_stems TEXT,
|
|
23
|
+
content_snippet TEXT,
|
|
24
|
+
permalink VARCHAR,
|
|
25
|
+
file_path VARCHAR,
|
|
26
|
+
type VARCHAR,
|
|
27
|
+
from_id INTEGER,
|
|
28
|
+
to_id INTEGER,
|
|
29
|
+
relation_type VARCHAR,
|
|
30
|
+
entity_id INTEGER,
|
|
31
|
+
category VARCHAR,
|
|
32
|
+
metadata JSONB,
|
|
33
|
+
created_at TIMESTAMP WITH TIME ZONE,
|
|
34
|
+
updated_at TIMESTAMP WITH TIME ZONE,
|
|
35
|
+
textsearchable_index_col tsvector GENERATED ALWAYS AS (
|
|
36
|
+
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content_stems, ''))
|
|
37
|
+
) STORED,
|
|
38
|
+
PRIMARY KEY (id, type, project_id),
|
|
39
|
+
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
|
|
40
|
+
)
|
|
41
|
+
""")
|
|
42
|
+
|
|
43
|
+
CREATE_POSTGRES_SEARCH_INDEX_FTS = DDL("""
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_search_index_fts ON search_index USING gin(textsearchable_index_col)
|
|
45
|
+
""")
|
|
46
|
+
|
|
47
|
+
CREATE_POSTGRES_SEARCH_INDEX_METADATA = DDL("""
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_search_index_metadata_gin ON search_index USING gin(metadata jsonb_path_ops)
|
|
49
|
+
""")
|
|
50
|
+
|
|
51
|
+
# Partial unique index on (permalink, project_id) for non-null permalinks
|
|
52
|
+
# This prevents duplicate permalinks per project and is used by upsert operations
|
|
53
|
+
# in PostgresSearchRepository to handle race conditions during parallel indexing
|
|
54
|
+
CREATE_POSTGRES_SEARCH_INDEX_PERMALINK = DDL("""
|
|
55
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uix_search_index_permalink_project
|
|
56
|
+
ON search_index (permalink, project_id)
|
|
57
|
+
WHERE permalink IS NOT NULL
|
|
58
|
+
""")
|
|
59
|
+
|
|
60
|
+
# Define FTS5 virtual table creation for SQLite only
|
|
61
|
+
# This DDL is executed separately for SQLite databases
|
|
6
62
|
CREATE_SEARCH_INDEX = DDL("""
|
|
7
63
|
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
8
64
|
-- Core entity fields
|
|
9
65
|
id UNINDEXED, -- Row ID
|
|
10
66
|
title, -- Title for searching
|
|
11
|
-
|
|
67
|
+
content_stems, -- Main searchable content split into stems
|
|
68
|
+
content_snippet, -- File content snippet for display
|
|
12
69
|
permalink, -- Stable identifier (now indexed for path search)
|
|
13
70
|
file_path UNINDEXED, -- Physical location
|
|
14
71
|
type UNINDEXED, -- entity/relation/observation
|
|
15
|
-
|
|
16
|
-
--
|
|
72
|
+
|
|
73
|
+
-- Project context
|
|
74
|
+
project_id UNINDEXED, -- Project identifier
|
|
75
|
+
|
|
76
|
+
-- Relation fields
|
|
17
77
|
from_id UNINDEXED, -- Source entity
|
|
18
78
|
to_id UNINDEXED, -- Target entity
|
|
19
79
|
relation_type UNINDEXED, -- Type of relation
|
|
20
|
-
|
|
80
|
+
|
|
21
81
|
-- Observation fields
|
|
22
82
|
entity_id UNINDEXED, -- Parent entity
|
|
23
83
|
category UNINDEXED, -- Observation category
|
|
24
|
-
|
|
84
|
+
|
|
25
85
|
-- Common fields
|
|
26
86
|
metadata UNINDEXED, -- JSON metadata
|
|
27
87
|
created_at UNINDEXED, -- Creation timestamp
|
|
28
88
|
updated_at UNINDEXED, -- Last update
|
|
29
|
-
|
|
89
|
+
|
|
30
90
|
-- Configuration
|
|
31
91
|
tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
|
|
32
92
|
prefix='1,2,3,4' -- Support longer prefixes for paths
|