@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.
Files changed (106) hide show
  1. package/README.md +21 -31
  2. package/assets/templates/skill-template.md +58 -0
  3. package/package.json +5 -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/dashboard/README.md +87 -0
  9. package/servers/dashboard/bin/crewloop-dashboard.js +5 -0
  10. package/servers/dashboard/config-examples/codex-hooks.json +14 -0
  11. package/servers/dashboard/config-examples/kimi-code-config.toml +6 -0
  12. package/servers/dashboard/config-examples/opencode-plugin/crewloop-dashboard.js +64 -0
  13. package/servers/dashboard/package.json +46 -0
  14. package/servers/dashboard/public/app.js +447 -0
  15. package/servers/dashboard/public/index.html +96 -0
  16. package/servers/dashboard/public/styles.css +664 -0
  17. package/servers/dashboard/src/adapters/codex.ts +50 -0
  18. package/servers/dashboard/src/adapters/kimi.ts +40 -0
  19. package/servers/dashboard/src/adapters/opencode.ts +36 -0
  20. package/servers/dashboard/src/adapters/shim.test.ts +74 -0
  21. package/servers/dashboard/src/adapters/shim.ts +120 -0
  22. package/servers/dashboard/src/api/event.ts +70 -0
  23. package/servers/dashboard/src/api/skills.ts +11 -0
  24. package/servers/dashboard/src/config.ts +66 -0
  25. package/servers/dashboard/src/filters/sanitize.test.ts +94 -0
  26. package/servers/dashboard/src/filters/sanitize.ts +78 -0
  27. package/servers/dashboard/src/index.ts +24 -0
  28. package/servers/dashboard/src/presenter.test.ts +69 -0
  29. package/servers/dashboard/src/presenter.ts +56 -0
  30. package/servers/dashboard/src/server.test.ts +123 -0
  31. package/servers/dashboard/src/server.ts +191 -0
  32. package/servers/dashboard/src/skills/infer.test.ts +86 -0
  33. package/servers/dashboard/src/skills/infer.ts +53 -0
  34. package/servers/dashboard/src/skills/mapping.ts +26 -0
  35. package/servers/dashboard/src/skills/registry.ts +60 -0
  36. package/servers/dashboard/src/state.test.ts +88 -0
  37. package/servers/dashboard/src/state.ts +115 -0
  38. package/servers/dashboard/src/types.ts +110 -0
  39. package/servers/dashboard/tsconfig.json +19 -0
  40. package/servers/obsidian-mcp/README.md +82 -0
  41. package/servers/obsidian-mcp/pyproject.toml +32 -0
  42. package/servers/obsidian-mcp/src/obsidian_mcp/__init__.py +0 -0
  43. package/servers/obsidian-mcp/src/obsidian_mcp/config.py +47 -0
  44. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/__init__.py +0 -0
  45. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/embeddings.py +105 -0
  46. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/indexer.py +79 -0
  47. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/store.py +141 -0
  48. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/sync.py +37 -0
  49. package/servers/obsidian-mcp/src/obsidian_mcp/learning/__init__.py +0 -0
  50. package/servers/obsidian-mcp/src/obsidian_mcp/learning/detector.py +66 -0
  51. package/servers/obsidian-mcp/src/obsidian_mcp/learning/note_generator.py +40 -0
  52. package/servers/obsidian-mcp/src/obsidian_mcp/main.py +4 -0
  53. package/servers/obsidian-mcp/src/obsidian_mcp/models.py +42 -0
  54. package/servers/obsidian-mcp/src/obsidian_mcp/privacy/__init__.py +0 -0
  55. package/servers/obsidian-mcp/src/obsidian_mcp/privacy/filter.py +68 -0
  56. package/servers/obsidian-mcp/src/obsidian_mcp/rag/__init__.py +0 -0
  57. package/servers/obsidian-mcp/src/obsidian_mcp/rag/engine.py +50 -0
  58. package/servers/obsidian-mcp/src/obsidian_mcp/rag/graph_search.py +55 -0
  59. package/servers/obsidian-mcp/src/obsidian_mcp/rag/text_search.py +37 -0
  60. package/servers/obsidian-mcp/src/obsidian_mcp/rag/vector_search.py +118 -0
  61. package/servers/obsidian-mcp/src/obsidian_mcp/server.py +61 -0
  62. package/servers/obsidian-mcp/src/obsidian_mcp/tools/__init__.py +0 -0
  63. package/servers/obsidian-mcp/src/obsidian_mcp/tools/create.py +43 -0
  64. package/servers/obsidian-mcp/src/obsidian_mcp/tools/delete.py +16 -0
  65. package/servers/obsidian-mcp/src/obsidian_mcp/tools/learn.py +42 -0
  66. package/servers/obsidian-mcp/src/obsidian_mcp/tools/list.py +16 -0
  67. package/servers/obsidian-mcp/src/obsidian_mcp/tools/read.py +15 -0
  68. package/servers/obsidian-mcp/src/obsidian_mcp/tools/registry.py +130 -0
  69. package/servers/obsidian-mcp/src/obsidian_mcp/tools/related.py +20 -0
  70. package/servers/obsidian-mcp/src/obsidian_mcp/tools/search.py +26 -0
  71. package/servers/obsidian-mcp/src/obsidian_mcp/tools/sync.py +22 -0
  72. package/servers/obsidian-mcp/src/obsidian_mcp/tools/update.py +34 -0
  73. package/servers/obsidian-mcp/src/obsidian_mcp/vault/__init__.py +0 -0
  74. package/servers/obsidian-mcp/src/obsidian_mcp/vault/parser.py +82 -0
  75. package/servers/obsidian-mcp/src/obsidian_mcp/vault/repository.py +68 -0
  76. package/servers/obsidian-mcp/src/obsidian_mcp/vault/writer.py +61 -0
  77. package/servers/obsidian-mcp/tests/conftest.py +39 -0
  78. package/servers/obsidian-mcp/tests/test_async_tools.py +87 -0
  79. package/servers/obsidian-mcp/tests/test_edge_cases.py +59 -0
  80. package/servers/obsidian-mcp/tests/test_indexer.py +27 -0
  81. package/servers/obsidian-mcp/tests/test_integration.py +90 -0
  82. package/servers/obsidian-mcp/tests/test_learning.py +34 -0
  83. package/servers/obsidian-mcp/tests/test_privacy.py +31 -0
  84. package/servers/obsidian-mcp/tests/test_privacy_config.py +44 -0
  85. package/servers/obsidian-mcp/tests/test_rag.py +64 -0
  86. package/servers/obsidian-mcp/tests/test_read_raw.py +37 -0
  87. package/servers/obsidian-mcp/tests/test_tfidf_fallback.py +54 -0
  88. package/servers/obsidian-mcp/tests/test_tools.py +108 -0
  89. package/servers/obsidian-mcp/tests/test_vault.py +103 -0
  90. package/servers/obsidian-mcp/tests/test_writer.py +139 -0
  91. package/skills/accessibility-auditor/SKILL.md +262 -0
  92. package/skills/accessibility-auditor/references/a11y-checklist.md +66 -0
  93. package/skills/architect/SKILL.md +1 -1
  94. package/skills/designer/SKILL.md +1 -1
  95. package/skills/docs-writer/SKILL.md +1 -1
  96. package/skills/engineer/SKILL.md +1 -1
  97. package/skills/maintainer/SKILL.md +22 -22
  98. package/skills/obsidian-second-brain/SKILL.md +48 -13
  99. package/skills/orchestrator/SKILL.md +1 -1
  100. package/skills/product-manager/SKILL.md +22 -22
  101. package/skills/researcher/SKILL.md +22 -22
  102. package/skills/reviewer/SKILL.md +1 -1
  103. package/skills/security-guard/SKILL.md +142 -0
  104. package/skills/security-guard/references/security-checklist.md +57 -0
  105. package/skills/shipper/SKILL.md +1 -1
  106. package/skills/tester/SKILL.md +22 -22
