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 +0 -0
- lib/vault_index/__init__.py +19 -0
- lib/vault_index/cli.py +217 -0
- lib/vault_index/config.py +44 -0
- lib/vault_index/filters.py +86 -0
- lib/vault_index/indexer.py +398 -0
- lib/vault_index/primer.py +58 -0
- obsidian_knowledge-3.19.0.dist-info/METADATA +10 -0
- obsidian_knowledge-3.19.0.dist-info/RECORD +12 -0
- obsidian_knowledge-3.19.0.dist-info/WHEEL +4 -0
- obsidian_knowledge-3.19.0.dist-info/entry_points.txt +2 -0
- obsidian_knowledge-3.19.0.dist-info/licenses/LICENSE +22 -0
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,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
|
+
|