@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,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"])
|