obsidian-knowledge 3.19.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lib/__init__.py ADDED
File without changes
@@ -0,0 +1,19 @@
1
+ """Shared retrieval + primer library for obsidian-knowledge plugin.
2
+
3
+ Imported by both the Claude Code adapter (hooks/recall-init.py) and the
4
+ Hermes Agent CLI memory provider (hermes-plugin/__init__.py).
5
+ """
6
+ from lib.vault_index.config import VaultIndexConfig, load_config
7
+ from lib.vault_index.filters import apply_filters, score_path
8
+ from lib.vault_index.indexer import Hit, Indexer
9
+ from lib.vault_index.primer import build_primer
10
+
11
+ __all__ = [
12
+ "VaultIndexConfig",
13
+ "load_config",
14
+ "apply_filters",
15
+ "score_path",
16
+ "Hit",
17
+ "Indexer",
18
+ "build_primer",
19
+ ]
lib/vault_index/cli.py ADDED
@@ -0,0 +1,217 @@
1
+ """CLI entry points for obsidian-knowledge tooling."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ DEFAULT_VAULT_INDEX_TEMPLATE = """
12
+ # Vault index config — drives memweave retrieval, filtering, and weighting.
13
+ # Path patterns are Python regexes evaluated against vault-relative paths.
14
+ vault_index:
15
+ # What gets embedded at index time. Skipped paths are invisible to vault_search.
16
+ index:
17
+ allow_regex: []
18
+ deny_regex:
19
+ - "^Journal/"
20
+ - "^Inbox/"
21
+ - "^_sources/"
22
+ - "^\\\\.obsidian/"
23
+ - "^\\\\.config/"
24
+ - "^\\\\.stversions/"
25
+ - "^\\\\.trash/"
26
+ - "^Utility/obsidian-knowledge/cache/"
27
+
28
+ # What surfaces in default prefetch digest. Subset of indexed.
29
+ digest:
30
+ allow_regex:
31
+ - "^wiki/"
32
+ - "^.+/convos/"
33
+ deny_regex: []
34
+
35
+ # Score multipliers (longest-regex-match wins) applied before top-K truncation.
36
+ weights:
37
+ - regex: "^wiki/"
38
+ multiplier: 1.5
39
+ - regex: "^.+/convos/"
40
+ multiplier: 1.3
41
+ - regex: "^Utility/obsidian-knowledge/changelog/"
42
+ multiplier: 0.6
43
+
44
+ default_weight: 1.0
45
+ top_k: 5
46
+ # min_score: null # uncomment to set a hard cutoff
47
+ """
48
+
49
+
50
+ def init_vault_index(yaml_path: Path) -> None:
51
+ """Add a `vault_index:` section template to the per-vault config file.
52
+
53
+ No-op if the section already exists. Preserves any other sections.
54
+ """
55
+ if yaml_path.exists():
56
+ try:
57
+ existing = yaml.safe_load(yaml_path.read_text()) or {}
58
+ except yaml.YAMLError as exc:
59
+ print(f"error: malformed YAML in {yaml_path}: {exc}", file=sys.stderr)
60
+ sys.exit(1)
61
+ if "vault_index" in existing:
62
+ print(f"vault_index section already present in {yaml_path}; not modified.")
63
+ return
64
+ with yaml_path.open("a") as f:
65
+ f.write("\n" + DEFAULT_VAULT_INDEX_TEMPLATE)
66
+ else:
67
+ yaml_path.write_text(DEFAULT_VAULT_INDEX_TEMPLATE)
68
+ print(f"Wrote vault_index template to {yaml_path}")
69
+
70
+
71
+ def link_hermes_memories(vault_root: Path, hermes_memories_dir: Path) -> None:
72
+ """Symlink Hermes built-in MEMORY.md and USER.md into the vault.
73
+
74
+ Symlinks live at <vault>/Utility/obsidian-knowledge/hermes/{MEMORY,USER}.md.
75
+ Idempotent — overwrites existing symlinks.
76
+
77
+ NOTE: Obsidian linter must be configured to skip this directory before
78
+ symlinks go live, or the linter's frontmatter rewrites will corrupt the
79
+ section-sign delimiter format Hermes uses. See:
80
+ <vault>/.obsidian/plugins/obsidian-linter/data.json (excluded_paths)
81
+ """
82
+ if not hermes_memories_dir.exists():
83
+ raise FileNotFoundError(f"Hermes memories dir not found: {hermes_memories_dir}")
84
+
85
+ link_dir = vault_root / "Utility" / "obsidian-knowledge" / "hermes"
86
+ link_dir.mkdir(parents=True, exist_ok=True)
87
+
88
+ for filename in ("MEMORY.md", "USER.md"):
89
+ target = hermes_memories_dir / filename
90
+ link = link_dir / filename
91
+ if not target.exists():
92
+ print(f"Source missing, skipping: {target}", file=sys.stderr)
93
+ continue
94
+ if link.is_symlink() or link.exists():
95
+ link.unlink()
96
+ link.symlink_to(target)
97
+ print(f"Symlinked: {link} -> {target}")
98
+
99
+ print(
100
+ f"\nIMPORTANT: configure your Obsidian linter to exclude '{link_dir.relative_to(vault_root)}/' "
101
+ "before opening these files in Obsidian. The linter would corrupt Hermes's "
102
+ "section-sign delimiter format otherwise."
103
+ )
104
+
105
+
106
+ def main() -> int:
107
+ parser = argparse.ArgumentParser(prog="obsidian-knowledge")
108
+ sub = parser.add_subparsers(dest="cmd", required=True)
109
+
110
+ p_init = sub.add_parser(
111
+ "init-vault-index",
112
+ help="Add vault_index template to .claude/obsidian-knowledge.yaml",
113
+ )
114
+ p_init.add_argument("--vault", type=Path, default=Path.cwd(), help="Vault root (default: cwd)")
115
+
116
+ p_reindex = sub.add_parser("reindex", help="Run a full re-index of the vault")
117
+ p_reindex.add_argument("--vault", type=Path, default=Path.cwd())
118
+ p_reindex.add_argument("--force", action="store_true")
119
+
120
+ p_search = sub.add_parser("search", help="Hybrid (BM25+vector) search the vault index")
121
+ p_search.add_argument("query", help="Free-text query")
122
+ p_search.add_argument("--vault", type=Path, default=Path.cwd())
123
+ p_search.add_argument("--top-k", type=int, default=None)
124
+ p_search.add_argument(
125
+ "--all",
126
+ action="store_true",
127
+ help="Override digest filter (include paths normally hidden from prefetch).",
128
+ )
129
+
130
+ p_link = sub.add_parser(
131
+ "link-hermes-memories",
132
+ help="Symlink Hermes MEMORY.md/USER.md into the vault",
133
+ )
134
+ p_link.add_argument("--vault", type=Path, default=Path.cwd())
135
+ p_link.add_argument(
136
+ "--hermes-memories-dir",
137
+ type=Path,
138
+ default=Path.home() / ".hermes" / "memories",
139
+ )
140
+
141
+ args = parser.parse_args()
142
+
143
+ if args.cmd == "init-vault-index":
144
+ init_vault_index(args.vault / ".claude" / "obsidian-knowledge.yaml")
145
+ elif args.cmd == "reindex":
146
+ import fcntl
147
+ import memweave
148
+ from lib.vault_index.config import load_config
149
+ from lib.vault_index.indexer import Indexer, default_cache_dir
150
+
151
+ cfg = load_config(args.vault / ".claude" / "obsidian-knowledge.yaml")
152
+ cache = default_cache_dir(args.vault)
153
+ cache.mkdir(parents=True, exist_ok=True)
154
+ lock_path = cache / ".reindex.lock"
155
+ with open(lock_path, "w") as lock_f:
156
+ try:
157
+ fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
158
+ except BlockingIOError:
159
+ print(
160
+ "reindex: another reindex is in progress (lock held); exiting cleanly.",
161
+ file=sys.stderr,
162
+ )
163
+ return 0
164
+ idx = Indexer(vault_root=args.vault, cache_dir=cache, config=cfg)
165
+ stats = idx.full_reindex(force=args.force)
166
+ print(
167
+ f"Indexed: {stats.indexed}, Skipped: {stats.skipped}, Deleted: {stats.deleted}",
168
+ flush=True,
169
+ )
170
+ elif args.cmd == "link-hermes-memories":
171
+ link_hermes_memories(args.vault, args.hermes_memories_dir)
172
+ elif args.cmd == "search":
173
+ from lib.vault_index.config import load_config
174
+ from lib.vault_index.indexer import Indexer, default_cache_dir
175
+
176
+ cfg = load_config(args.vault / ".claude" / "obsidian-knowledge.yaml")
177
+ cache = default_cache_dir(args.vault)
178
+ idx = Indexer(vault_root=args.vault, cache_dir=cache, config=cfg)
179
+ if not idx._vector_enabled:
180
+ print(f"# vector lane off ({idx.vector_status}); FTS-only", file=sys.stderr)
181
+ hits = idx.search(args.query, top_k=args.top_k, override_digest_filter=args.all)
182
+ if not hits:
183
+ print("(no results)")
184
+ return 0
185
+ for h in hits:
186
+ print(f"{h.score:6.1f} {h.path}")
187
+
188
+ return 0
189
+
190
+
191
+ def _exit_hard(code: int) -> None:
192
+ """Flush stdio then `os._exit` to skip Python's atexit/asyncio shutdown.
193
+
194
+ memweave/litellm/aiohttp leave non-daemon threads or pending tasks alive
195
+ after `idx.full_reindex()` and `idx.search()` finish, which makes the
196
+ interpreter hang on shutdown — confirmed on both dream-machine (Linux
197
+ Python 3.13) and mac mini (Apple Silicon Python 3.13), where the hourly
198
+ cron piled up zombie `reindex` processes overnight. Force-exit is the
199
+ same workaround used in `hermes_plugin` for the asyncio-daemon-thread
200
+ mismatch.
201
+
202
+ Called from `cli_main()` (the console-script entry point) so it applies
203
+ whether the CLI is invoked via `python -m lib.vault_index.cli` or via
204
+ the `obsidian-knowledge` entry point.
205
+ """
206
+ sys.stdout.flush()
207
+ sys.stderr.flush()
208
+ os._exit(code)
209
+
210
+
211
+ def cli_main() -> None:
212
+ """Console-script entry point. See [project.scripts] in pyproject.toml."""
213
+ _exit_hard(main())
214
+
215
+
216
+ if __name__ == "__main__":
217
+ cli_main()
@@ -0,0 +1,44 @@
1
+ """Pydantic models + YAML loader for the vault_index config section."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class IndexFilter(BaseModel):
11
+ allow_regex: list[str] = Field(default_factory=list)
12
+ deny_regex: list[str] = Field(default_factory=list)
13
+
14
+
15
+ class DigestFilter(BaseModel):
16
+ allow_regex: list[str] = Field(default_factory=list)
17
+ deny_regex: list[str] = Field(default_factory=list)
18
+
19
+
20
+ class WeightRule(BaseModel):
21
+ regex: str
22
+ multiplier: float
23
+
24
+
25
+ class VaultIndexConfig(BaseModel):
26
+ index: IndexFilter = Field(default_factory=IndexFilter)
27
+ digest: DigestFilter = Field(default_factory=DigestFilter)
28
+ weights: list[WeightRule] = Field(default_factory=list)
29
+ default_weight: float = 1.0
30
+ top_k: int = 5
31
+ min_score: float | None = None
32
+
33
+
34
+ def load_config(yaml_path: Path) -> VaultIndexConfig:
35
+ """Load `vault_index:` section from `<vault>/.claude/obsidian-knowledge.yaml`.
36
+
37
+ Returns defaults if the section is missing. Raises pydantic ValidationError
38
+ if the section is malformed.
39
+ """
40
+ if not yaml_path.exists():
41
+ return VaultIndexConfig()
42
+ raw = yaml.safe_load(yaml_path.read_text()) or {}
43
+ section = raw.get("vault_index", {}) or {}
44
+ return VaultIndexConfig.model_validate(section)
@@ -0,0 +1,86 @@
1
+ """Path-based filtering and weighting."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from lib.vault_index.config import DigestFilter, IndexFilter, VaultIndexConfig
7
+
8
+
9
+ def score_path(path: str, config: VaultIndexConfig) -> float:
10
+ """Return the weight multiplier for `path` per config weights.
11
+
12
+ Longest regex match wins. Ties broken by first-listed rule.
13
+ Falls back to `default_weight` if no rule matches.
14
+ """
15
+ best_match_len = -1
16
+ best_multiplier = config.default_weight
17
+ for rule in config.weights:
18
+ m = re.search(rule.regex, path)
19
+ if m is None:
20
+ continue
21
+ match_len = m.end() - m.start()
22
+ if match_len > best_match_len:
23
+ best_match_len = match_len
24
+ best_multiplier = rule.multiplier
25
+ return best_multiplier
26
+
27
+
28
+ def path_passes(path: str, filt: IndexFilter | DigestFilter) -> bool:
29
+ """Return True if `path` passes the allow/deny filter rules.
30
+
31
+ Logic:
32
+ - If `deny_regex` matches, fail.
33
+ - Else if `allow_regex` is non-empty, require a match.
34
+ - Else pass.
35
+ """
36
+ for pattern in filt.deny_regex:
37
+ if re.search(pattern, path):
38
+ return False
39
+ if filt.allow_regex:
40
+ return any(re.search(p, path) for p in filt.allow_regex)
41
+ return True
42
+
43
+
44
+ def apply_filters(
45
+ hits: list,
46
+ config: VaultIndexConfig,
47
+ *,
48
+ override_digest_filter: bool = False,
49
+ ) -> list:
50
+ """Apply digest filters + path weights to a list of hits.
51
+
52
+ Pipeline:
53
+ 1. (Unless overridden) drop hits failing the digest allow/deny.
54
+ 2. Multiply each hit's score by its path weight.
55
+ 3. Re-sort descending by weighted score.
56
+ 4. Truncate to `config.top_k`.
57
+ 5. (If `min_score` set) drop weighted scores below threshold.
58
+ """
59
+ from lib.vault_index.indexer import Hit
60
+
61
+ if not override_digest_filter:
62
+ hits = [h for h in hits if path_passes(h.path, config.digest)]
63
+
64
+ weighted: list[Hit] = []
65
+ for h in hits:
66
+ w = score_path(h.path, config)
67
+ weighted.append(Hit(
68
+ path=h.path,
69
+ score=h.score * w,
70
+ weight_applied=w,
71
+ ))
72
+
73
+ # Dedup by path — keep highest-scoring chunk per path so a note with
74
+ # multiple matching paragraphs doesn't crowd out other results.
75
+ best: dict[str, Hit] = {}
76
+ for h in weighted:
77
+ if h.path not in best or best[h.path].score < h.score:
78
+ best[h.path] = h
79
+ weighted = list(best.values())
80
+
81
+ weighted.sort(key=lambda h: h.score, reverse=True)
82
+
83
+ if config.min_score is not None:
84
+ weighted = [h for h in weighted if h.score >= config.min_score]
85
+
86
+ return weighted[: config.top_k]
@@ -0,0 +1,398 @@
1
+ """memweave wrapper for vault retrieval.
2
+
3
+ Hybrid retrieval over a vault: BM25 (FTS5) + dense vectors (Ollama/local
4
+ embeddings via LiteLLM), fused by memweave. Vector lane is on by default;
5
+ the wrapper probes Ollama at construction and degrades to FTS-only if the
6
+ embedding endpoint is unreachable or the chosen model isn't pulled.
7
+
8
+ memweave API notes (verified against memweave source, 2026-05-09):
9
+ - Main class is ``memweave.MemWeave`` (not ``memweave.Store``).
10
+ - All public methods are async coroutines — wrapped here with asyncio.run().
11
+ - File discovery uses ``workspace_dir/memory/`` + ``extra_paths``. We leave
12
+ ``workspace_dir/memory/`` empty and drive indexing entirely via
13
+ ``extra_paths`` (set to the filtered vault file list on each reindex).
14
+ - Stored file paths are absolute. We convert to vault-relative on output.
15
+ - ``IndexResult`` fields: ``files_indexed``, ``files_skipped``, ``files_deleted``.
16
+ - ``status().files`` gives file count.
17
+ - Deletion of stale files is handled by ``store.index()`` comparing
18
+ ``extra_paths`` to the DB's stored paths.
19
+ - ``sync.on_search=True`` (memweave default) triggers an auto-reindex on every
20
+ search, which DELETES all stored chunks when extra_paths=[]. We always set
21
+ ``sync=SyncConfig(on_search=False)`` to prevent destructive auto-sync.
22
+ - Search ``min_score`` defaults to 0.35 in memweave QueryConfig. We pass 0.0
23
+ and let ``apply_filters`` handle thresholding.
24
+
25
+ Embedding defaults (this wrapper):
26
+ - Model: ``ollama/bge-m3`` (8192-token context, multilingual, ~2.3GB).
27
+ Picked over mxbai-embed-large because mxbai's 512-token context overran
28
+ on long uninterrupted paragraphs (logged in changelog 2026-05-09).
29
+ - API base: ``http://127.0.0.1:11434`` (Ollama default).
30
+ - API key: empty placeholder (LiteLLM/Ollama doesn't require one).
31
+ - Chunking: tokens=320, overlap=64 — well under bge-m3's 8192 ceiling, and
32
+ also safe if a user swaps in a 512-ctx model later.
33
+ - Override any of the above via ``MEMWEAVE_EMBEDDING_MODEL``,
34
+ ``MEMWEAVE_EMBEDDING_API_BASE``, ``MEMWEAVE_EMBEDDING_API_KEY``.
35
+
36
+ Fail-soft behavior: ``vector_enabled=True`` by default. On construction we do
37
+ a quick HTTP probe of the Ollama tags endpoint; if it fails or the chosen
38
+ model isn't listed, we silently flip vector off for this process and surface
39
+ the reason via ``self.vector_status``. The doctor SessionStart hook reads
40
+ the same probe and prints a one-line reminder.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import asyncio
45
+ import hashlib
46
+ import os
47
+ import re
48
+ import urllib.error
49
+ import urllib.request
50
+ import json
51
+ from dataclasses import dataclass
52
+ from pathlib import Path
53
+
54
+ import memweave
55
+ import platformdirs
56
+ from pydantic import BaseModel
57
+
58
+ from lib.vault_index.config import VaultIndexConfig
59
+ from lib.vault_index.filters import path_passes
60
+
61
+
62
+ DEFAULT_EMBEDDING_MODEL = "ollama/bge-m3"
63
+ DEFAULT_EMBEDDING_API_BASE = "http://127.0.0.1:11434"
64
+ DEFAULT_CHUNK_TOKENS = 320
65
+ DEFAULT_CHUNK_OVERLAP = 64
66
+ PROBE_TIMEOUT_S = 1.5
67
+ FINGERPRINT_FILENAME = "embedder-fingerprint.txt"
68
+ APP_NAME = "obsidian-knowledge"
69
+
70
+
71
+ def default_cache_dir(vault_root: Path) -> Path:
72
+ """Per-host, per-vault cache directory outside the vault.
73
+
74
+ Returns ``<XDG_CACHE_HOME or ~/.cache>/obsidian-knowledge/<vault-key>/``
75
+ on Linux and ``~/Library/Caches/obsidian-knowledge/<vault-key>/`` on macOS.
76
+ The vault-key combines the directory's basename with an 8-char hash of its
77
+ absolute path so two vaults with the same dir name (e.g. ``obsidian/``
78
+ on different machines or in different parents) never collide.
79
+
80
+ Storing the cache outside the vault eliminates the Syncthing-conflict risk
81
+ entirely: each host owns its own embeddings DB, no replication, no race.
82
+ """
83
+ abs_path = str(vault_root.resolve())
84
+ digest = hashlib.sha256(abs_path.encode()).hexdigest()[:8]
85
+ safe_name = re.sub(r"[^a-zA-Z0-9._-]", "-", vault_root.resolve().name) or "vault"
86
+ return Path(platformdirs.user_cache_dir(APP_NAME)) / f"{safe_name}-{digest}"
87
+
88
+
89
+ def _ollama_probe(api_base: str, model: str) -> tuple[bool, str]:
90
+ """Check Ollama is up and the model is pulled. Returns (ok, message).
91
+
92
+ Strips the LiteLLM ``ollama/`` prefix from ``model`` before comparison
93
+ against Ollama's tag list. Tags appear as e.g. ``bge-m3:latest``; we
94
+ match on the bare name (everything before ``:``).
95
+ """
96
+ if not api_base.startswith("http"):
97
+ return False, f"api_base not http(s): {api_base}"
98
+ bare = model.split("/", 1)[1] if "/" in model else model
99
+ bare_name = bare.split(":", 1)[0]
100
+ url = api_base.rstrip("/") + "/api/tags"
101
+ try:
102
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
103
+ with urllib.request.urlopen(req, timeout=PROBE_TIMEOUT_S) as resp:
104
+ data = json.loads(resp.read())
105
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
106
+ return False, f"Ollama unreachable at {api_base}: {exc}"
107
+ except json.JSONDecodeError as exc:
108
+ return False, f"Ollama returned non-JSON: {exc}"
109
+ tags = [t.get("name", "") for t in data.get("models", [])]
110
+ if not any(name.split(":", 1)[0] == bare_name for name in tags):
111
+ return False, f"model '{bare_name}' not pulled (have: {tags or 'none'})"
112
+ return True, f"ok: {bare_name} via {api_base}"
113
+
114
+
115
+ class Hit(BaseModel):
116
+ path: str
117
+ score: float
118
+ weight_applied: float = 1.0
119
+
120
+
121
+ @dataclass
122
+ class SyncStats:
123
+ indexed: int
124
+ skipped: int
125
+ deleted: int
126
+
127
+
128
+ def _rescale(raw: float) -> float:
129
+ """Multiply raw BM25/hybrid score by 100 for readable digest display."""
130
+ return round(raw * 100, 1)
131
+
132
+
133
+
134
+
135
+ class Indexer:
136
+ """Vault-aware wrapper around memweave.
137
+
138
+ Uses memweave's FTS5 (BM25) backend for keyword retrieval. Index-time
139
+ path filtering is applied by computing the allowed file list before each
140
+ ``index()`` call and passing it as ``extra_paths`` — files excluded by
141
+ config never enter the store.
142
+
143
+ All memweave calls are async internally; this class exposes a synchronous
144
+ interface via ``asyncio.run()``.
145
+
146
+ A single ``MemWeave`` instance is created at construction and reused for
147
+ all operations. ``full_reindex()`` closes and recreates it with updated
148
+ ``extra_paths`` so memweave's deletion detection sees exactly the
149
+ currently-allowed file set.
150
+ """
151
+
152
+ def __init__(
153
+ self,
154
+ vault_root: Path,
155
+ cache_dir: Path,
156
+ config: VaultIndexConfig,
157
+ *,
158
+ vector_enabled: bool = True,
159
+ skip_probe: bool = False,
160
+ ):
161
+ self.vault_root = vault_root
162
+ self.cache_dir = cache_dir
163
+ self.config = config
164
+ self._vector_enabled_requested = vector_enabled
165
+ self._vector_enabled = vector_enabled
166
+ self.vector_status = "disabled-by-caller"
167
+ cache_dir.mkdir(parents=True, exist_ok=True)
168
+
169
+ if vector_enabled and not skip_probe:
170
+ model, api_base, _ = self._embedding_settings()
171
+ ok, msg = _ollama_probe(api_base, model)
172
+ self.vector_status = msg
173
+ if not ok:
174
+ self._vector_enabled = False
175
+
176
+ # Lazy auto-rebuild flag: set to True if the stored embedder fingerprint
177
+ # differs from the current one. The next .search() or .full_reindex()
178
+ # call will trigger a force-rebuild before serving results.
179
+ self._needs_rebuild = self._stale_fingerprint() if self._vector_enabled else False
180
+
181
+ # Start with an empty-extra_paths store for read-only queries.
182
+ # full_reindex() will replace this with the full allowed-paths store.
183
+ self._store = memweave.MemWeave(
184
+ self._make_config(extra_paths=[])
185
+ )
186
+
187
+ def _embedder_fingerprint(self) -> str:
188
+ """Stable string identifying the current embedding setup.
189
+
190
+ Format: ``<model>@<chunk_tokens>/<chunk_overlap>``. Any change to
191
+ model name or chunking parameters invalidates the existing index
192
+ because chunk boundaries and vector dimensions may both shift.
193
+ """
194
+ model, _api_base, _ = self._embedding_settings()
195
+ return f"{model}@{DEFAULT_CHUNK_TOKENS}/{DEFAULT_CHUNK_OVERLAP}"
196
+
197
+ def _fingerprint_path(self) -> Path:
198
+ return self.cache_dir / FINGERPRINT_FILENAME
199
+
200
+ def _read_stored_fingerprint(self) -> str | None:
201
+ try:
202
+ return self._fingerprint_path().read_text().strip()
203
+ except OSError:
204
+ return None
205
+
206
+ def _write_fingerprint(self) -> None:
207
+ try:
208
+ self._fingerprint_path().write_text(self._embedder_fingerprint())
209
+ except OSError:
210
+ pass
211
+
212
+ def _stale_fingerprint(self) -> bool:
213
+ """True iff a populated index exists but was built with a different embedder.
214
+
215
+ We only consider the fingerprint stale when there is *something* to
216
+ invalidate — an empty cache or a never-indexed vault is a fresh install,
217
+ not a model swap, so we don't auto-rebuild on first run.
218
+ """
219
+ db = self.cache_dir / "index.sqlite"
220
+ if not db.exists():
221
+ return False
222
+ stored = self._read_stored_fingerprint()
223
+ if stored is None:
224
+ # Pre-3.15 cache: built FTS-only, no fingerprint. If vectors are
225
+ # enabled now, we need a rebuild to populate chunks_vec.
226
+ return True
227
+ return stored != self._embedder_fingerprint()
228
+
229
+ @staticmethod
230
+ def _embedding_settings() -> tuple[str, str, str | None]:
231
+ """Resolve (model, api_base, api_key) from env with bge-m3/Ollama defaults."""
232
+ model = os.environ.get("MEMWEAVE_EMBEDDING_MODEL", DEFAULT_EMBEDDING_MODEL)
233
+ api_base = os.environ.get(
234
+ "MEMWEAVE_EMBEDDING_API_BASE", DEFAULT_EMBEDDING_API_BASE
235
+ )
236
+ api_key = os.environ.get("MEMWEAVE_EMBEDDING_API_KEY")
237
+ return model, api_base, api_key
238
+
239
+ def _make_config(
240
+ self,
241
+ extra_paths: list[str],
242
+ ) -> memweave.MemoryConfig:
243
+ """Build a MemoryConfig for our vault-wrapper use case.
244
+
245
+ Key non-defaults:
246
+ - ``progress=False`` — suppress rich/spinner output.
247
+ - ``sync.on_search=False`` — prevent auto-reindex on search.
248
+ - ``vector.enabled`` — driven by the constructor-resolved value
249
+ (probe may have flipped it off).
250
+ - ``chunking`` — 320/64 (vs memweave default 400/80) so even a
251
+ 512-token-context model can't overrun.
252
+ - ``embedding`` — defaults to ``ollama/bge-m3`` at
253
+ ``http://127.0.0.1:11434``. Override via ``MEMWEAVE_EMBEDDING_MODEL``,
254
+ ``MEMWEAVE_EMBEDDING_API_BASE``, ``MEMWEAVE_EMBEDDING_API_KEY``.
255
+ """
256
+ model, api_base, api_key = self._embedding_settings()
257
+ embedding_kwargs: dict = {"model": model, "api_base": api_base}
258
+ if api_key is not None:
259
+ embedding_kwargs["api_key"] = api_key
260
+
261
+ return memweave.MemoryConfig(
262
+ workspace_dir=str(self.cache_dir),
263
+ db_path=str(self.cache_dir / "index.sqlite"),
264
+ progress=False,
265
+ extra_paths=extra_paths,
266
+ embedding=memweave.EmbeddingConfig(**embedding_kwargs),
267
+ chunking=memweave.ChunkingConfig(
268
+ tokens=DEFAULT_CHUNK_TOKENS, overlap=DEFAULT_CHUNK_OVERLAP
269
+ ),
270
+ vector=memweave.VectorConfig(enabled=self._vector_enabled),
271
+ sync=memweave.SyncConfig(on_search=False),
272
+ )
273
+
274
+ def _abs_to_rel(self, abs_path: str) -> str:
275
+ """Convert an absolute path to vault-relative. Passthrough if outside vault."""
276
+ try:
277
+ return str(Path(abs_path).relative_to(self.vault_root))
278
+ except ValueError:
279
+ return abs_path
280
+
281
+ def row_count(self) -> int:
282
+ """Number of files currently indexed."""
283
+ status = asyncio.run(self._store.status())
284
+ return status.files
285
+
286
+ def indexed_paths(self) -> list[str]:
287
+ """All currently-indexed paths, relative to vault_root."""
288
+ files = asyncio.run(self._store.files())
289
+ return [self._abs_to_rel(f.path) for f in files]
290
+
291
+ def _allowed_vault_files(self) -> list[str]:
292
+ """Return absolute paths of all vault .md files passing index filters."""
293
+ allowed = []
294
+ for abs_path in self.vault_root.rglob("*.md"):
295
+ rel = str(abs_path.relative_to(self.vault_root))
296
+ if path_passes(rel, self.config.index):
297
+ allowed.append(str(abs_path))
298
+ return allowed
299
+
300
+ def full_reindex(self, force: bool = False) -> SyncStats:
301
+ """Walk vault, index every markdown file matching index filters.
302
+
303
+ Closes the current MemWeave instance and creates a new one with
304
+ ``extra_paths`` set to the filtered file list. memweave's ``index()``
305
+ handles hash-skip logic (unchanged files are skipped unless
306
+ ``force=True``) and automatic deletion of stale DB entries (paths
307
+ previously in the DB that are no longer in ``extra_paths``).
308
+
309
+ Writes the embedder fingerprint after a successful run so the next
310
+ Indexer init can detect a model change and auto-rebuild.
311
+ """
312
+ # Close the current store before opening a new one on the same DB.
313
+ asyncio.run(self._store.close())
314
+
315
+ allowed = self._allowed_vault_files()
316
+ self._store = memweave.MemWeave(
317
+ self._make_config(extra_paths=allowed)
318
+ )
319
+ result = asyncio.run(self._store.index(force=force))
320
+ self._write_fingerprint()
321
+ self._needs_rebuild = False
322
+ return SyncStats(
323
+ indexed=result.files_indexed,
324
+ skipped=result.files_skipped,
325
+ deleted=result.files_deleted,
326
+ )
327
+
328
+ def _auto_rebuild(self, reason: str) -> None:
329
+ """Force-rebuild the index, printing a notice. Used for embedder swaps."""
330
+ import sys
331
+ stored = self._read_stored_fingerprint() or "none"
332
+ current = self._embedder_fingerprint()
333
+ print(
334
+ f"# vault-index: rebuilding ({reason}; was {stored}, now {current})",
335
+ file=sys.stderr,
336
+ flush=True,
337
+ )
338
+ self.full_reindex(force=True)
339
+
340
+ def sync(self) -> SyncStats:
341
+ """Incremental re-index. Same as full_reindex without force."""
342
+ return self.full_reindex(force=False)
343
+
344
+ def search(
345
+ self,
346
+ query: str,
347
+ *,
348
+ top_k: int | None = None,
349
+ min_score: float | None = None,
350
+ override_digest_filter: bool = False,
351
+ ) -> list[Hit]:
352
+ """Run FTS retrieval; apply digest filter + weights; rescale; truncate.
353
+
354
+ Always passes ``min_score=0.0`` to memweave so the default 0.35
355
+ threshold (which filters out short-document BM25 scores) doesn't
356
+ silently drop results. Score thresholding is applied by ``apply_filters``
357
+ using ``config.min_score``.
358
+ """
359
+ from lib.vault_index.filters import apply_filters
360
+
361
+ effective_top_k = top_k or self.config.top_k
362
+ # Request more candidates than needed so apply_filters has room to filter.
363
+ candidate_count = max(50, effective_top_k * 5)
364
+
365
+ # Auto-rebuild when the embedder fingerprint changed since the last
366
+ # successful index. This keeps "swap MEMWEAVE_EMBEDDING_MODEL and run
367
+ # /vault-search" from blowing up on a stale chunks_vec.
368
+ if self._needs_rebuild:
369
+ self._auto_rebuild(reason="embedder changed")
370
+
371
+ try:
372
+ raw = asyncio.run(
373
+ self._store.search(query, max_results=candidate_count, min_score=0.0)
374
+ )
375
+ except memweave.SearchError as exc:
376
+ # Defensive net: reindex didn't run for some reason but the index
377
+ # is missing chunks_vec. Trigger a rebuild and retry once.
378
+ if "chunks_vec" in str(exc) and self._vector_enabled:
379
+ self._auto_rebuild(reason="chunks_vec missing")
380
+ raw = asyncio.run(
381
+ self._store.search(
382
+ query, max_results=candidate_count, min_score=0.0
383
+ )
384
+ )
385
+ else:
386
+ raise
387
+ hits = [
388
+ Hit(path=self._abs_to_rel(r.path), score=_rescale(r.score), weight_applied=1.0)
389
+ for r in raw
390
+ ]
391
+
392
+ cfg = self.config
393
+ if top_k is not None:
394
+ cfg = cfg.model_copy(update={"top_k": top_k})
395
+ if min_score is not None:
396
+ cfg = cfg.model_copy(update={"min_score": min_score})
397
+
398
+ return apply_filters(hits, cfg, override_digest_filter=override_digest_filter)
@@ -0,0 +1,58 @@
1
+ """Harness primer text. Single source of truth for both CC and Hermes adapters."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def _resolve_memory_target(cwd: str):
10
+ """Lazy import; the resolver lives under hooks/ which isn't always on path."""
11
+ plugin_root = Path(__file__).resolve().parent.parent.parent
12
+ hooks_dir = plugin_root / "hooks"
13
+ if str(hooks_dir) not in sys.path:
14
+ sys.path.insert(0, str(hooks_dir))
15
+ from hookslib.repo_memory import resolve_target # noqa: WPS433
16
+ return resolve_target(cwd)
17
+
18
+
19
+ def build_primer(
20
+ vault_root: Path,
21
+ plugin_root: Path,
22
+ cwd: str | None = None,
23
+ ) -> str:
24
+ """Build the harness primer text injected into every session.
25
+
26
+ Loaded into every session's context. Must stand alone — agents that
27
+ read only this primer should know what to do.
28
+
29
+ `cwd` (optional) lets the primer surface the *exact* per-repo or per-host
30
+ memory directory the agent should write to. Defaults to os.getcwd().
31
+ """
32
+ wiki = vault_root / "wiki"
33
+ target = _resolve_memory_target(cwd or os.getcwd())
34
+ memory_dir = wiki / target.rel_path
35
+ if target.kind == "repo":
36
+ scope_desc = f"this repo ({target.owner}/{target.repo})"
37
+ else:
38
+ scope_desc = f"this host ({target.hostname}) — cwd is not in a git repo"
39
+ return (
40
+ "You are operating under the obsidian-knowledge harness.\n"
41
+ f"- Knowledge: Obsidian wiki at {wiki}/ is the persistent memory store — "
42
+ "search it before answering non-trivial questions with `/vault-search <query>` "
43
+ "(hybrid BM25 + dense-embedding retrieval; ranked top-K paths). "
44
+ f"Fall back to `rg <pattern> {wiki}/` only for exact-string lookups. "
45
+ "File outcomes at session end (`remember-conversations` skill) — this creates a terse changelog entry and any diary/convo notes. "
46
+ "Do NOT use Claude's built-in MEMORY.md system; the wiki is the source of truth.\n"
47
+ f"- Per-session agent memory ({scope_desc}) lives at "
48
+ f"{memory_dir}/. Use the same MEMORY.md + per-fact .md file layout as "
49
+ "Claude's native auto-memory, but stored in the vault so it's portable, "
50
+ "syncs across hosts, and is searchable. Read MEMORY.md there at session "
51
+ "start; append new feedback/project/reference facts there, not under "
52
+ "~/.claude/projects/*/memory/ (a PreToolUse hook will block that).\n"
53
+ "- Reflect on friction: if you struggle with the harness, hit unexpected "
54
+ "blocks, or repeat the same workaround, invoke `/improve-harness`.\n"
55
+ "- Reflect on user frustration: if the user expresses frustration "
56
+ "('fuck', 'wtf', 'this keeps happening'), invoke `/improve-harness`. "
57
+ "The agent is not the unit of analysis — the system is."
58
+ )
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: obsidian-knowledge
3
+ Version: 3.19.0
4
+ Summary: Obsidian vault knowledge integration for Claude Code and Hermes Agent CLI
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: memweave>=0.1
8
+ Requires-Dist: platformdirs>=4.9.6
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: pyyaml>=6.0
@@ -0,0 +1,12 @@
1
+ lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ lib/vault_index/__init__.py,sha256=CAoRrkN4hqEDke4QiL0NSEDp94VdylIjknKvXc2KHfA,574
3
+ lib/vault_index/cli.py,sha256=NhGFYbzdB4Jwm3ZZotXTEjfmX8llyWvqoHc0Ck1GQt8,7985
4
+ lib/vault_index/config.py,sha256=-lqrwZskSWhvsG5XbmlE9fsLe3_1qAUpxHozVJwVuPA,1328
5
+ lib/vault_index/filters.py,sha256=6Pl2DkkWmisOSUasLDX48aG4HPJdKUiuPWCzRSm3p7I,2662
6
+ lib/vault_index/indexer.py,sha256=gYR8asRyqgWmRWnx-RlT_pjZ7Dj1RbpAl3CBgn-4-1s,16455
7
+ lib/vault_index/primer.py,sha256=3XExEozDaSAcyWOVvgDtMl9EHK2W3_lEfVSeSQiKBLE,2834
8
+ obsidian_knowledge-3.19.0.dist-info/METADATA,sha256=FSerM37BT4Oxs_MwknaDRgVjBrlnLeJGbKhzcJ5eRfc,312
9
+ obsidian_knowledge-3.19.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ obsidian_knowledge-3.19.0.dist-info/entry_points.txt,sha256=hD9HHnEfFRRRHSpfydJ6JncPK99Gfd0keXvnX-p-GQQ,68
11
+ obsidian_knowledge-3.19.0.dist-info/licenses/LICENSE,sha256=cmlMn4TweDeX2VOmTt41lUJZplQlVBO8e5bjLKLzz-M,1071
12
+ obsidian_knowledge-3.19.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ obsidian-knowledge = lib.vault_index.cli:cli_main
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ricardo Decal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+