@archznn/crewloop-skills 0.2.0 → 0.3.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.
Files changed (74) hide show
  1. package/README.md +21 -31
  2. package/assets/templates/skill-template.md +58 -0
  3. package/package.json +4 -1
  4. package/references/conventions.md +144 -0
  5. package/references/obsidian-mcp-usage.md +190 -0
  6. package/references/skill-anatomy.md +77 -0
  7. package/references/workflow.md +64 -0
  8. package/servers/obsidian-mcp/README.md +82 -0
  9. package/servers/obsidian-mcp/pyproject.toml +32 -0
  10. package/servers/obsidian-mcp/src/obsidian_mcp/__init__.py +0 -0
  11. package/servers/obsidian-mcp/src/obsidian_mcp/config.py +47 -0
  12. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/__init__.py +0 -0
  13. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/embeddings.py +105 -0
  14. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/indexer.py +79 -0
  15. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/store.py +141 -0
  16. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/sync.py +37 -0
  17. package/servers/obsidian-mcp/src/obsidian_mcp/learning/__init__.py +0 -0
  18. package/servers/obsidian-mcp/src/obsidian_mcp/learning/detector.py +66 -0
  19. package/servers/obsidian-mcp/src/obsidian_mcp/learning/note_generator.py +40 -0
  20. package/servers/obsidian-mcp/src/obsidian_mcp/main.py +4 -0
  21. package/servers/obsidian-mcp/src/obsidian_mcp/models.py +42 -0
  22. package/servers/obsidian-mcp/src/obsidian_mcp/privacy/__init__.py +0 -0
  23. package/servers/obsidian-mcp/src/obsidian_mcp/privacy/filter.py +68 -0
  24. package/servers/obsidian-mcp/src/obsidian_mcp/rag/__init__.py +0 -0
  25. package/servers/obsidian-mcp/src/obsidian_mcp/rag/engine.py +50 -0
  26. package/servers/obsidian-mcp/src/obsidian_mcp/rag/graph_search.py +55 -0
  27. package/servers/obsidian-mcp/src/obsidian_mcp/rag/text_search.py +37 -0
  28. package/servers/obsidian-mcp/src/obsidian_mcp/rag/vector_search.py +118 -0
  29. package/servers/obsidian-mcp/src/obsidian_mcp/server.py +61 -0
  30. package/servers/obsidian-mcp/src/obsidian_mcp/tools/__init__.py +0 -0
  31. package/servers/obsidian-mcp/src/obsidian_mcp/tools/create.py +43 -0
  32. package/servers/obsidian-mcp/src/obsidian_mcp/tools/delete.py +16 -0
  33. package/servers/obsidian-mcp/src/obsidian_mcp/tools/learn.py +42 -0
  34. package/servers/obsidian-mcp/src/obsidian_mcp/tools/list.py +16 -0
  35. package/servers/obsidian-mcp/src/obsidian_mcp/tools/read.py +15 -0
  36. package/servers/obsidian-mcp/src/obsidian_mcp/tools/registry.py +130 -0
  37. package/servers/obsidian-mcp/src/obsidian_mcp/tools/related.py +20 -0
  38. package/servers/obsidian-mcp/src/obsidian_mcp/tools/search.py +26 -0
  39. package/servers/obsidian-mcp/src/obsidian_mcp/tools/sync.py +22 -0
  40. package/servers/obsidian-mcp/src/obsidian_mcp/tools/update.py +34 -0
  41. package/servers/obsidian-mcp/src/obsidian_mcp/vault/__init__.py +0 -0
  42. package/servers/obsidian-mcp/src/obsidian_mcp/vault/parser.py +82 -0
  43. package/servers/obsidian-mcp/src/obsidian_mcp/vault/repository.py +68 -0
  44. package/servers/obsidian-mcp/src/obsidian_mcp/vault/writer.py +61 -0
  45. package/servers/obsidian-mcp/tests/conftest.py +39 -0
  46. package/servers/obsidian-mcp/tests/test_async_tools.py +87 -0
  47. package/servers/obsidian-mcp/tests/test_edge_cases.py +59 -0
  48. package/servers/obsidian-mcp/tests/test_indexer.py +27 -0
  49. package/servers/obsidian-mcp/tests/test_integration.py +90 -0
  50. package/servers/obsidian-mcp/tests/test_learning.py +34 -0
  51. package/servers/obsidian-mcp/tests/test_privacy.py +31 -0
  52. package/servers/obsidian-mcp/tests/test_privacy_config.py +44 -0
  53. package/servers/obsidian-mcp/tests/test_rag.py +64 -0
  54. package/servers/obsidian-mcp/tests/test_read_raw.py +37 -0
  55. package/servers/obsidian-mcp/tests/test_tfidf_fallback.py +54 -0
  56. package/servers/obsidian-mcp/tests/test_tools.py +108 -0
  57. package/servers/obsidian-mcp/tests/test_vault.py +103 -0
  58. package/servers/obsidian-mcp/tests/test_writer.py +139 -0
  59. package/skills/accessibility-auditor/SKILL.md +262 -0
  60. package/skills/accessibility-auditor/references/a11y-checklist.md +66 -0
  61. package/skills/architect/SKILL.md +1 -1
  62. package/skills/designer/SKILL.md +1 -1
  63. package/skills/docs-writer/SKILL.md +1 -1
  64. package/skills/engineer/SKILL.md +1 -1
  65. package/skills/maintainer/SKILL.md +22 -22
  66. package/skills/obsidian-second-brain/SKILL.md +48 -13
  67. package/skills/orchestrator/SKILL.md +1 -1
  68. package/skills/product-manager/SKILL.md +22 -22
  69. package/skills/researcher/SKILL.md +22 -22
  70. package/skills/reviewer/SKILL.md +1 -1
  71. package/skills/security-guard/SKILL.md +142 -0
  72. package/skills/security-guard/references/security-checklist.md +57 -0
  73. package/skills/shipper/SKILL.md +1 -1
  74. package/skills/tester/SKILL.md +22 -22
