basic-memory 0.2.12__py3-none-any.whl → 0.16.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +27 -3
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -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/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/api/app.py +63 -31
- 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 +165 -28
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +28 -67
- basic_memory/api/routers/project_router.py +406 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +219 -14
- basic_memory/api/routers/search_router.py +21 -13
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +52 -1
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +13 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +51 -0
- basic_memory/cli/commands/db.py +26 -7
- basic_memory/cli/commands/import_chatgpt.py +83 -0
- basic_memory/cli/commands/import_claude_conversations.py +86 -0
- basic_memory/cli/commands/import_claude_projects.py +85 -0
- basic_memory/cli/commands/import_memory_json.py +35 -92
- basic_memory/cli/commands/mcp.py +84 -10
- basic_memory/cli/commands/project.py +876 -0
- basic_memory/cli/commands/status.py +47 -30
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +13 -6
- basic_memory/config.py +481 -22
- basic_memory/db.py +192 -32
- basic_memory/deps.py +252 -22
- basic_memory/file_utils.py +113 -58
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +177 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +143 -23
- basic_memory/markdown/markdown_processor.py +3 -3
- basic_memory/markdown/plugins.py +39 -21
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +28 -13
- basic_memory/mcp/async_client.py +134 -4
- basic_memory/mcp/project_context.py +141 -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 +7 -13
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +130 -0
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +225 -0
- basic_memory/mcp/tools/edit_note.py +320 -0
- basic_memory/mcp/tools/list_directory.py +167 -0
- basic_memory/mcp/tools/move_note.py +545 -0
- basic_memory/mcp/tools/project_management.py +200 -0
- basic_memory/mcp/tools/read_content.py +271 -0
- basic_memory/mcp/tools/read_note.py +255 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +369 -14
- basic_memory/mcp/tools/utils.py +374 -16
- basic_memory/mcp/tools/view_note.py +77 -0
- basic_memory/mcp/tools/write_note.py +207 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +67 -15
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +10 -6
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +229 -7
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +103 -0
- basic_memory/repository/relation_repository.py +21 -2
- basic_memory/repository/repository.py +147 -29
- basic_memory/repository/search_repository.py +437 -59
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +97 -8
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +188 -23
- basic_memory/schemas/project_info.py +211 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +57 -3
- basic_memory/schemas/response.py +9 -1
- basic_memory/schemas/search.py +33 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +251 -106
- basic_memory/services/directory_service.py +295 -0
- basic_memory/services/entity_service.py +595 -60
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +284 -30
- basic_memory/services/initialization.py +191 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +863 -0
- basic_memory/services/search_service.py +172 -34
- basic_memory/sync/__init__.py +3 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1176 -96
- basic_memory/sync/watch_service.py +412 -135
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +388 -28
- basic_memory-0.16.1.dist-info/METADATA +493 -0
- basic_memory-0.16.1.dist-info/RECORD +148 -0
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -203
- basic_memory/mcp/tools/knowledge.py +0 -56
- basic_memory/mcp/tools/memory.py +0 -151
- basic_memory/mcp/tools/notes.py +0 -122
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -34
- basic_memory-0.2.12.dist-info/METADATA +0 -291
- basic_memory-0.2.12.dist-info/RECORD +0 -78
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Continuing conversation on: {{ topic }}
|
|
2
|
+
|
|
3
|
+
This is a memory retrieval session.
|
|
4
|
+
|
|
5
|
+
Please use the available basic-memory tools to gather relevant context before responding. Start by executing one of the suggested commands below to retrieve content.
|
|
6
|
+
|
|
7
|
+
> **Knowledge Capture Recommendation:** As you continue this conversation, actively look for opportunities to record new information, decisions, or insights that emerge. Use `write_note()` to document important context.
|
|
8
|
+
|
|
9
|
+
Here's what I found from previous conversations:
|
|
10
|
+
|
|
11
|
+
{{#if has_results}}
|
|
12
|
+
{{#each hierarchical_results}}
|
|
13
|
+
<memory>
|
|
14
|
+
--- memory://{{ primary_result.permalink }}
|
|
15
|
+
|
|
16
|
+
## {{ primary_result.title }}
|
|
17
|
+
- **Type**: {{ primary_result.type }}
|
|
18
|
+
- **Created**: {{date primary_result.created_at "%Y-%m-%d %H:%M"}}
|
|
19
|
+
|
|
20
|
+
{{#if primary_result.content}}
|
|
21
|
+
**Excerpt**:
|
|
22
|
+
<excerpt>
|
|
23
|
+
{{ primary_result.content }}
|
|
24
|
+
</excerpt>
|
|
25
|
+
{{/if}}
|
|
26
|
+
|
|
27
|
+
{{#if observations}}
|
|
28
|
+
## Observations
|
|
29
|
+
{{#each observations}}
|
|
30
|
+
<observation>
|
|
31
|
+
- [{{ category }}] {{ content }}
|
|
32
|
+
</observation>
|
|
33
|
+
{{/each}}
|
|
34
|
+
{{/if}}
|
|
35
|
+
|
|
36
|
+
You can read this document with: `read_note("{{ primary_result.permalink }}")`
|
|
37
|
+
|
|
38
|
+
{{#if related_results}}
|
|
39
|
+
## Related Context
|
|
40
|
+
|
|
41
|
+
{{#each related_results}}
|
|
42
|
+
<related>
|
|
43
|
+
- type: **{{ type }}**
|
|
44
|
+
- title: {{ title }}
|
|
45
|
+
|
|
46
|
+
{{#if permalink}}
|
|
47
|
+
You can view this document with: `read_note("{{ permalink }}")`
|
|
48
|
+
{{else}}
|
|
49
|
+
You can view this file with: `read_file("{{ file_path }}")`
|
|
50
|
+
{{/if}}
|
|
51
|
+
</related>
|
|
52
|
+
{{/each}}
|
|
53
|
+
{{/if}}
|
|
54
|
+
|
|
55
|
+
</memory>
|
|
56
|
+
{{/each}}
|
|
57
|
+
{{else}}
|
|
58
|
+
The supplied query did not return any information specifically on this topic.
|
|
59
|
+
|
|
60
|
+
## Opportunity to Capture New Knowledge!
|
|
61
|
+
|
|
62
|
+
This is an excellent chance to start documenting this topic:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
await write_note(
|
|
66
|
+
title="{{ topic }}",
|
|
67
|
+
content=f'''
|
|
68
|
+
# {{ topic }}
|
|
69
|
+
|
|
70
|
+
## Overview
|
|
71
|
+
[Summary of what we know about {{ topic }}]
|
|
72
|
+
|
|
73
|
+
## Key Points
|
|
74
|
+
[Main aspects or components of {{ topic }}]
|
|
75
|
+
|
|
76
|
+
## Observations
|
|
77
|
+
- [category] [First important observation about {{ topic }}]
|
|
78
|
+
- [category] [Second observation about {{ topic }}]
|
|
79
|
+
|
|
80
|
+
## Relations
|
|
81
|
+
- relates_to [[Related Topic]]
|
|
82
|
+
- part_of [[Broader Context]]
|
|
83
|
+
'''
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Other Options
|
|
88
|
+
|
|
89
|
+
Please use the available basic-memory tools to gather relevant context before responding.
|
|
90
|
+
You can also:
|
|
91
|
+
- Try a different search term
|
|
92
|
+
- Check recent activity with `recent_activity(timeframe="1w")`
|
|
93
|
+
{{/if}}
|
|
94
|
+
## Next Steps
|
|
95
|
+
<instructions>
|
|
96
|
+
You can:
|
|
97
|
+
- Explore more with: `search_notes("{{ topic }}")`
|
|
98
|
+
- See what's changed: `recent_activity(timeframe="{{default timeframe "7d"}}")`
|
|
99
|
+
- **Record new learnings or decisions from this conversation:** `write_note(folder="[Choose a folder]" title="[Create a meaningful title]", content="[Content with observations and relations]")`
|
|
100
|
+
|
|
101
|
+
## Knowledge Capture Recommendation
|
|
102
|
+
|
|
103
|
+
As you continue this conversation, **actively look for opportunities to:**
|
|
104
|
+
1. Record key information, decisions, or insights that emerge
|
|
105
|
+
2. Link new knowledge to existing topics
|
|
106
|
+
3. Suggest capturing important context when appropriate
|
|
107
|
+
4. Create forward references to topics that might be created later
|
|
108
|
+
|
|
109
|
+
Remember that capturing knowledge during conversations is one of the most valuable aspects of Basic Memory.
|
|
110
|
+
</instructions>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Search Results for: "{{ query }}"{{#if timeframe}} (after {{ timeframe }}){{/if}}
|
|
2
|
+
|
|
3
|
+
This is a memory search session.
|
|
4
|
+
Please use the available basic-memory tools to gather relevant context before responding.
|
|
5
|
+
I found {{ result_count }} result(s) that match your query.
|
|
6
|
+
|
|
7
|
+
{{#if has_results}}
|
|
8
|
+
Here are the most relevant results:
|
|
9
|
+
|
|
10
|
+
{{#each results}}
|
|
11
|
+
{{#if_cond (lt @index 5)}}
|
|
12
|
+
{{#dedent}}
|
|
13
|
+
## {{math @index "+" 1}}. {{ title }}
|
|
14
|
+
- **Type**: {{ type.value }}
|
|
15
|
+
{{#if metadata.created_at}}
|
|
16
|
+
- **Created**: {{date metadata.created_at "%Y-%m-%d %H:%M"}}
|
|
17
|
+
{{/if}}
|
|
18
|
+
- **Relevance Score**: {{round score 2}}
|
|
19
|
+
|
|
20
|
+
{{#if content}}
|
|
21
|
+
- **Excerpt**:
|
|
22
|
+
{{ content }}
|
|
23
|
+
{{/if}}
|
|
24
|
+
|
|
25
|
+
{{#if permalink}}
|
|
26
|
+
You can view this content with: `read_note("{{ permalink }}")`
|
|
27
|
+
Or explore its context with: `build_context("memory://{{ permalink }}")`
|
|
28
|
+
{{else}}
|
|
29
|
+
You can view this file with: `read_file("{{ file_path }}")`
|
|
30
|
+
{{/if}}
|
|
31
|
+
{{/dedent}}
|
|
32
|
+
{{/if_cond}}
|
|
33
|
+
{{/each}}
|
|
34
|
+
|
|
35
|
+
## Next Steps
|
|
36
|
+
|
|
37
|
+
You can:
|
|
38
|
+
- Refine your search: `search_notes("{{ query }} AND additional_term")`
|
|
39
|
+
- Exclude terms: `search_notes("{{ query }} NOT exclude_term")`
|
|
40
|
+
- View more results: `search_notes("{{ query }}", after_date=None)`
|
|
41
|
+
- Check recent activity: `recent_activity()`
|
|
42
|
+
|
|
43
|
+
## Synthesize and Capture Knowledge
|
|
44
|
+
|
|
45
|
+
Consider creating a new note that synthesizes what you've learned:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
await write_note(
|
|
49
|
+
title="Synthesis of {{capitalize query}} Information",
|
|
50
|
+
content='''
|
|
51
|
+
# Synthesis of {{capitalize query}} Information
|
|
52
|
+
|
|
53
|
+
## Overview
|
|
54
|
+
[Synthesis of the search results and your conversation]
|
|
55
|
+
|
|
56
|
+
## Key Insights
|
|
57
|
+
[Summary of main points learned from these results]
|
|
58
|
+
|
|
59
|
+
## Observations
|
|
60
|
+
- [insight] [Important observation from search results]
|
|
61
|
+
- [connection] [How this connects to other topics]
|
|
62
|
+
|
|
63
|
+
## Relations
|
|
64
|
+
- relates_to [[{{#if results.length}}{{#if results.0.title}}{{results.0.title}}{{else}}Related Topic{{/if}}{{else}}Related Topic{{/if}}]]
|
|
65
|
+
- extends [[Another Relevant Topic]]
|
|
66
|
+
'''
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Remember that capturing synthesized knowledge is one of the most valuable features of Basic Memory.
|
|
71
|
+
{{else}}
|
|
72
|
+
I couldn't find any results for this query.
|
|
73
|
+
|
|
74
|
+
## Opportunity to Capture Knowledge!
|
|
75
|
+
|
|
76
|
+
This is an excellent opportunity to create new knowledge on this topic. Consider:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
await write_note(
|
|
80
|
+
title="{{capitalize query}}",
|
|
81
|
+
content='''
|
|
82
|
+
# {{capitalize query}}
|
|
83
|
+
|
|
84
|
+
## Overview
|
|
85
|
+
[Summary of what we've discussed about {{ query }}]
|
|
86
|
+
|
|
87
|
+
## Observations
|
|
88
|
+
- [category] [First observation about {{ query }}]
|
|
89
|
+
- [category] [Second observation about {{ query }}]
|
|
90
|
+
|
|
91
|
+
## Relations
|
|
92
|
+
- relates_to [[Other Relevant Topic]]
|
|
93
|
+
'''
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Other Suggestions
|
|
98
|
+
- Try a different search term
|
|
99
|
+
- Broaden your search criteria
|
|
100
|
+
- Check recent activity with `recent_activity(timeframe="1w")`
|
|
101
|
+
{{/if}}
|
basic_memory/utils.py
CHANGED
|
@@ -1,26 +1,85 @@
|
|
|
1
1
|
"""Utility functions for basic-memory."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
|
|
5
|
+
import logging
|
|
4
6
|
import re
|
|
5
7
|
import sys
|
|
8
|
+
from datetime import datetime
|
|
6
9
|
from pathlib import Path
|
|
7
|
-
from typing import Optional, Union
|
|
10
|
+
from typing import Optional, Protocol, Union, runtime_checkable, List
|
|
8
11
|
|
|
9
12
|
from loguru import logger
|
|
10
13
|
from unidecode import unidecode
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
|
|
16
|
+
def normalize_project_path(path: str) -> str:
|
|
17
|
+
"""Normalize project path by stripping mount point prefix.
|
|
18
|
+
|
|
19
|
+
In cloud deployments, the S3 bucket is mounted at /app/data. We strip this
|
|
20
|
+
prefix from project paths to avoid leaking implementation details and to
|
|
21
|
+
ensure paths match the actual S3 bucket structure.
|
|
22
|
+
|
|
23
|
+
For local paths (including Windows paths), returns the path unchanged.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
path: Project path (e.g., "/app/data/basic-memory-llc" or "C:\\Users\\...")
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Normalized path (e.g., "/basic-memory-llc" or "C:\\Users\\...")
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
>>> normalize_project_path("/app/data/my-project")
|
|
33
|
+
'/my-project'
|
|
34
|
+
>>> normalize_project_path("/my-project")
|
|
35
|
+
'/my-project'
|
|
36
|
+
>>> normalize_project_path("app/data/my-project")
|
|
37
|
+
'/my-project'
|
|
38
|
+
>>> normalize_project_path("C:\\\\Users\\\\project")
|
|
39
|
+
'C:\\\\Users\\\\project'
|
|
40
|
+
"""
|
|
41
|
+
# Check if this is a Windows absolute path (e.g., C:\Users\...)
|
|
42
|
+
# Windows paths have a drive letter followed by a colon
|
|
43
|
+
if len(path) >= 2 and path[1] == ":":
|
|
44
|
+
# Windows absolute path - return unchanged
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
# Handle both absolute and relative Unix paths
|
|
48
|
+
normalized = path.lstrip("/")
|
|
49
|
+
if normalized.startswith("app/data/"):
|
|
50
|
+
normalized = normalized.removeprefix("app/data/")
|
|
51
|
+
|
|
52
|
+
# Ensure leading slash for Unix absolute paths
|
|
53
|
+
if not normalized.startswith("/"):
|
|
54
|
+
normalized = "/" + normalized
|
|
55
|
+
|
|
56
|
+
return normalized
|
|
13
57
|
|
|
14
58
|
|
|
15
|
-
|
|
59
|
+
@runtime_checkable
|
|
60
|
+
class PathLike(Protocol):
|
|
61
|
+
"""Protocol for objects that can be used as paths."""
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# In type annotations, use Union[Path, str] instead of FilePath for now
|
|
67
|
+
# This preserves compatibility with existing code while we migrate
|
|
68
|
+
FilePath = Union[Path, str]
|
|
69
|
+
|
|
70
|
+
# Disable the "Queue is full" warning
|
|
71
|
+
logging.getLogger("opentelemetry.sdk.metrics._internal.instrument").setLevel(logging.ERROR)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: bool = True) -> str:
|
|
16
75
|
"""Generate a stable permalink from a file path.
|
|
17
76
|
|
|
18
77
|
Args:
|
|
19
|
-
file_path: Original file path
|
|
78
|
+
file_path: Original file path (str, Path, or PathLike)
|
|
20
79
|
|
|
21
80
|
Returns:
|
|
22
81
|
Normalized permalink that matches validation rules. Converts spaces and underscores
|
|
23
|
-
to hyphens for consistency.
|
|
82
|
+
to hyphens for consistency. Preserves non-ASCII characters like Chinese.
|
|
24
83
|
|
|
25
84
|
Examples:
|
|
26
85
|
>>> generate_permalink("docs/My Feature.md")
|
|
@@ -29,27 +88,88 @@ def generate_permalink(file_path: Union[Path, str]) -> str:
|
|
|
29
88
|
'specs/api-v2'
|
|
30
89
|
>>> generate_permalink("design/unified_model_refactor.md")
|
|
31
90
|
'design/unified-model-refactor'
|
|
91
|
+
>>> generate_permalink("中文/测试文档.md")
|
|
92
|
+
'中文/测试文档'
|
|
32
93
|
"""
|
|
33
94
|
# Convert Path to string if needed
|
|
34
|
-
path_str = str(file_path)
|
|
95
|
+
path_str = Path(str(file_path)).as_posix()
|
|
35
96
|
|
|
36
|
-
# Remove extension
|
|
37
|
-
base = os.path.splitext(path_str)
|
|
97
|
+
# Remove extension (for now, possibly)
|
|
98
|
+
(base, extension) = os.path.splitext(path_str)
|
|
38
99
|
|
|
39
|
-
#
|
|
40
|
-
|
|
100
|
+
# Check if we have CJK characters that should be preserved
|
|
101
|
+
# CJK ranges: \u4e00-\u9fff (CJK Unified Ideographs), \u3000-\u303f (CJK symbols),
|
|
102
|
+
# \u3400-\u4dbf (CJK Extension A), \uff00-\uffef (Fullwidth forms)
|
|
103
|
+
has_cjk_chars = any(
|
|
104
|
+
"\u4e00" <= char <= "\u9fff"
|
|
105
|
+
or "\u3000" <= char <= "\u303f"
|
|
106
|
+
or "\u3400" <= char <= "\u4dbf"
|
|
107
|
+
or "\uff00" <= char <= "\uffef"
|
|
108
|
+
for char in base
|
|
109
|
+
)
|
|
41
110
|
|
|
42
|
-
|
|
43
|
-
|
|
111
|
+
if has_cjk_chars:
|
|
112
|
+
# For text with CJK characters, selectively transliterate only Latin accented chars
|
|
113
|
+
result = ""
|
|
114
|
+
for char in base:
|
|
115
|
+
if (
|
|
116
|
+
"\u4e00" <= char <= "\u9fff"
|
|
117
|
+
or "\u3000" <= char <= "\u303f"
|
|
118
|
+
or "\u3400" <= char <= "\u4dbf"
|
|
119
|
+
):
|
|
120
|
+
# Preserve CJK ideographs and symbols
|
|
121
|
+
result += char
|
|
122
|
+
elif "\uff00" <= char <= "\uffef":
|
|
123
|
+
# Remove Chinese fullwidth punctuation entirely (like ,!?)
|
|
124
|
+
continue
|
|
125
|
+
else:
|
|
126
|
+
# Transliterate Latin accented characters to ASCII
|
|
127
|
+
result += unidecode(char)
|
|
44
128
|
|
|
45
|
-
|
|
46
|
-
|
|
129
|
+
# Insert hyphens between CJK and Latin character transitions
|
|
130
|
+
# Match: CJK followed by Latin letter/digit, or Latin letter/digit followed by CJK
|
|
131
|
+
result = re.sub(
|
|
132
|
+
r"([\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf])([a-zA-Z0-9])", r"\1-\2", result
|
|
133
|
+
)
|
|
134
|
+
result = re.sub(
|
|
135
|
+
r"([a-zA-Z0-9])([\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf])", r"\1-\2", result
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Insert dash between camelCase
|
|
139
|
+
result = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", result)
|
|
140
|
+
|
|
141
|
+
# Convert ASCII letters to lowercase, preserve CJK
|
|
142
|
+
lower_text = "".join(c.lower() if c.isascii() and c.isalpha() else c for c in result)
|
|
47
143
|
|
|
48
|
-
|
|
49
|
-
|
|
144
|
+
# Replace underscores with hyphens
|
|
145
|
+
text_with_hyphens = lower_text.replace("_", "-")
|
|
146
|
+
|
|
147
|
+
# Remove apostrophes entirely (don't replace with hyphens)
|
|
148
|
+
text_no_apostrophes = text_with_hyphens.replace("'", "")
|
|
149
|
+
|
|
150
|
+
# Replace unsafe chars with hyphens, but preserve CJK characters
|
|
151
|
+
clean_text = re.sub(
|
|
152
|
+
r"[^a-z0-9\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf/\-]", "-", text_no_apostrophes
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
# Original ASCII-only processing for backward compatibility
|
|
156
|
+
# Transliterate unicode to ascii
|
|
157
|
+
ascii_text = unidecode(base)
|
|
50
158
|
|
|
51
|
-
|
|
52
|
-
|
|
159
|
+
# Insert dash between camelCase
|
|
160
|
+
ascii_text = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", ascii_text)
|
|
161
|
+
|
|
162
|
+
# Convert to lowercase
|
|
163
|
+
lower_text = ascii_text.lower()
|
|
164
|
+
|
|
165
|
+
# replace underscores with hyphens
|
|
166
|
+
text_with_hyphens = lower_text.replace("_", "-")
|
|
167
|
+
|
|
168
|
+
# Remove apostrophes entirely (don't replace with hyphens)
|
|
169
|
+
text_no_apostrophes = text_with_hyphens.replace("'", "")
|
|
170
|
+
|
|
171
|
+
# Replace remaining invalid chars with hyphens
|
|
172
|
+
clean_text = re.sub(r"[^a-z0-9/\-]", "-", text_no_apostrophes)
|
|
53
173
|
|
|
54
174
|
# Collapse multiple hyphens
|
|
55
175
|
clean_text = re.sub(r"-+", "-", clean_text)
|
|
@@ -58,24 +178,43 @@ def generate_permalink(file_path: Union[Path, str]) -> str:
|
|
|
58
178
|
segments = clean_text.split("/")
|
|
59
179
|
clean_segments = [s.strip("-") for s in segments]
|
|
60
180
|
|
|
61
|
-
|
|
181
|
+
return_val = "/".join(clean_segments)
|
|
62
182
|
|
|
183
|
+
# Append file extension back, if necessary
|
|
184
|
+
if not split_extension and extension:
|
|
185
|
+
return_val += extension
|
|
63
186
|
|
|
64
|
-
|
|
187
|
+
return return_val
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def setup_logging(
|
|
191
|
+
env: str,
|
|
192
|
+
home_dir: Path,
|
|
193
|
+
log_file: Optional[str] = None,
|
|
194
|
+
log_level: str = "INFO",
|
|
195
|
+
console: bool = True,
|
|
196
|
+
) -> None: # pragma: no cover
|
|
65
197
|
"""
|
|
66
198
|
Configure logging for the application.
|
|
67
|
-
"""
|
|
68
199
|
|
|
200
|
+
Args:
|
|
201
|
+
env: The environment name (dev, test, prod)
|
|
202
|
+
home_dir: The root directory for the application
|
|
203
|
+
log_file: The name of the log file to write to
|
|
204
|
+
log_level: The logging level to use
|
|
205
|
+
console: Whether to log to the console
|
|
206
|
+
"""
|
|
69
207
|
# Remove default handler and any existing handlers
|
|
70
208
|
logger.remove()
|
|
71
209
|
|
|
72
|
-
# Add file handler
|
|
73
|
-
if log_file:
|
|
210
|
+
# Add file handler if we are not running tests and a log file is specified
|
|
211
|
+
if log_file and env != "test":
|
|
212
|
+
# Setup file logger
|
|
74
213
|
log_path = home_dir / log_file
|
|
75
214
|
logger.add(
|
|
76
|
-
str(log_path),
|
|
77
|
-
level=
|
|
78
|
-
rotation="
|
|
215
|
+
str(log_path),
|
|
216
|
+
level=log_level,
|
|
217
|
+
rotation="10 MB",
|
|
79
218
|
retention="10 days",
|
|
80
219
|
backtrace=True,
|
|
81
220
|
diagnose=True,
|
|
@@ -83,5 +222,226 @@ def setup_logging(home_dir: Path = config.home, log_file: Optional[str] = None)
|
|
|
83
222
|
colorize=False,
|
|
84
223
|
)
|
|
85
224
|
|
|
86
|
-
# Add
|
|
87
|
-
|
|
225
|
+
# Add console logger if requested or in test mode
|
|
226
|
+
if env == "test" or console:
|
|
227
|
+
logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
|
|
228
|
+
|
|
229
|
+
logger.info(f"ENV: '{env}' Log level: '{log_level}' Logging to {log_file}")
|
|
230
|
+
|
|
231
|
+
# Bind environment context for structured logging (works in both local and cloud)
|
|
232
|
+
tenant_id = os.getenv("BASIC_MEMORY_TENANT_ID", "local")
|
|
233
|
+
fly_app_name = os.getenv("FLY_APP_NAME", "local")
|
|
234
|
+
fly_machine_id = os.getenv("FLY_MACHINE_ID", "local")
|
|
235
|
+
fly_region = os.getenv("FLY_REGION", "local")
|
|
236
|
+
|
|
237
|
+
logger.configure(
|
|
238
|
+
extra={
|
|
239
|
+
"tenant_id": tenant_id,
|
|
240
|
+
"fly_app_name": fly_app_name,
|
|
241
|
+
"fly_machine_id": fly_machine_id,
|
|
242
|
+
"fly_region": fly_region,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Reduce noise from third-party libraries
|
|
247
|
+
noisy_loggers = {
|
|
248
|
+
# HTTP client logs
|
|
249
|
+
"httpx": logging.WARNING,
|
|
250
|
+
# File watching logs
|
|
251
|
+
"watchfiles.main": logging.WARNING,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# Set log levels for noisy loggers
|
|
255
|
+
for logger_name, level in noisy_loggers.items():
|
|
256
|
+
logging.getLogger(logger_name).setLevel(level)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
|
|
260
|
+
"""Parse tags from various input formats into a consistent list.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
tags: Can be a list of strings, a comma-separated string, or None
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
A list of tag strings, or an empty list if no tags
|
|
267
|
+
|
|
268
|
+
Note:
|
|
269
|
+
This function strips leading '#' characters from tags to prevent
|
|
270
|
+
their accumulation when tags are processed multiple times.
|
|
271
|
+
"""
|
|
272
|
+
if tags is None:
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
# Process list of tags
|
|
276
|
+
if isinstance(tags, list):
|
|
277
|
+
# First strip whitespace, then strip leading '#' characters to prevent accumulation
|
|
278
|
+
return [tag.strip().lstrip("#") for tag in tags if tag and tag.strip()]
|
|
279
|
+
|
|
280
|
+
# Process string input
|
|
281
|
+
if isinstance(tags, str):
|
|
282
|
+
# Check if it's a JSON array string (common issue from AI assistants)
|
|
283
|
+
import json
|
|
284
|
+
|
|
285
|
+
if tags.strip().startswith("[") and tags.strip().endswith("]"):
|
|
286
|
+
try:
|
|
287
|
+
# Try to parse as JSON array
|
|
288
|
+
parsed_json = json.loads(tags)
|
|
289
|
+
if isinstance(parsed_json, list):
|
|
290
|
+
# Recursively parse the JSON array as a list
|
|
291
|
+
return parse_tags(parsed_json)
|
|
292
|
+
except json.JSONDecodeError:
|
|
293
|
+
# Not valid JSON, fall through to comma-separated parsing
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
# Split by comma, strip whitespace, then strip leading '#' characters
|
|
297
|
+
return [tag.strip().lstrip("#") for tag in tags.split(",") if tag and tag.strip()]
|
|
298
|
+
|
|
299
|
+
# For any other type, try to convert to string and parse
|
|
300
|
+
try: # pragma: no cover
|
|
301
|
+
return parse_tags(str(tags))
|
|
302
|
+
except (ValueError, TypeError): # pragma: no cover
|
|
303
|
+
logger.warning(f"Couldn't parse tags from input of type {type(tags)}: {tags}")
|
|
304
|
+
return []
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def normalize_newlines(multiline: str) -> str:
|
|
308
|
+
"""Replace any \r\n, \r, or \n with the native newline.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
multiline: String containing any mixture of newlines.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
A string with normalized newlines native to the platform.
|
|
315
|
+
"""
|
|
316
|
+
return re.sub(r"\r\n?|\n", os.linesep, multiline)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def normalize_file_path_for_comparison(file_path: str) -> str:
|
|
320
|
+
"""Normalize a file path for conflict detection.
|
|
321
|
+
|
|
322
|
+
This function normalizes file paths to help detect potential conflicts:
|
|
323
|
+
- Converts to lowercase for case-insensitive comparison
|
|
324
|
+
- Normalizes Unicode characters
|
|
325
|
+
- Handles path separators consistently
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
file_path: The file path to normalize
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
Normalized file path for comparison purposes
|
|
332
|
+
"""
|
|
333
|
+
import unicodedata
|
|
334
|
+
|
|
335
|
+
# Convert to lowercase for case-insensitive comparison
|
|
336
|
+
normalized = file_path.lower()
|
|
337
|
+
|
|
338
|
+
# Normalize Unicode characters (NFD normalization)
|
|
339
|
+
normalized = unicodedata.normalize("NFD", normalized)
|
|
340
|
+
|
|
341
|
+
# Replace path separators with forward slashes
|
|
342
|
+
normalized = normalized.replace("\\", "/")
|
|
343
|
+
|
|
344
|
+
# Remove multiple slashes
|
|
345
|
+
normalized = re.sub(r"/+", "/", normalized)
|
|
346
|
+
|
|
347
|
+
return normalized
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def detect_potential_file_conflicts(file_path: str, existing_paths: List[str]) -> List[str]:
|
|
351
|
+
"""Detect potential conflicts between a file path and existing paths.
|
|
352
|
+
|
|
353
|
+
This function checks for various types of conflicts:
|
|
354
|
+
- Case sensitivity differences
|
|
355
|
+
- Unicode normalization differences
|
|
356
|
+
- Path separator differences
|
|
357
|
+
- Permalink generation conflicts
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
file_path: The file path to check
|
|
361
|
+
existing_paths: List of existing file paths to check against
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
List of existing paths that might conflict with the given file path
|
|
365
|
+
"""
|
|
366
|
+
conflicts = []
|
|
367
|
+
|
|
368
|
+
# Normalize the input file path
|
|
369
|
+
normalized_input = normalize_file_path_for_comparison(file_path)
|
|
370
|
+
input_permalink = generate_permalink(file_path)
|
|
371
|
+
|
|
372
|
+
for existing_path in existing_paths:
|
|
373
|
+
# Skip identical paths
|
|
374
|
+
if existing_path == file_path:
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
# Check for case-insensitive path conflicts
|
|
378
|
+
normalized_existing = normalize_file_path_for_comparison(existing_path)
|
|
379
|
+
if normalized_input == normalized_existing:
|
|
380
|
+
conflicts.append(existing_path)
|
|
381
|
+
continue
|
|
382
|
+
|
|
383
|
+
# Check for permalink conflicts
|
|
384
|
+
existing_permalink = generate_permalink(existing_path)
|
|
385
|
+
if input_permalink == existing_permalink:
|
|
386
|
+
conflicts.append(existing_path)
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
return conflicts
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def valid_project_path_value(path: str):
|
|
393
|
+
"""Ensure project path is valid."""
|
|
394
|
+
# Allow empty strings as they resolve to the project root
|
|
395
|
+
if not path:
|
|
396
|
+
return True
|
|
397
|
+
|
|
398
|
+
# Check for obvious path traversal patterns first
|
|
399
|
+
if ".." in path or "~" in path:
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
# Check for Windows-style path traversal (even on Unix systems)
|
|
403
|
+
if "\\.." in path or path.startswith("\\"):
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
# Block absolute paths (Unix-style starting with / or Windows-style with drive letters)
|
|
407
|
+
if path.startswith("/") or (len(path) >= 2 and path[1] == ":"):
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
# Block paths with control characters (but allow whitespace that will be stripped)
|
|
411
|
+
if path.strip() and any(ord(c) < 32 and c not in [" ", "\t"] for c in path):
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def validate_project_path(path: str, project_path: Path) -> bool:
|
|
418
|
+
"""Ensure path is valid and stays within project boundaries."""
|
|
419
|
+
|
|
420
|
+
if not valid_project_path_value(path):
|
|
421
|
+
return False
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
resolved = (project_path / path).resolve()
|
|
425
|
+
return resolved.is_relative_to(project_path.resolve())
|
|
426
|
+
except (ValueError, OSError):
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def ensure_timezone_aware(dt: datetime) -> datetime:
|
|
431
|
+
"""Ensure a datetime is timezone-aware using system timezone.
|
|
432
|
+
|
|
433
|
+
If the datetime is naive, convert it to timezone-aware using the system's local timezone.
|
|
434
|
+
If it's already timezone-aware, return it unchanged.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
dt: The datetime to ensure is timezone-aware
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
A timezone-aware datetime
|
|
441
|
+
"""
|
|
442
|
+
if dt.tzinfo is None:
|
|
443
|
+
# Naive datetime - assume it's in local time and add timezone
|
|
444
|
+
return dt.astimezone()
|
|
445
|
+
else:
|
|
446
|
+
# Already timezone-aware
|
|
447
|
+
return dt
|