@archznn/crewloop-skills 0.2.0 → 0.4.0
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.
- package/README.md +21 -31
- package/assets/templates/skill-template.md +58 -0
- package/package.json +5 -1
- package/references/conventions.md +144 -0
- package/references/obsidian-mcp-usage.md +190 -0
- package/references/skill-anatomy.md +77 -0
- package/references/workflow.md +64 -0
- package/servers/dashboard/README.md +87 -0
- package/servers/dashboard/bin/crewloop-dashboard.js +5 -0
- package/servers/dashboard/config-examples/codex-hooks.json +14 -0
- package/servers/dashboard/config-examples/kimi-code-config.toml +6 -0
- package/servers/dashboard/config-examples/opencode-plugin/crewloop-dashboard.js +64 -0
- package/servers/dashboard/package.json +46 -0
- package/servers/dashboard/public/app.js +447 -0
- package/servers/dashboard/public/index.html +96 -0
- package/servers/dashboard/public/styles.css +664 -0
- package/servers/dashboard/src/adapters/codex.ts +50 -0
- package/servers/dashboard/src/adapters/kimi.ts +40 -0
- package/servers/dashboard/src/adapters/opencode.ts +36 -0
- package/servers/dashboard/src/adapters/shim.test.ts +74 -0
- package/servers/dashboard/src/adapters/shim.ts +120 -0
- package/servers/dashboard/src/api/event.ts +70 -0
- package/servers/dashboard/src/api/skills.ts +11 -0
- package/servers/dashboard/src/config.ts +66 -0
- package/servers/dashboard/src/filters/sanitize.test.ts +94 -0
- package/servers/dashboard/src/filters/sanitize.ts +78 -0
- package/servers/dashboard/src/index.ts +24 -0
- package/servers/dashboard/src/presenter.test.ts +69 -0
- package/servers/dashboard/src/presenter.ts +56 -0
- package/servers/dashboard/src/server.test.ts +123 -0
- package/servers/dashboard/src/server.ts +191 -0
- package/servers/dashboard/src/skills/infer.test.ts +86 -0
- package/servers/dashboard/src/skills/infer.ts +53 -0
- package/servers/dashboard/src/skills/mapping.ts +26 -0
- package/servers/dashboard/src/skills/registry.ts +60 -0
- package/servers/dashboard/src/state.test.ts +88 -0
- package/servers/dashboard/src/state.ts +115 -0
- package/servers/dashboard/src/types.ts +110 -0
- package/servers/dashboard/tsconfig.json +19 -0
- package/servers/obsidian-mcp/README.md +82 -0
- package/servers/obsidian-mcp/pyproject.toml +32 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/config.py +47 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/embeddings.py +105 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/indexer.py +79 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/store.py +141 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/sync.py +37 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/detector.py +66 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/note_generator.py +40 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/main.py +4 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/models.py +42 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/privacy/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/privacy/filter.py +68 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/engine.py +50 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/graph_search.py +55 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/text_search.py +37 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/vector_search.py +118 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/server.py +61 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/create.py +43 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/delete.py +16 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/learn.py +42 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/list.py +16 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/read.py +15 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/registry.py +130 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/related.py +20 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/search.py +26 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/sync.py +22 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/update.py +34 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/parser.py +82 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/repository.py +68 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/writer.py +61 -0
- package/servers/obsidian-mcp/tests/conftest.py +39 -0
- package/servers/obsidian-mcp/tests/test_async_tools.py +87 -0
- package/servers/obsidian-mcp/tests/test_edge_cases.py +59 -0
- package/servers/obsidian-mcp/tests/test_indexer.py +27 -0
- package/servers/obsidian-mcp/tests/test_integration.py +90 -0
- package/servers/obsidian-mcp/tests/test_learning.py +34 -0
- package/servers/obsidian-mcp/tests/test_privacy.py +31 -0
- package/servers/obsidian-mcp/tests/test_privacy_config.py +44 -0
- package/servers/obsidian-mcp/tests/test_rag.py +64 -0
- package/servers/obsidian-mcp/tests/test_read_raw.py +37 -0
- package/servers/obsidian-mcp/tests/test_tfidf_fallback.py +54 -0
- package/servers/obsidian-mcp/tests/test_tools.py +108 -0
- package/servers/obsidian-mcp/tests/test_vault.py +103 -0
- package/servers/obsidian-mcp/tests/test_writer.py +139 -0
- package/skills/accessibility-auditor/SKILL.md +262 -0
- package/skills/accessibility-auditor/references/a11y-checklist.md +66 -0
- package/skills/architect/SKILL.md +1 -1
- package/skills/designer/SKILL.md +1 -1
- package/skills/docs-writer/SKILL.md +1 -1
- package/skills/engineer/SKILL.md +1 -1
- package/skills/maintainer/SKILL.md +22 -22
- package/skills/obsidian-second-brain/SKILL.md +48 -13
- package/skills/orchestrator/SKILL.md +1 -1
- package/skills/product-manager/SKILL.md +22 -22
- package/skills/researcher/SKILL.md +22 -22
- package/skills/reviewer/SKILL.md +1 -1
- package/skills/security-guard/SKILL.md +142 -0
- package/skills/security-guard/references/security-checklist.md +57 -0
- package/skills/shipper/SKILL.md +1 -1
- package/skills/tester/SKILL.md +22 -22
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from mcp import ErrorData, McpError
|
|
7
|
+
from mcp.server import Server
|
|
8
|
+
from mcp.server.stdio import stdio_server
|
|
9
|
+
from mcp.types import TextContent, Tool
|
|
10
|
+
|
|
11
|
+
from obsidian_mcp.config import Config
|
|
12
|
+
from obsidian_mcp.tools.registry import TOOLS, dispatch_async
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _error_code_for(exc: Exception) -> int:
|
|
18
|
+
if isinstance(exc, (ValueError, FileExistsError)):
|
|
19
|
+
return -32600
|
|
20
|
+
if isinstance(exc, FileNotFoundError):
|
|
21
|
+
return -32602
|
|
22
|
+
return -32603
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def serve(config: Config | None = None):
|
|
26
|
+
config = config or Config()
|
|
27
|
+
server = Server("obsidian-mcp")
|
|
28
|
+
|
|
29
|
+
@server.list_tools()
|
|
30
|
+
async def list_tools() -> list[Tool]:
|
|
31
|
+
return [
|
|
32
|
+
Tool(name=name, description=meta["description"], inputSchema=meta["input_schema"])
|
|
33
|
+
for name, meta in TOOLS.items()
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
@server.call_tool()
|
|
37
|
+
async def call_tool(name: str, arguments: dict | None) -> list[TextContent]:
|
|
38
|
+
arguments = arguments or {}
|
|
39
|
+
start = time.perf_counter()
|
|
40
|
+
logger.info("tool start: %s", name)
|
|
41
|
+
try:
|
|
42
|
+
result = await dispatch_async(name, arguments, config)
|
|
43
|
+
if isinstance(result, str):
|
|
44
|
+
return [TextContent(type="text", text=result)]
|
|
45
|
+
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
logger.exception("tool error: %s", name)
|
|
48
|
+
raise McpError(
|
|
49
|
+
ErrorData(code=_error_code_for(exc), message=str(exc))
|
|
50
|
+
) from exc
|
|
51
|
+
finally:
|
|
52
|
+
elapsed = time.perf_counter() - start
|
|
53
|
+
logger.info("tool end: %s (%.3fs)", name, elapsed)
|
|
54
|
+
|
|
55
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
56
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main():
|
|
60
|
+
logging.basicConfig(level=logging.INFO)
|
|
61
|
+
asyncio.run(serve())
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from obsidian_mcp.config import Config
|
|
5
|
+
from obsidian_mcp.models import Note
|
|
6
|
+
from obsidian_mcp.privacy.filter import PrivacyFilter
|
|
7
|
+
from obsidian_mcp.vault.repository import VaultRepository
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def handle_create_note(arguments: dict, config: Config) -> dict:
|
|
13
|
+
path = arguments.get("path")
|
|
14
|
+
content = arguments.get("content", "")
|
|
15
|
+
title = arguments.get("title")
|
|
16
|
+
tags = arguments.get("tags", [])
|
|
17
|
+
overwrite = bool(arguments.get("overwrite", False))
|
|
18
|
+
if not path:
|
|
19
|
+
raise ValueError("path is required")
|
|
20
|
+
|
|
21
|
+
if not path.endswith(".md"):
|
|
22
|
+
path = path + ".md"
|
|
23
|
+
|
|
24
|
+
PrivacyFilter(config).validate(path)
|
|
25
|
+
PrivacyFilter(config).validate(content)
|
|
26
|
+
|
|
27
|
+
vault = VaultRepository(config)
|
|
28
|
+
if vault.exists(path) and not overwrite:
|
|
29
|
+
raise FileExistsError(f"note already exists: {path}")
|
|
30
|
+
|
|
31
|
+
note = Note(
|
|
32
|
+
path=path,
|
|
33
|
+
title=title or _title_from_path(path),
|
|
34
|
+
content=content,
|
|
35
|
+
tags=tags,
|
|
36
|
+
)
|
|
37
|
+
vault.save(note)
|
|
38
|
+
logger.info("created note: %s", path)
|
|
39
|
+
return {"status": "created", "path": path}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _title_from_path(path: str) -> str:
|
|
43
|
+
return Path(path).stem.replace("-", " ").replace("_", " ").title()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from obsidian_mcp.config import Config
|
|
4
|
+
from obsidian_mcp.vault.repository import VaultRepository
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def handle_delete_note(arguments: dict, config: Config) -> dict:
|
|
10
|
+
path = arguments.get("path")
|
|
11
|
+
if not path:
|
|
12
|
+
raise ValueError("path is required")
|
|
13
|
+
vault = VaultRepository(config)
|
|
14
|
+
vault.delete(path)
|
|
15
|
+
logger.info("deleted note: %s", path)
|
|
16
|
+
return {"status": "deleted", "path": path}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from obsidian_mcp.config import Config
|
|
4
|
+
from obsidian_mcp.indexer.indexer import Indexer
|
|
5
|
+
from obsidian_mcp.indexer.store import IndexStore
|
|
6
|
+
from obsidian_mcp.learning.detector import LearningDetector
|
|
7
|
+
from obsidian_mcp.learning.note_generator import NoteGenerator
|
|
8
|
+
from obsidian_mcp.privacy.filter import PrivacyFilter
|
|
9
|
+
from obsidian_mcp.vault.repository import VaultRepository
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def handle_learn_from_text(arguments: dict, config: Config) -> dict:
|
|
15
|
+
text = arguments.get("text", "")
|
|
16
|
+
if not text:
|
|
17
|
+
raise ValueError("text is required")
|
|
18
|
+
|
|
19
|
+
PrivacyFilter(config).validate(text)
|
|
20
|
+
|
|
21
|
+
detector = LearningDetector(config)
|
|
22
|
+
learnings = detector.detect(text)
|
|
23
|
+
if not learnings:
|
|
24
|
+
return {"status": "no_learning_detected"}
|
|
25
|
+
|
|
26
|
+
vault = VaultRepository(config)
|
|
27
|
+
generator = NoteGenerator(config, vault)
|
|
28
|
+
indexer = Indexer(config, vault, IndexStore(config.index_dir / "index.db"))
|
|
29
|
+
|
|
30
|
+
created = []
|
|
31
|
+
for learning in learnings:
|
|
32
|
+
if vault.exists(generator.path_for(learning)):
|
|
33
|
+
continue
|
|
34
|
+
note = generator.generate_and_save(learning)
|
|
35
|
+
indexer.index_note(note)
|
|
36
|
+
created.append(note.path)
|
|
37
|
+
|
|
38
|
+
if not created:
|
|
39
|
+
logger.info("no new learnings created from text")
|
|
40
|
+
return {"status": "duplicate", "created_notes": []}
|
|
41
|
+
logger.info("learned from text, created notes: %s", created)
|
|
42
|
+
return {"status": "learned", "created_notes": created}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from obsidian_mcp.config import Config
|
|
4
|
+
from obsidian_mcp.vault.repository import VaultRepository
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def handle_list_notes(arguments: dict, config: Config) -> str:
|
|
10
|
+
folder = arguments.get("folder")
|
|
11
|
+
vault = VaultRepository(config)
|
|
12
|
+
notes = vault.list_notes(folder)
|
|
13
|
+
logger.debug("listed %d notes in folder %s", len(notes), folder)
|
|
14
|
+
if not notes:
|
|
15
|
+
return "No notes found."
|
|
16
|
+
return "\n".join(f"- {n}" for n in notes)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from obsidian_mcp.config import Config
|
|
4
|
+
from obsidian_mcp.vault.repository import VaultRepository
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def handle_read_note(arguments: dict, config: Config) -> str:
|
|
10
|
+
path = arguments.get("path")
|
|
11
|
+
if not path:
|
|
12
|
+
raise ValueError("path is required")
|
|
13
|
+
vault = VaultRepository(config)
|
|
14
|
+
logger.debug("reading note: %s", path)
|
|
15
|
+
return vault.read_raw(path)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from obsidian_mcp.config import Config
|
|
4
|
+
from obsidian_mcp.tools.create import handle_create_note
|
|
5
|
+
from obsidian_mcp.tools.delete import handle_delete_note
|
|
6
|
+
from obsidian_mcp.tools.learn import handle_learn_from_text
|
|
7
|
+
from obsidian_mcp.tools.list import handle_list_notes
|
|
8
|
+
from obsidian_mcp.tools.read import handle_read_note
|
|
9
|
+
from obsidian_mcp.tools.related import handle_get_related_notes
|
|
10
|
+
from obsidian_mcp.tools.search import handle_search_notes
|
|
11
|
+
from obsidian_mcp.tools.sync import handle_sync_from_bundle
|
|
12
|
+
from obsidian_mcp.tools.update import handle_update_note
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TOOLS = {
|
|
16
|
+
"read_note": {
|
|
17
|
+
"description": "Read a note from the Obsidian vault.",
|
|
18
|
+
"input_schema": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {"path": {"type": "string"}},
|
|
21
|
+
"required": ["path"],
|
|
22
|
+
},
|
|
23
|
+
"handler": handle_read_note,
|
|
24
|
+
},
|
|
25
|
+
"search_notes": {
|
|
26
|
+
"description": "Search notes by text, vector, graph or hybrid.",
|
|
27
|
+
"input_schema": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"query": {"type": "string"},
|
|
31
|
+
"mode": {"type": "string", "enum": ["text", "vector", "graph", "hybrid"]},
|
|
32
|
+
"limit": {"type": "integer", "default": 10},
|
|
33
|
+
},
|
|
34
|
+
"required": ["query"],
|
|
35
|
+
},
|
|
36
|
+
"handler": handle_search_notes,
|
|
37
|
+
},
|
|
38
|
+
"create_note": {
|
|
39
|
+
"description": "Create a new note in the vault.",
|
|
40
|
+
"input_schema": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": {
|
|
43
|
+
"path": {"type": "string"},
|
|
44
|
+
"content": {"type": "string"},
|
|
45
|
+
"title": {"type": "string"},
|
|
46
|
+
"tags": {"type": "array", "items": {"type": "string"}},
|
|
47
|
+
"overwrite": {"type": "boolean", "default": False},
|
|
48
|
+
},
|
|
49
|
+
"required": ["path"],
|
|
50
|
+
},
|
|
51
|
+
"handler": handle_create_note,
|
|
52
|
+
},
|
|
53
|
+
"update_note": {
|
|
54
|
+
"description": "Update or append content to an existing note.",
|
|
55
|
+
"input_schema": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"path": {"type": "string"},
|
|
59
|
+
"content": {"type": "string"},
|
|
60
|
+
"append": {"type": "string"},
|
|
61
|
+
"tags": {"type": "array", "items": {"type": "string"}},
|
|
62
|
+
},
|
|
63
|
+
"required": ["path"],
|
|
64
|
+
},
|
|
65
|
+
"handler": handle_update_note,
|
|
66
|
+
},
|
|
67
|
+
"delete_note": {
|
|
68
|
+
"description": "Delete a note from the vault.",
|
|
69
|
+
"input_schema": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {"path": {"type": "string"}},
|
|
72
|
+
"required": ["path"],
|
|
73
|
+
},
|
|
74
|
+
"handler": handle_delete_note,
|
|
75
|
+
},
|
|
76
|
+
"list_notes": {
|
|
77
|
+
"description": "List notes in the vault.",
|
|
78
|
+
"input_schema": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"properties": {"folder": {"type": "string"}},
|
|
81
|
+
},
|
|
82
|
+
"handler": handle_list_notes,
|
|
83
|
+
},
|
|
84
|
+
"get_related_notes": {
|
|
85
|
+
"description": "Get notes related by links and graph traversal.",
|
|
86
|
+
"input_schema": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"properties": {
|
|
89
|
+
"path": {"type": "string"},
|
|
90
|
+
"depth": {"type": "integer", "default": 1},
|
|
91
|
+
},
|
|
92
|
+
"required": ["path"],
|
|
93
|
+
},
|
|
94
|
+
"handler": handle_get_related_notes,
|
|
95
|
+
},
|
|
96
|
+
"sync_from_bundle": {
|
|
97
|
+
"description": "Re-index the loop-engineering-agents bundle and local vault.",
|
|
98
|
+
"input_schema": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"properties": {"force": {"type": "boolean", "default": False}},
|
|
101
|
+
},
|
|
102
|
+
"handler": handle_sync_from_bundle,
|
|
103
|
+
},
|
|
104
|
+
"learn_from_text": {
|
|
105
|
+
"description": "Detect learnings in text and create notes automatically.",
|
|
106
|
+
"input_schema": {
|
|
107
|
+
"type": "object",
|
|
108
|
+
"properties": {"text": {"type": "string"}},
|
|
109
|
+
"required": ["text"],
|
|
110
|
+
},
|
|
111
|
+
"handler": handle_learn_from_text,
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def dispatch(name: str, arguments: dict, config: Config):
|
|
117
|
+
tool = TOOLS.get(name)
|
|
118
|
+
if tool is None:
|
|
119
|
+
raise ValueError(f"unknown tool: {name}")
|
|
120
|
+
return tool["handler"](arguments, config)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def dispatch_async(name: str, arguments: dict, config: Config):
|
|
124
|
+
tool = TOOLS.get(name)
|
|
125
|
+
if tool is None:
|
|
126
|
+
raise ValueError(f"unknown tool: {name}")
|
|
127
|
+
handler = tool["handler"]
|
|
128
|
+
if asyncio.iscoroutinefunction(handler):
|
|
129
|
+
return await handler(arguments, config)
|
|
130
|
+
return await asyncio.to_thread(handler, arguments, config)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from obsidian_mcp.config import Config
|
|
4
|
+
from obsidian_mcp.indexer.store import IndexStore
|
|
5
|
+
from obsidian_mcp.rag.engine import RAGEngine
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def handle_get_related_notes(arguments: dict, config: Config) -> str:
|
|
11
|
+
path = arguments.get("path")
|
|
12
|
+
if not path:
|
|
13
|
+
raise ValueError("path is required")
|
|
14
|
+
depth = int(arguments.get("depth", 1))
|
|
15
|
+
logger.info("getting related notes: path=%s depth=%d", path, depth)
|
|
16
|
+
engine = RAGEngine(config, IndexStore(config.index_dir / "index.db"))
|
|
17
|
+
results = engine.related(path, depth=depth)
|
|
18
|
+
if not results:
|
|
19
|
+
return "No related notes found."
|
|
20
|
+
return "\n".join(f"- **{r.note_path}** (score: {r.score:.3f})" for r in results)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from obsidian_mcp.config import Config
|
|
4
|
+
from obsidian_mcp.indexer.store import IndexStore
|
|
5
|
+
from obsidian_mcp.rag.engine import RAGEngine
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def handle_search_notes(arguments: dict, config: Config) -> str:
|
|
11
|
+
query = arguments.get("query")
|
|
12
|
+
if not query:
|
|
13
|
+
raise ValueError("query is required")
|
|
14
|
+
mode = arguments.get("mode", "hybrid")
|
|
15
|
+
limit = int(arguments.get("limit", 10))
|
|
16
|
+
logger.info("searching notes: mode=%s query=%r limit=%d", mode, query, limit)
|
|
17
|
+
engine = RAGEngine(config, IndexStore(config.index_dir / "index.db"))
|
|
18
|
+
results = engine.search(query, mode=mode, limit=limit)
|
|
19
|
+
if not results:
|
|
20
|
+
return "No results found."
|
|
21
|
+
lines = []
|
|
22
|
+
for r in results:
|
|
23
|
+
lines.append(f"- **{r.note_path}** (score: {r.score:.3f})")
|
|
24
|
+
if r.snippet:
|
|
25
|
+
lines.append(f" {r.snippet[:200].replace(chr(10), ' ')}")
|
|
26
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from obsidian_mcp.config import Config
|
|
4
|
+
from obsidian_mcp.indexer.indexer import Indexer
|
|
5
|
+
from obsidian_mcp.indexer.store import IndexStore
|
|
6
|
+
from obsidian_mcp.indexer.sync import BundleSync
|
|
7
|
+
from obsidian_mcp.vault.repository import VaultRepository
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def handle_sync_from_bundle(arguments: dict, config: Config) -> dict:
|
|
13
|
+
force = bool(arguments.get("force", False))
|
|
14
|
+
logger.info("syncing from bundle: force=%s", force)
|
|
15
|
+
vault = VaultRepository(config)
|
|
16
|
+
store = IndexStore(config.index_dir / "index.db")
|
|
17
|
+
indexer = Indexer(config, vault, store)
|
|
18
|
+
indexer.index_all(force=force)
|
|
19
|
+
sync = BundleSync(config, indexer, vault)
|
|
20
|
+
report = sync.sync(force=force)
|
|
21
|
+
logger.info("synced from bundle: %s", report)
|
|
22
|
+
return {"status": "synced", **report}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from obsidian_mcp.config import Config
|
|
5
|
+
from obsidian_mcp.privacy.filter import PrivacyFilter
|
|
6
|
+
from obsidian_mcp.vault.repository import VaultRepository
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def handle_update_note(arguments: dict, config: Config) -> dict:
|
|
12
|
+
path = arguments.get("path")
|
|
13
|
+
if not path:
|
|
14
|
+
raise ValueError("path is required")
|
|
15
|
+
content = arguments.get("content")
|
|
16
|
+
append = arguments.get("append")
|
|
17
|
+
tags = arguments.get("tags")
|
|
18
|
+
|
|
19
|
+
vault = VaultRepository(config)
|
|
20
|
+
note = vault.read(path)
|
|
21
|
+
|
|
22
|
+
PrivacyFilter(config).validate(content or "")
|
|
23
|
+
PrivacyFilter(config).validate(append or "")
|
|
24
|
+
|
|
25
|
+
if content is not None:
|
|
26
|
+
note.content = content
|
|
27
|
+
if append:
|
|
28
|
+
note.content = note.content.rstrip() + "\n\n" + append
|
|
29
|
+
if tags is not None:
|
|
30
|
+
note.tags = tags
|
|
31
|
+
note.frontmatter["updated"] = datetime.now(timezone.utc).isoformat()
|
|
32
|
+
vault.save(note)
|
|
33
|
+
logger.info("updated note: %s", path)
|
|
34
|
+
return {"status": "updated", "path": path}
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from obsidian_mcp.models import Note
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
14
|
+
LINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_frontmatter(content: str) -> tuple[dict, str]:
|
|
18
|
+
match = FRONTMATTER_RE.match(content)
|
|
19
|
+
if not match:
|
|
20
|
+
return {}, content
|
|
21
|
+
try:
|
|
22
|
+
frontmatter = yaml.safe_load(match.group(1)) or {}
|
|
23
|
+
except yaml.YAMLError as exc:
|
|
24
|
+
logger.warning("failed to parse frontmatter: %s", exc)
|
|
25
|
+
frontmatter = {}
|
|
26
|
+
body = content[match.end():]
|
|
27
|
+
return frontmatter, body
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_title_and_body(body: str) -> tuple[str, str]:
|
|
31
|
+
lines = body.splitlines()
|
|
32
|
+
if lines and lines[0].startswith("# "):
|
|
33
|
+
title = lines[0][2:].strip()
|
|
34
|
+
return title, "\n".join(lines[1:]).lstrip("\n")
|
|
35
|
+
return "", body
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_title(body: str, path: str) -> str:
|
|
39
|
+
title, _ = extract_title_and_body(body)
|
|
40
|
+
if title:
|
|
41
|
+
return title
|
|
42
|
+
return Path(path).stem.replace("-", " ").replace("_", " ").title()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_link(link: str) -> str:
|
|
46
|
+
link = link.strip()
|
|
47
|
+
if not any(link.endswith(ext) for ext in (".md", ".png", ".jpg", ".jpeg", ".pdf")):
|
|
48
|
+
link += ".md"
|
|
49
|
+
return link
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_links(content: str) -> list[str]:
|
|
53
|
+
return [normalize_link(m.group(1)) for m in LINK_RE.finditer(content)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_note(path: str, full_path: Path) -> Note:
|
|
57
|
+
content = full_path.read_text(encoding="utf-8")
|
|
58
|
+
frontmatter, body = extract_frontmatter(content)
|
|
59
|
+
if frontmatter.get("title"):
|
|
60
|
+
title = frontmatter["title"]
|
|
61
|
+
else:
|
|
62
|
+
title, body = extract_title_and_body(body)
|
|
63
|
+
if not title:
|
|
64
|
+
title = extract_title(body, path)
|
|
65
|
+
links = extract_links(content)
|
|
66
|
+
stat = full_path.stat()
|
|
67
|
+
ctime = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
|
|
68
|
+
mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
|
|
69
|
+
tags = frontmatter.get("tags", []) or []
|
|
70
|
+
if isinstance(tags, str):
|
|
71
|
+
tags = [t.strip() for t in tags.split(",") if t.strip()]
|
|
72
|
+
return Note(
|
|
73
|
+
path=path,
|
|
74
|
+
title=title,
|
|
75
|
+
content=body,
|
|
76
|
+
frontmatter=frontmatter,
|
|
77
|
+
links=links,
|
|
78
|
+
backlinks=[],
|
|
79
|
+
tags=tags,
|
|
80
|
+
ctime=ctime,
|
|
81
|
+
mtime=mtime,
|
|
82
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from obsidian_mcp.config import Config
|
|
4
|
+
from obsidian_mcp.models import Note
|
|
5
|
+
from obsidian_mcp.vault.parser import parse_note
|
|
6
|
+
from obsidian_mcp.vault.writer import write_note
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class VaultRepository:
|
|
10
|
+
def __init__(self, config: Config):
|
|
11
|
+
self.config = config
|
|
12
|
+
self.root = config.vault_path
|
|
13
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
def _resolve(self, path: str) -> Path:
|
|
16
|
+
safe = path.lstrip("/")
|
|
17
|
+
if ".." in safe.split("/"):
|
|
18
|
+
raise ValueError(f"invalid note path: {path}")
|
|
19
|
+
full = (self.root / safe).resolve()
|
|
20
|
+
if full != self.root and self.root not in full.parents:
|
|
21
|
+
raise ValueError(f"invalid note path: {path}")
|
|
22
|
+
return full
|
|
23
|
+
|
|
24
|
+
def _is_inside_vault(self, path: Path) -> bool:
|
|
25
|
+
resolved = path.resolve()
|
|
26
|
+
return resolved == self.root or self.root in resolved.parents
|
|
27
|
+
|
|
28
|
+
def list_notes(self, folder: str | None = None) -> list[str]:
|
|
29
|
+
base = self.root
|
|
30
|
+
if folder:
|
|
31
|
+
base = self._resolve(folder)
|
|
32
|
+
paths = []
|
|
33
|
+
for p in base.rglob("*.md"):
|
|
34
|
+
if not self._is_inside_vault(p):
|
|
35
|
+
continue
|
|
36
|
+
rel = p.relative_to(self.root)
|
|
37
|
+
if any(part.startswith(".") for part in rel.parts):
|
|
38
|
+
continue
|
|
39
|
+
paths.append(rel.as_posix())
|
|
40
|
+
return sorted(paths)
|
|
41
|
+
|
|
42
|
+
def read(self, path: str) -> Note:
|
|
43
|
+
full = self._resolve(path)
|
|
44
|
+
if not full.exists():
|
|
45
|
+
raise FileNotFoundError(f"note not found: {path}")
|
|
46
|
+
return parse_note(path, full)
|
|
47
|
+
|
|
48
|
+
def read_raw(self, path: str) -> str:
|
|
49
|
+
full = self._resolve(path)
|
|
50
|
+
if not full.exists():
|
|
51
|
+
raise FileNotFoundError(f"note not found: {path}")
|
|
52
|
+
return full.read_text(encoding="utf-8", errors="replace")
|
|
53
|
+
|
|
54
|
+
def read_all(self) -> list[Note]:
|
|
55
|
+
return [self.read(p) for p in self.list_notes()]
|
|
56
|
+
|
|
57
|
+
def exists(self, path: str) -> bool:
|
|
58
|
+
return self._resolve(path).exists()
|
|
59
|
+
|
|
60
|
+
def save(self, note: Note) -> None:
|
|
61
|
+
full = self._resolve(note.path)
|
|
62
|
+
write_note(full, note)
|
|
63
|
+
|
|
64
|
+
def delete(self, path: str) -> None:
|
|
65
|
+
full = self._resolve(path)
|
|
66
|
+
if not full.exists():
|
|
67
|
+
raise FileNotFoundError(f"note not found: {path}")
|
|
68
|
+
full.unlink()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from obsidian_mcp.models import Note
|
|
8
|
+
from obsidian_mcp.vault.parser import extract_frontmatter
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_frontmatter(data: dict) -> str:
|
|
14
|
+
if not data:
|
|
15
|
+
return ""
|
|
16
|
+
return "---\n" + yaml.safe_dump(data, sort_keys=False, allow_unicode=True) + "---\n\n"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize_tags(tags) -> list[str]:
|
|
20
|
+
if tags is None:
|
|
21
|
+
return []
|
|
22
|
+
if isinstance(tags, str):
|
|
23
|
+
return [t.strip() for t in tags.split(",") if t.strip()]
|
|
24
|
+
return list(tags)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_note(full_path: Path, note: Note) -> None:
|
|
28
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
body = note.content
|
|
31
|
+
extracted_frontmatter = {}
|
|
32
|
+
if note.content.startswith("---"):
|
|
33
|
+
extracted_frontmatter, body = extract_frontmatter(note.content)
|
|
34
|
+
if extracted_frontmatter is None:
|
|
35
|
+
logger.warning("failed to parse embedded frontmatter in %s", full_path)
|
|
36
|
+
extracted_frontmatter = {}
|
|
37
|
+
body = note.content
|
|
38
|
+
|
|
39
|
+
frontmatter = dict(note.frontmatter)
|
|
40
|
+
frontmatter.setdefault("title", note.title)
|
|
41
|
+
|
|
42
|
+
server_tags = _normalize_tags(note.tags)
|
|
43
|
+
extracted_tags = _normalize_tags(extracted_frontmatter.pop("tags", []))
|
|
44
|
+
all_tags = set(server_tags)
|
|
45
|
+
all_tags.update(extracted_tags)
|
|
46
|
+
all_tags.update(_normalize_tags(frontmatter.get("tags", [])))
|
|
47
|
+
if all_tags:
|
|
48
|
+
frontmatter["tags"] = sorted(all_tags)
|
|
49
|
+
elif "tags" in frontmatter:
|
|
50
|
+
del frontmatter["tags"]
|
|
51
|
+
|
|
52
|
+
for key, value in extracted_frontmatter.items():
|
|
53
|
+
if key not in ("title", "created", "updated"):
|
|
54
|
+
frontmatter.setdefault(key, value)
|
|
55
|
+
|
|
56
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
57
|
+
frontmatter.setdefault("created", now)
|
|
58
|
+
frontmatter["updated"] = now
|
|
59
|
+
|
|
60
|
+
content = to_frontmatter(frontmatter) + body
|
|
61
|
+
full_path.write_text(content, encoding="utf-8")
|