@@ -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}
@@ -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")
@@ -0,0 +1,39 @@
1
+ import tempfile
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from obsidian_mcp.config import Config
7
+ from obsidian_mcp.indexer.indexer import Indexer
8
+ from obsidian_mcp.indexer.store import IndexStore
9
+ from obsidian_mcp.vault.repository import VaultRepository
10
+
11
+
12
+ @pytest.fixture
13
+ def temp_vault():
14
+ with tempfile.TemporaryDirectory() as tmp:
15
+ yield Path(tmp)
16
+
17
+
18
+ @pytest.fixture
19
+ def config(temp_vault):
20
+ return Config(
21
+ vault_path=temp_vault,
22
+ index_dir=temp_vault / ".index",
23
+ bundle_path=temp_vault,
24
+ )
25
+
26
+
27
+ @pytest.fixture
28
+ def vault(config):
29
+ return VaultRepository(config)
30
+
31
+
32
+ @pytest.fixture
33
+ def store(config):
34
+ return IndexStore(config.index_dir / "index.db")
35
+
36
+
37
+ @pytest.fixture
38
+ def indexer(config, vault, store):
39
+ return Indexer(config, vault, store)
@@ -0,0 +1,87 @@
1
+ import asyncio
2
+ import threading
3
+ import time
4
+
5
+ import pytest
6
+
7
+ from obsidian_mcp.config import Config
8
+ from obsidian_mcp.tools.registry import TOOLS, dispatch_async
9
+ from obsidian_mcp.vault.repository import VaultRepository
10
+
11
+
12
+ def test_dispatch_async_runs_sync_handler_in_thread(monkeypatch):
13
+ events = []
14
+
15
+ def slow_handler(arguments, config):
16
+ events.append(("start", threading.current_thread().name))
17
+ time.sleep(0.1)
18
+ events.append(("end", threading.current_thread().name))
19
+ return "slow-result"
20
+
21
+ monkeypatch.setitem(TOOLS, "slow_tool", {
22
+ "description": "Slow tool",
23
+ "input_schema": {"type": "object"},
24
+ "handler": slow_handler,
25
+ })
26
+
27
+ async def _run():
28
+ start = time.monotonic()
29
+ result = await dispatch_async("slow_tool", {}, Config())
30
+ elapsed = time.monotonic() - start
31
+ return result, elapsed
32
+
33
+ result, elapsed = asyncio.run(_run())
34
+ assert result == "slow-result"
35
+ assert elapsed >= 0.05
36
+ assert any("start" in e[0] for e in events)
37
+
38
+
39
+ def test_dispatch_async_awaits_async_handler(monkeypatch):
40
+ async def async_handler(arguments, config):
41
+ await asyncio.sleep(0.01)
42
+ return "async-result"
43
+
44
+ monkeypatch.setitem(TOOLS, "async_tool", {
45
+ "description": "Async tool",
46
+ "input_schema": {"type": "object"},
47
+ "handler": async_handler,
48
+ })
49
+
50
+ async def _run():
51
+ return await dispatch_async("async_tool", {}, Config())
52
+
53
+ result = asyncio.run(_run())
54
+ assert result == "async-result"
55
+
56
+
57
+ def test_dispatch_async_does_not_block_event_loop(monkeypatch):
58
+ async def background_task():
59
+ await asyncio.sleep(0.05)
60
+ return "background-done"
61
+
62
+ def slow_handler(arguments, config):
63
+ time.sleep(0.15)
64
+ return "done"
65
+
66
+ monkeypatch.setitem(TOOLS, "slow_tool2", {
67
+ "description": "Slow tool",
68
+ "input_schema": {"type": "object"},
69
+ "handler": slow_handler,
70
+ })
71
+
72
+ async def _run():
73
+ task = asyncio.create_task(background_task())
74
+ result = await dispatch_async("slow_tool2", {}, Config())
75
+ background_result = await task
76
+ return result, background_result
77
+
78
+ result, background_result = asyncio.run(_run())
79
+ assert result == "done"
80
+ assert background_result == "background-done"
81
+
82
+
83
+ def test_dispatch_stays_synchronous(config):
84
+ VaultRepository(config)
85
+ from obsidian_mcp.tools.registry import dispatch
86
+ result = dispatch("create_note", {"path": "sync.md", "content": "hello"}, config)
87
+ assert result["status"] == "created"
@@ -0,0 +1,59 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from obsidian_mcp.config import Config
6
+ from obsidian_mcp.indexer.indexer import Indexer
7
+ from obsidian_mcp.indexer.store import IndexStore
8
+ from obsidian_mcp.models import Note
9
+ from obsidian_mcp.rag.engine import RAGEngine
10
+ from obsidian_mcp.tools.registry import dispatch
11
+ from obsidian_mcp.vault.repository import VaultRepository
12
+
13
+
14
+ def test_config_bundle_path_from_env(monkeypatch, tmp_path):
15
+ monkeypatch.setenv("CREWLOOP_BUNDLE_PATH", str(tmp_path))
16
+ config = Config()
17
+ assert config.bundle_path == tmp_path.resolve()
18
+
19
+
20
+ def test_vector_search_empty_vault(config, store):
21
+ engine = RAGEngine(config, store)
22
+ results = engine.search("anything", mode="vector")
23
+ assert results == []
24
+
25
+
26
+ def test_text_search_empty_vault(config, store):
27
+ engine = RAGEngine(config, store)
28
+ results = engine.search("anything", mode="text")
29
+ assert results == []
30
+
31
+
32
+ def test_graph_search_empty_vault(config, store):
33
+ engine = RAGEngine(config, store)
34
+ results = engine.search("anything", mode="graph")
35
+ assert results == []
36
+
37
+
38
+ def test_related_notes_empty_vault(config, store):
39
+ engine = RAGEngine(config, store)
40
+ results = engine.related("missing.md")
41
+ assert results == []
42
+
43
+
44
+ def test_list_notes_empty_vault(config):
45
+ VaultRepository(config)
46
+ result = dispatch("list_notes", {}, config)
47
+ assert result == "No notes found."
48
+
49
+
50
+ def test_sync_from_bundle_empty_bundle(config, temp_vault):
51
+ VaultRepository(config)
52
+ result = dispatch("sync_from_bundle", {"force": True}, config)
53
+ assert result["status"] == "synced"
54
+ assert result["indexed_bundle_files"] == 0
55
+
56
+
57
+ def test_learn_from_text_no_learning(config):
58
+ result = dispatch("learn_from_text", {"text": "Just a random sentence with no clear concept."}, config)
59
+ assert result["status"] == "no_learning_detected"
@@ -0,0 +1,27 @@
1
+ from obsidian_mcp.indexer.embeddings import chunk_text
2
+ from obsidian_mcp.models import Note
3
+
4
+
5
+ def test_chunk_text_character_based():
6
+ long_text = "word " * 200
7
+ chunks = chunk_text(long_text, chunk_size=50, overlap=10)
8
+ assert len(chunks) > 1
9
+ assert all(len(text) <= 80 for text, _, _ in chunks)
10
+
11
+
12
+ def test_index_note(indexer, vault, store):
13
+ note = Note(path="test.md", title="Test", content="Hello world from MCP.")
14
+ vault.save(note)
15
+ indexer.index_note(note)
16
+ chunks = store.get_all_chunks()
17
+ assert any(c.note_path == "test.md" for c in chunks)
18
+
19
+
20
+ def test_compute_backlinks(indexer, vault, store):
21
+ vault.save(Note(path="a.md", title="A", content="Link to [[b]]."))
22
+ vault.save(Note(path="b.md", title="B", content="Content."))
23
+ indexer.index_note(vault.read("a.md"))
24
+ indexer.index_note(vault.read("b.md"))
25
+ indexer.compute_backlinks()
26
+ edges = store.get_all_edges()
27
+ assert any(e.source == "b.md" and e.target == "a.md" and e.relation == "backlink" for e in edges)
@@ -0,0 +1,90 @@
1
+ import time
2
+ from pathlib import Path
3
+
4
+ from obsidian_mcp.config import Config
5
+ from obsidian_mcp.indexer.indexer import Indexer
6
+ from obsidian_mcp.indexer.store import IndexStore
7
+ from obsidian_mcp.models import Note
8
+ from obsidian_mcp.tools.registry import dispatch
9
+ from obsidian_mcp.vault.repository import VaultRepository
10
+
11
+
12
+ def test_end_to_end_workflow(tmp_path):
13
+ config = Config(
14
+ vault_path=tmp_path / "vault",
15
+ index_dir=tmp_path / ".index",
16
+ bundle_path=Path(__file__).resolve().parents[3],
17
+ )
18
+
19
+ # 1. Sync bundle as initial knowledge
20
+ result = dispatch("sync_from_bundle", {"force": True}, config)
21
+ assert result["status"] == "synced"
22
+ assert result["indexed_bundle_files"] > 0
23
+
24
+ # 2. Search existing knowledge
25
+ search = dispatch("search_notes", {"query": "orchestrator", "mode": "text"}, config)
26
+ assert "orchestrator" in search.lower() or "No results" not in search
27
+
28
+ # 3. Create a note with a link
29
+ dispatch(
30
+ "create_note",
31
+ {
32
+ "path": "projects/mcp-integration.md",
33
+ "title": "MCP Integration",
34
+ "content": "We integrated Obsidian via [[MCP]].",
35
+ "tags": ["integration"],
36
+ },
37
+ config,
38
+ )
39
+
40
+ # 4. Learn from new text
41
+ learn = dispatch(
42
+ "learn_from_text",
43
+ {"text": "Novo conceito: MCP Server conecta Obsidian local ao bundle LEA."},
44
+ config,
45
+ )
46
+ assert learn["status"] == "learned"
47
+ assert any("mcp-server" in p.lower() for p in learn["created_notes"])
48
+
49
+ # 5. Re-index and find related notes
50
+ vault = VaultRepository(config)
51
+ indexer = Indexer(config, vault, IndexStore(config.index_dir / "index.db"))
52
+ indexer.index_all()
53
+ indexer.compute_backlinks()
54
+
55
+ related = dispatch("get_related_notes", {"path": "projects/mcp-integration.md"}, config)
56
+ assert "mcp" in related.lower()
57
+
58
+ # 6. List notes
59
+ listed = dispatch("list_notes", {"folder": "concepts"}, config)
60
+ assert "mcp-server" in listed.lower()
61
+
62
+ # 7. Read generated concept note
63
+ concept_path = learn["created_notes"][0]
64
+ concept = dispatch("read_note", {"path": concept_path}, config)
65
+ assert "MCP Server" in concept
66
+
67
+
68
+ def test_search_performance(tmp_path):
69
+ config = Config(
70
+ vault_path=tmp_path / "vault",
71
+ index_dir=tmp_path / ".index",
72
+ bundle_path=tmp_path,
73
+ )
74
+ vault = VaultRepository(config)
75
+ for i in range(1000):
76
+ vault.save(
77
+ Note(
78
+ path=f"notes/note-{i:04d}.md",
79
+ title=f"Note {i}",
80
+ content=f"This is note number {i} about topic {i % 10} and keyword {i}.",
81
+ )
82
+ )
83
+ indexer = Indexer(config, vault, IndexStore(config.index_dir / "index.db"))
84
+ indexer.index_all()
85
+
86
+ start = time.time()
87
+ result = dispatch("search_notes", {"query": "topic 5", "mode": "text", "limit": 10}, config)
88
+ elapsed = time.time() - start
89
+ assert elapsed < 2.0, f"search took {elapsed:.2f}s"
90
+ assert "note-" in result
@@ -0,0 +1,34 @@
1
+ from obsidian_mcp.learning.detector import LearningDetector
2
+ from obsidian_mcp.learning.note_generator import NoteGenerator
3
+ from obsidian_mcp.tools.registry import dispatch
4
+ from obsidian_mcp.vault.repository import VaultRepository
5
+
6
+
7
+ def test_detect_concept(config, vault):
8
+ detector = LearningDetector(config)
9
+ learnings = detector.detect("Novo conceito: Graph RAG será usado no projeto.")
10
+ assert any(l.type == "concept" and "Graph RAG" in l.title for l in learnings)
11
+
12
+
13
+ def test_detect_decision(config, vault):
14
+ detector = LearningDetector(config)
15
+ learnings = detector.detect("Decidimos que o vault ficará em ~/.lea.")
16
+ assert any(l.type == "decision" for l in learnings)
17
+
18
+
19
+ def test_note_generator(config, vault):
20
+ detector = LearningDetector(config)
21
+ learnings = detector.detect("Novo conceito: MCP Server.")
22
+ gen = NoteGenerator(config, vault)
23
+ note = gen.to_note(learnings[0])
24
+ assert note.path.startswith("concepts/")
25
+ assert note.frontmatter["auto_generated"] is True
26
+
27
+
28
+ def test_learn_from_text_dedup(config):
29
+ VaultRepository(config)
30
+ text = "Novo conceito: Dedup Test no projeto."
31
+ first = dispatch("learn_from_text", {"text": text}, config)
32
+ assert first["status"] == "learned"
33
+ second = dispatch("learn_from_text", {"text": text}, config)
34
+ assert second["status"] == "duplicate"