@@ -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"
@@ -0,0 +1,31 @@
1
+ import pytest
2
+
3
+ from obsidian_mcp.config import Config
4
+ from obsidian_mcp.privacy.filter import PrivacyFilter
5
+
6
+
7
+ def test_blocks_api_key():
8
+ f = PrivacyFilter()
9
+ assert not f.is_safe("API_KEY=sk-123")
10
+ with pytest.raises(ValueError):
11
+ f.validate("API_KEY=sk-123")
12
+
13
+
14
+ def test_allows_safe_text():
15
+ f = PrivacyFilter()
16
+ assert f.is_safe("This is a normal note about MCP.")
17
+
18
+
19
+ def test_blocks_email():
20
+ f = PrivacyFilter()
21
+ assert not f.is_safe("Contact me at user@example.com")
22
+
23
+
24
+ def test_blocks_secret_key_prefix():
25
+ f = PrivacyFilter()
26
+ assert not f.is_safe("Key: sk-abc123def456ghi789")
27
+
28
+
29
+ def test_allows_tokenization_word():
30
+ f = PrivacyFilter()
31
+ assert f.is_safe("This note discusses tokenization of text.")
@@ -0,0 +1,44 @@
1
+ import pytest
2
+
3
+ from obsidian_mcp.config import Config, PrivacyConfig
4
+ from obsidian_mcp.privacy.filter import PrivacyFilter
5
+
6
+
7
+ def test_privacy_filter_disabled_via_config():
8
+ config = Config(privacy={"enabled": False})
9
+ f = PrivacyFilter(config)
10
+ assert f.is_safe("API_KEY=secret")
11
+ f.validate("API_KEY=secret") # no exception
12
+
13
+
14
+ def test_privacy_filter_allow_list_bypass():
15
+ config = Config(privacy={"allowed_strings": ["user@example.com"]})
16
+ f = PrivacyFilter(config)
17
+ assert f.is_safe("Contact user@example.com please")
18
+ f.validate("Contact user@example.com please")
19
+
20
+
21
+ def test_privacy_filter_toggle_emails():
22
+ config = Config(privacy={"block_emails": False})
23
+ f = PrivacyFilter(config)
24
+ assert f.is_safe("user@example.com")
25
+
26
+
27
+ def test_privacy_filter_toggle_credit_cards():
28
+ config = Config(privacy={"block_credit_cards": False})
29
+ f = PrivacyFilter(config)
30
+ assert f.is_safe("1234 5678 9012 3456")
31
+
32
+
33
+ def test_privacy_filter_default_still_blocks():
34
+ f = PrivacyFilter()
35
+ assert not f.is_safe("API_KEY=secret")
36
+ assert not f.is_safe("user@example.com")
37
+ with pytest.raises(ValueError):
38
+ f.validate("API_KEY=secret")
39
+
40
+
41
+ def test_privacy_config_object():
42
+ config = Config(privacy=PrivacyConfig(enabled=False))
43
+ f = PrivacyFilter(config)
44
+ assert f.is_safe("anything")
@@ -0,0 +1,64 @@
1
+ from obsidian_mcp.indexer.indexer import Indexer
2
+ from obsidian_mcp.models import Note
3
+ from obsidian_mcp.rag.engine import RAGEngine
4
+
5
+
6
+ def test_text_search(indexer, vault, config, store):
7
+ note = Note(path="search.md", title="Search", content="Obsidian vault local second brain.")
8
+ vault.save(note)
9
+ indexer.index_note(note)
10
+ engine = RAGEngine(config, store)
11
+ results = engine.search("Obsidian", mode="text")
12
+ assert any(r.note_path == "search.md" for r in results)
13
+
14
+
15
+ def test_related_notes(indexer, vault, config, store):
16
+ vault.save(Note(path="a.md", title="A", content="See [[b]]."))
17
+ vault.save(Note(path="b.md", title="B", content="Content."))
18
+ indexer.index_note(vault.read("a.md"))
19
+ indexer.index_note(vault.read("b.md"))
20
+ indexer.compute_backlinks()
21
+ engine = RAGEngine(config, store)
22
+ results = engine.related("a.md")
23
+ assert any(r.note_path == "b.md" for r in results)
24
+
25
+
26
+ def test_graph_search(indexer, vault, config, store):
27
+ vault.save(Note(path="a.md", title="A", content="See [[b]]."))
28
+ vault.save(Note(path="b.md", title="B", content="Content."))
29
+ indexer.index_note(vault.read("a.md"))
30
+ indexer.index_note(vault.read("b.md"))
31
+ indexer.compute_backlinks()
32
+ engine = RAGEngine(config, store)
33
+ results = engine.search("b", mode="graph")
34
+ assert any(r.note_path == "b.md" for r in results)
35
+
36
+
37
+ def test_vector_search_fallback(config, vault, store):
38
+ vault.save(Note(path="v.md", title="V", content="MCP local second brain"))
39
+ indexer = Indexer(config, vault, store)
40
+ indexer.index_all()
41
+ engine = RAGEngine(config, store)
42
+ results = engine.search("MCP", mode="vector")
43
+ assert any(r.note_path == "v.md" for r in results)
44
+
45
+
46
+ def test_vector_search_fallback_ranks_matching_note(config, vault, store):
47
+ vault.save(Note(path="a.md", title="A", content="MCP local second brain"))
48
+ vault.save(Note(path="b.md", title="B", content="Another note about servers and embedding"))
49
+ indexer = Indexer(config, vault, store)
50
+ indexer.index_all()
51
+ engine = RAGEngine(config, store)
52
+ results = engine.search("MCP server", mode="vector")
53
+ assert results[0].note_path == "a.md"
54
+
55
+
56
+ def test_hybrid_search_normalizes_scores(config, vault, store):
57
+ vault.save(Note(path="a.md", title="A", content="MCP local second brain"))
58
+ vault.save(Note(path="b.md", title="B", content="Another note about servers and embedding"))
59
+ indexer = Indexer(config, vault, store)
60
+ indexer.index_all()
61
+ engine = RAGEngine(config, store)
62
+ results = engine.search("MCP", mode="hybrid")
63
+ assert results
64
+ assert all(0.0 <= r.score <= 1.0 for r in results)
@@ -0,0 +1,37 @@
1
+ import pytest
2
+
3
+ from obsidian_mcp.tools.registry import dispatch
4
+ from obsidian_mcp.vault.repository import VaultRepository
5
+
6
+
7
+ def test_read_note_returns_exact_file_content(config, temp_vault):
8
+ VaultRepository(config)
9
+ raw = "---\ntitle: Exact Note\ntags:\n - alpha\n - beta\n---\n\n# Exact Note\n\nKeep the frontmatter exactly as written."
10
+ (temp_vault / "exact.md").write_text(raw, encoding="utf-8")
11
+ result = dispatch("read_note", {"path": "exact.md"}, config)
12
+ assert result == raw
13
+
14
+
15
+ def test_read_note_does_not_inject_title_or_tags(config, temp_vault):
16
+ VaultRepository(config)
17
+ raw = "Plain body without title or tags."
18
+ (temp_vault / "plain.md").write_text(raw, encoding="utf-8")
19
+ result = dispatch("read_note", {"path": "plain.md"}, config)
20
+ assert result == raw
21
+ assert "# " not in result
22
+ assert "**Tags:**" not in result
23
+
24
+
25
+ def test_read_note_non_utf8_replacement(config, vault, temp_vault):
26
+ VaultRepository(config)
27
+ path = temp_vault / "binary.md"
28
+ path.write_bytes(b"\xff\xfeHello\xfa world")
29
+ result = dispatch("read_note", {"path": "binary.md"}, config)
30
+ assert "Hello" in result
31
+ assert "world" in result
32
+
33
+
34
+ def test_read_note_missing_raises(config):
35
+ VaultRepository(config)
36
+ with pytest.raises(FileNotFoundError):
37
+ dispatch("read_note", {"path": "missing.md"}, config)
@@ -0,0 +1,54 @@
1
+ from obsidian_mcp.indexer.indexer import Indexer
2
+ from obsidian_mcp.models import Chunk, Note
3
+ from obsidian_mcp.rag.engine import RAGEngine
4
+ from obsidian_mcp.rag.vector_search import TfidfIndex
5
+
6
+
7
+ def test_tfidf_index_fits_on_corpus():
8
+ index = TfidfIndex()
9
+ chunks = [
10
+ Chunk(id="c1", note_path="a.md", text="MCP local second brain"),
11
+ Chunk(id="c2", note_path="b.md", text="Another note about servers"),
12
+ ]
13
+ index.fit(chunks)
14
+ results = index.query("MCP", top_k=10)
15
+ assert any(doc_id == "c1" for doc_id, _ in results)
16
+
17
+
18
+ def test_tfidf_index_query_empty_corpus():
19
+ index = TfidfIndex()
20
+ index.fit([])
21
+ assert index.query("anything") == []
22
+
23
+
24
+ def test_vector_search_builds_corpus_fitted_index(config, vault, store):
25
+ vault.save(Note(path="a.md", title="A", content="MCP local second brain"))
26
+ indexer = Indexer(config, vault, store)
27
+ indexer.index_all()
28
+ engine = RAGEngine(config, store)
29
+ assert engine.vector_search._tfidf_index is None
30
+ results = engine.search("MCP", mode="vector")
31
+ assert results
32
+ assert engine.vector_search._tfidf_index is not None
33
+ assert engine.vector_search._tfidf_chunk_count > 0
34
+
35
+
36
+ def test_vector_search_reuses_fitted_index(config, vault, store):
37
+ vault.save(Note(path="a.md", title="A", content="MCP local second brain"))
38
+ indexer = Indexer(config, vault, store)
39
+ indexer.index_all()
40
+ engine = RAGEngine(config, store)
41
+ engine.search("MCP", mode="vector")
42
+ first_index = engine.vector_search._tfidf_index
43
+ engine.search("local", mode="vector")
44
+ assert engine.vector_search._tfidf_index is first_index
45
+
46
+
47
+ def test_vector_search_ranks_matching_note_highest(config, vault, store):
48
+ vault.save(Note(path="a.md", title="A", content="MCP local second brain"))
49
+ vault.save(Note(path="b.md", title="B", content="Another note about servers and embedding"))
50
+ indexer = Indexer(config, vault, store)
51
+ indexer.index_all()
52
+ engine = RAGEngine(config, store)
53
+ results = engine.search("MCP server", mode="vector")
54
+ assert results[0].note_path == "a.md"
@@ -0,0 +1,108 @@
1
+ import pytest
2
+
3
+ from obsidian_mcp.indexer.indexer import Indexer
4
+ from obsidian_mcp.indexer.store import IndexStore
5
+ from obsidian_mcp.models import Note
6
+ from obsidian_mcp.tools.registry import dispatch
7
+ from obsidian_mcp.vault.repository import VaultRepository
8
+
9
+
10
+ def test_tool_create_and_read(config):
11
+ VaultRepository(config)
12
+ result = dispatch("create_note", {"path": "t.md", "content": "Hello"}, config)
13
+ assert result["status"] == "created"
14
+ text = dispatch("read_note", {"path": "t.md"}, config)
15
+ assert "Hello" in text
16
+
17
+
18
+ def test_tool_create_note_adds_extension(config):
19
+ VaultRepository(config)
20
+ result = dispatch("create_note", {"path": "no-extension", "content": "body"}, config)
21
+ assert result["path"] == "no-extension.md"
22
+ text = dispatch("read_note", {"path": "no-extension.md"}, config)
23
+ assert "body" in text
24
+
25
+
26
+ def test_tool_create_note_title_from_path(config):
27
+ VaultRepository(config)
28
+ dispatch("create_note", {"path": "my-great-note.md", "content": "body"}, config)
29
+ text = dispatch("read_note", {"path": "my-great-note.md"}, config)
30
+ assert "My Great Note" in text
31
+
32
+
33
+ def test_tool_read_note_returns_raw_content(config, temp_vault):
34
+ VaultRepository(config)
35
+ dispatch(
36
+ "create_note",
37
+ {"path": "fm.md", "title": "FM", "content": "body", "tags": ["a", "b"]},
38
+ config,
39
+ )
40
+ raw = (temp_vault / "fm.md").read_text(encoding="utf-8")
41
+ text = dispatch("read_note", {"path": "fm.md"}, config)
42
+ assert text == raw
43
+ assert "body" in text
44
+ assert "FM" in text
45
+
46
+
47
+ def test_tool_search(config):
48
+ vault = VaultRepository(config)
49
+ vault.save(Note(path="s.md", title="S", content="MCP server local"))
50
+ indexer = Indexer(config, vault, IndexStore(config.index_dir / "index.db"))
51
+ indexer.index_all()
52
+ text = dispatch("search_notes", {"query": "MCP", "mode": "text"}, config)
53
+ assert "s.md" in text
54
+
55
+
56
+ def test_tool_privacy_blocks(config):
57
+ with pytest.raises(ValueError):
58
+ dispatch("create_note", {"path": "bad.md", "content": "API_KEY=secret"}, config)
59
+
60
+
61
+ def test_tool_update_and_delete(config):
62
+ VaultRepository(config)
63
+ dispatch("create_note", {"path": "u.md", "content": "Original", "tags": ["a"]}, config)
64
+ dispatch("update_note", {"path": "u.md", "append": "More", "tags": ["b"]}, config)
65
+ text = dispatch("read_note", {"path": "u.md"}, config)
66
+ assert "Original" in text
67
+ assert "More" in text
68
+ assert "b" in text
69
+ dispatch("delete_note", {"path": "u.md"}, config)
70
+ with pytest.raises(FileNotFoundError):
71
+ dispatch("read_note", {"path": "u.md"}, config)
72
+
73
+
74
+ def test_tool_list_and_related(config):
75
+ vault = VaultRepository(config)
76
+ vault.save(Note(path="x.md", title="X", content="Link to [[y]]."))
77
+ vault.save(Note(path="y.md", title="Y", content="Content."))
78
+ indexer = Indexer(config, vault, IndexStore(config.index_dir / "index.db"))
79
+ indexer.index_all()
80
+ indexer.compute_backlinks()
81
+ listed = dispatch("list_notes", {}, config)
82
+ assert "x.md" in listed
83
+ related = dispatch("get_related_notes", {"path": "x.md"}, config)
84
+ assert "y.md" in related
85
+
86
+
87
+ def test_tool_sync_from_bundle(config, temp_vault):
88
+ (temp_vault / "skills" / "orchestrator").mkdir(parents=True)
89
+ (temp_vault / "skills" / "orchestrator" / "SKILL.md").write_text("# Orchestrator\n")
90
+ (temp_vault / "README.md").write_text("# Bundle\n")
91
+ result = dispatch("sync_from_bundle", {"force": True}, config)
92
+ assert result["status"] == "synced"
93
+ assert result["indexed_bundle_files"] > 0
94
+
95
+
96
+ def test_tool_path_traversal_blocked(config):
97
+ with pytest.raises(ValueError):
98
+ dispatch("create_note", {"path": "../outside.md", "content": "x"}, config)
99
+
100
+
101
+ def test_tool_learn_from_text(config):
102
+ result = dispatch(
103
+ "learn_from_text",
104
+ {"text": "Novo conceito: Observability no projeto LEA."},
105
+ config,
106
+ )
107
+ assert result["status"] == "learned"
108
+ assert any("observability" in p.lower() for p in result["created_notes"])