claude-code-arcane 2.0.0 → 2.1.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/CHANGELOG.md +17 -0
- package/README.md +4 -3
- package/docs/SKILLS-CATALOG.md +25 -2
- package/docs/USER-GUIDE.md +2 -2
- package/package.json +1 -1
- package/profiles/second-brain.yaml +37 -0
- package/rules/vault-conventions.md +144 -0
- package/skills/brain-dump/SKILL.md +53 -0
- package/skills/hub-note/SKILL.md +68 -0
- package/skills/obsidian-bases/SKILL.md +85 -0
- package/skills/obsidian-bases/references/bases-syntax.md +123 -0
- package/skills/obsidian-canvas/SKILL.md +91 -0
- package/skills/obsidian-markdown/SKILL.md +110 -0
- package/skills/pdf-generator/scripts/fill_inspect.py +2 -0
- package/skills/pdf-generator/scripts/render_body.py +2 -0
- package/skills/review-dump/SKILL.md +108 -0
- package/skills/review-monthly/SKILL.md +56 -0
- package/skills/review-weekly/SKILL.md +91 -0
- package/skills/second-brain/SKILL.md +211 -0
- package/skills/second-brain/hooks/hooks.json +16 -0
- package/skills/second-brain/hooks/validate-note.sh +85 -0
- package/skills/second-brain/references/cross-project.md +57 -0
- package/skills/second-brain/references/templates/Atomic.md +22 -0
- package/skills/second-brain/references/templates/Daily.md +25 -0
- package/skills/second-brain/references/templates/Dashboard.base +70 -0
- package/skills/second-brain/references/templates/Dump.md +11 -0
- package/skills/second-brain/references/templates/Hub.md +22 -0
- package/skills/second-brain/references/templates/Monthly.md +29 -0
- package/skills/second-brain/references/templates/Weekly.md +29 -0
- package/skills/second-brain/references/templates/codebases.md +37 -0
- package/skills/second-brain/references/templates/hot.md +37 -0
- package/skills/second-brain/references/templates/vault-CLAUDE.md +127 -0
- package/skills/vault-audit/SKILL.md +72 -0
- package/skills/vault-audit/scripts/vault_audit.py +539 -0
- package/skills/vault-clip/SKILL.md +73 -0
- package/skills/vault-recall/SKILL.md +82 -0
- package/skills/vault-recall/scripts/vault_index.py +626 -0
- package/skills/vault-tidy/SKILL.md +76 -0
- package/skills/xlsx-generator/scripts/formula_check.py +2 -0
- package/skills/xlsx-generator/scripts/libreoffice_recalc.py +2 -0
- package/skills/xlsx-generator/scripts/xlsx_add_column.py +2 -0
- package/skills/xlsx-generator/scripts/xlsx_insert_row.py +2 -0
- package/skills/xlsx-generator/scripts/xlsx_reader.py +2 -0
- package/skills/zettel/SKILL.md +66 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""
|
|
2
|
+
vault_index.py - Ranked retrieval and a cached inventory for an Obsidian vault.
|
|
3
|
+
|
|
4
|
+
No dependencies beyond the Python 3 standard library. Builds an inverted index
|
|
5
|
+
in <vault>/.vault-index.json (a dotfile, so Obsidian ignores it) and refreshes
|
|
6
|
+
only the notes whose mtime or size changed.
|
|
7
|
+
|
|
8
|
+
Two problems this solves, both of which show up around a thousand notes:
|
|
9
|
+
|
|
10
|
+
search Grep finds the notes containing your exact words. This ranks by
|
|
11
|
+
BM25, folds accents, and expands the query with the aliases any note
|
|
12
|
+
declares, so searching "postgres" also reaches the notes that only
|
|
13
|
+
ever said "PG". It is lexical retrieval with vault-derived synonyms,
|
|
14
|
+
NOT embeddings: it will not connect "indices parciales" to
|
|
15
|
+
"performance de queries" unless some note ties those words together.
|
|
16
|
+
Teaching it a synonym is one frontmatter line.
|
|
17
|
+
|
|
18
|
+
related Notes closest to one note by tf-idf cosine over shared vocabulary.
|
|
19
|
+
Answers "what else talks about this", which is what a review session
|
|
20
|
+
needs to propose connections. Same limit as search: no shared words,
|
|
21
|
+
no match.
|
|
22
|
+
|
|
23
|
+
inventory A review session needs to know what hubs, projects and areas exist
|
|
24
|
+
before it can propose where an item goes. Globbing the vault on
|
|
25
|
+
every run costs context that grows with the vault; this emits a
|
|
26
|
+
compact listing from the cache.
|
|
27
|
+
|
|
28
|
+
Folder roles are resolved from flags, not assumed: --role hubs=People lets an
|
|
29
|
+
adopted vault keep its own structure (see the ## Rutas contract in its CLAUDE.md).
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
python vault_index.py <vault> refresh [--role name=path ...]
|
|
33
|
+
python vault_index.py <vault> search "<query>" [-n 10] [--no-expand]
|
|
34
|
+
python vault_index.py <vault> related "<note>" [-n 10]
|
|
35
|
+
python vault_index.py <vault> inventory [--format json|text]
|
|
36
|
+
|
|
37
|
+
Errors go to stderr as {"error": "...", "code": "..."} with exit code 1.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import math
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import sys
|
|
46
|
+
import time
|
|
47
|
+
import unicodedata
|
|
48
|
+
|
|
49
|
+
INDEX_FILENAME = ".vault-index.json"
|
|
50
|
+
INDEX_VERSION = 1
|
|
51
|
+
|
|
52
|
+
SKIP_DIRS = {
|
|
53
|
+
".obsidian", ".git", ".trash", ".stfolder", ".stversions",
|
|
54
|
+
"node_modules", "__pycache__", ".smart-env",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
DEFAULT_ROLES = {
|
|
58
|
+
"inbox": "_inbox",
|
|
59
|
+
"daily": "Reflect/Daily",
|
|
60
|
+
"weekly": "Reflect/Weekly",
|
|
61
|
+
"monthly": "Reflect/Monthly",
|
|
62
|
+
"atomic": "03_Resources",
|
|
63
|
+
"hubs": "Hubs",
|
|
64
|
+
"projects": "01_Projects",
|
|
65
|
+
"areas": "02_Areas",
|
|
66
|
+
"archive": "04_Archive",
|
|
67
|
+
"templates": "Templates",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# BM25 defaults. k1 controls how fast term frequency saturates, b how much a
|
|
71
|
+
# long note gets penalized. These are the standard values and there is no reason
|
|
72
|
+
# to tune them for a personal vault.
|
|
73
|
+
K1 = 1.5
|
|
74
|
+
B = 0.75
|
|
75
|
+
|
|
76
|
+
# A term in the title is worth this many occurrences in the body: a note called
|
|
77
|
+
# "Indices parciales" is about that, a note mentioning it once is not.
|
|
78
|
+
TITLE_BOOST = 3
|
|
79
|
+
|
|
80
|
+
MIN_TERM_LEN = 2
|
|
81
|
+
|
|
82
|
+
STOPWORDS = {
|
|
83
|
+
"de", "la", "el", "los", "las", "un", "una", "unos", "unas", "y", "o", "que",
|
|
84
|
+
"en", "con", "por", "para", "del", "al", "se", "su", "sus", "lo", "es", "son",
|
|
85
|
+
"como", "mas", "pero", "sin", "sobre", "este", "esta", "esto", "estos", "estas",
|
|
86
|
+
"the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with", "is",
|
|
87
|
+
"are", "was", "were", "be", "it", "this", "that", "as", "at", "by", "from",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
FENCED_CODE = re.compile(r"^```.*?^```", re.DOTALL | re.MULTILINE)
|
|
91
|
+
WIKILINK = re.compile(r"!?\[\[([^\[\]\n]+?)\]\]")
|
|
92
|
+
DATE_IN_NAME = re.compile(r"\d{4}-\d{2}-\d{2}")
|
|
93
|
+
TOKEN = re.compile(r"[^\W_]+", re.UNICODE)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def write_error(error, code):
|
|
97
|
+
sys.stderr.write(json.dumps({"error": error, "code": code}) + "\n")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fold(text):
|
|
101
|
+
"""Strip accents so 'indices' matches 'indices' written with the accent."""
|
|
102
|
+
decomposed = unicodedata.normalize("NFD", text)
|
|
103
|
+
return "".join(c for c in decomposed if not unicodedata.combining(c))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def normalize_term(raw):
|
|
107
|
+
"""
|
|
108
|
+
Fold accents, lowercase, and collapse plurals so 'indices' and 'indice' land
|
|
109
|
+
on the same term. Crude next to a real stemmer, but the highest-recall trick
|
|
110
|
+
available without a dependency.
|
|
111
|
+
|
|
112
|
+
The two plural forms need different handling and getting it wrong costs
|
|
113
|
+
recall in both directions. Spanish adds '-es' to a consonant ('ciudad' ->
|
|
114
|
+
'ciudades'), everything else just adds '-s'. Stripping '-es' unconditionally
|
|
115
|
+
turns 'indices' into 'indic' while 'indice' stays whole, so the plural stops
|
|
116
|
+
finding the singular. Only strip '-es' when what remains ends in one of the
|
|
117
|
+
consonants that actually take it.
|
|
118
|
+
|
|
119
|
+
Known cost: 'l'-final and 's'-final Spanish plurals still miss their
|
|
120
|
+
singular ('papeles'/'papel', 'meses'/'mes'). Adding those letters would
|
|
121
|
+
break the far more common 'base'/'bases' and 'table'/'tables'.
|
|
122
|
+
"""
|
|
123
|
+
term = fold(raw.lower())
|
|
124
|
+
if len(term) >= 5 and term.endswith("es") and term[-3] in "dnrz":
|
|
125
|
+
return term[:-2]
|
|
126
|
+
if len(term) > 4 and term.endswith("s"):
|
|
127
|
+
return term[:-1]
|
|
128
|
+
return term
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def tokenize(text):
|
|
132
|
+
terms = []
|
|
133
|
+
for match in TOKEN.findall(text):
|
|
134
|
+
if len(match) < MIN_TERM_LEN:
|
|
135
|
+
continue
|
|
136
|
+
term = normalize_term(match)
|
|
137
|
+
if term in STOPWORDS or len(term) < MIN_TERM_LEN:
|
|
138
|
+
continue
|
|
139
|
+
terms.append(term)
|
|
140
|
+
return terms
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_frontmatter(text):
|
|
144
|
+
"""Minimal YAML subset: scalars, inline lists and block lists."""
|
|
145
|
+
lines = text.split("\n")
|
|
146
|
+
if not lines or lines[0].strip() != "---":
|
|
147
|
+
return {}, 0
|
|
148
|
+
fields = {}
|
|
149
|
+
key = None
|
|
150
|
+
for index in range(1, len(lines)):
|
|
151
|
+
raw = lines[index]
|
|
152
|
+
if raw.strip() == "---":
|
|
153
|
+
return fields, index + 1
|
|
154
|
+
if raw.startswith((" - ", "- ", "\t- ")) and key:
|
|
155
|
+
if not isinstance(fields.get(key), list):
|
|
156
|
+
fields[key] = []
|
|
157
|
+
fields[key].append(raw.split("- ", 1)[1].strip().strip("\"'"))
|
|
158
|
+
continue
|
|
159
|
+
if ":" not in raw:
|
|
160
|
+
continue
|
|
161
|
+
key, _, value = raw.partition(":")
|
|
162
|
+
key, value = key.strip(), value.strip()
|
|
163
|
+
if value.startswith("[") and value.endswith("]"):
|
|
164
|
+
fields[key] = [v.strip().strip("\"'") for v in value[1:-1].split(",") if v.strip()]
|
|
165
|
+
else:
|
|
166
|
+
fields[key] = value.strip("\"'")
|
|
167
|
+
return fields, len(lines)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def as_list(value):
|
|
171
|
+
if isinstance(value, list):
|
|
172
|
+
return [v for v in value if v]
|
|
173
|
+
if isinstance(value, str) and value:
|
|
174
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
175
|
+
return []
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def relative(path, root):
|
|
179
|
+
return os.path.relpath(path, root).replace(os.sep, "/")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def walk_notes(root):
|
|
183
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
184
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
|
|
185
|
+
for filename in sorted(filenames):
|
|
186
|
+
if filename.endswith(".md"):
|
|
187
|
+
yield os.path.join(dirpath, filename)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def read_note(full, root):
|
|
191
|
+
rel = relative(full, root)
|
|
192
|
+
stat = os.stat(full)
|
|
193
|
+
with open(full, "r", encoding="utf-8", errors="replace") as handle:
|
|
194
|
+
text = handle.read()
|
|
195
|
+
|
|
196
|
+
fields, body_start = parse_frontmatter(text)
|
|
197
|
+
body = FENCED_CODE.sub("", "\n".join(text.split("\n")[body_start:]))
|
|
198
|
+
title = os.path.splitext(os.path.basename(rel))[0]
|
|
199
|
+
aliases = as_list(fields.get("aliases"))
|
|
200
|
+
|
|
201
|
+
counts = {}
|
|
202
|
+
for term in tokenize(body):
|
|
203
|
+
counts[term] = counts.get(term, 0) + 1
|
|
204
|
+
# Title and aliases are indexed as body terms too, weighted: they are the
|
|
205
|
+
# strongest statement a note makes about what it is.
|
|
206
|
+
for name in [title] + aliases:
|
|
207
|
+
for term in tokenize(name):
|
|
208
|
+
counts[term] = counts.get(term, 0) + TITLE_BOOST
|
|
209
|
+
|
|
210
|
+
return rel, {
|
|
211
|
+
"mtime": stat.st_mtime,
|
|
212
|
+
"size": stat.st_size,
|
|
213
|
+
"title": title,
|
|
214
|
+
"type": fields.get("type") or "",
|
|
215
|
+
"tags": as_list(fields.get("tags")),
|
|
216
|
+
"aliases": aliases,
|
|
217
|
+
"links": sorted({m.split("|")[0].split("#")[0].strip()
|
|
218
|
+
for m in WIKILINK.findall(body) if m.strip()}),
|
|
219
|
+
"words": len(body.split()),
|
|
220
|
+
"terms": counts,
|
|
221
|
+
"length": sum(counts.values()),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def index_path(root):
|
|
226
|
+
return os.path.join(root, INDEX_FILENAME)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def load_index(root):
|
|
230
|
+
try:
|
|
231
|
+
with open(index_path(root), "r", encoding="utf-8") as handle:
|
|
232
|
+
cache = json.load(handle)
|
|
233
|
+
except (OSError, ValueError):
|
|
234
|
+
return None
|
|
235
|
+
if cache.get("version") != INDEX_VERSION:
|
|
236
|
+
return None
|
|
237
|
+
return cache
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def refresh(root, roles):
|
|
241
|
+
"""Incremental: re-read only notes whose mtime or size moved."""
|
|
242
|
+
cache = load_index(root) or {"version": INDEX_VERSION, "docs": {}}
|
|
243
|
+
docs = cache["docs"]
|
|
244
|
+
seen = set()
|
|
245
|
+
added = updated = 0
|
|
246
|
+
|
|
247
|
+
for full in walk_notes(root):
|
|
248
|
+
rel = relative(full, root)
|
|
249
|
+
seen.add(rel)
|
|
250
|
+
try:
|
|
251
|
+
stat = os.stat(full)
|
|
252
|
+
except OSError:
|
|
253
|
+
continue
|
|
254
|
+
known = docs.get(rel)
|
|
255
|
+
if known and known["mtime"] == stat.st_mtime and known["size"] == stat.st_size:
|
|
256
|
+
continue
|
|
257
|
+
try:
|
|
258
|
+
_, record = read_note(full, root)
|
|
259
|
+
except OSError as e:
|
|
260
|
+
write_error("cannot read {0}: {1}".format(rel, e), "READ_FAILED")
|
|
261
|
+
continue
|
|
262
|
+
docs[rel] = record
|
|
263
|
+
if known:
|
|
264
|
+
updated += 1
|
|
265
|
+
else:
|
|
266
|
+
added += 1
|
|
267
|
+
|
|
268
|
+
removed = [rel for rel in docs if rel not in seen]
|
|
269
|
+
for rel in removed:
|
|
270
|
+
del docs[rel]
|
|
271
|
+
|
|
272
|
+
# Document frequency is derived, never incremental: recomputing it over the
|
|
273
|
+
# cached term maps is cheap and keeps the two from drifting apart.
|
|
274
|
+
df = {}
|
|
275
|
+
for record in docs.values():
|
|
276
|
+
for term in record["terms"]:
|
|
277
|
+
df[term] = df.get(term, 0) + 1
|
|
278
|
+
|
|
279
|
+
cache.update({
|
|
280
|
+
"built_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
281
|
+
"roles": roles,
|
|
282
|
+
"df": df,
|
|
283
|
+
"n_docs": len(docs),
|
|
284
|
+
"avg_length": (sum(r["length"] for r in docs.values()) / len(docs)) if docs else 0.0,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
with open(index_path(root), "w", encoding="utf-8") as handle:
|
|
288
|
+
json.dump(cache, handle, ensure_ascii=False)
|
|
289
|
+
|
|
290
|
+
return cache, {"added": added, "updated": updated, "removed": len(removed),
|
|
291
|
+
"total": len(docs)}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def alias_map(cache, roles):
|
|
295
|
+
"""
|
|
296
|
+
Entity -> every other name it is known by, taken from the aliases any note
|
|
297
|
+
declares. This is the whole synonym mechanism: it comes from the user's own
|
|
298
|
+
vocabulary rather than from a generic thesaurus, which is why it is worth
|
|
299
|
+
having, and it means teaching the vault a synonym is one frontmatter line.
|
|
300
|
+
|
|
301
|
+
Hubs are where this normally lives, but not exclusively: an atomic note that
|
|
302
|
+
declares `aliases: [OKR, objetivos y resultados]` is teaching the same thing.
|
|
303
|
+
"""
|
|
304
|
+
groups = []
|
|
305
|
+
for record in cache["docs"].values():
|
|
306
|
+
names = [record["title"]] + record.get("aliases", [])
|
|
307
|
+
if len(names) > 1:
|
|
308
|
+
groups.append(names)
|
|
309
|
+
|
|
310
|
+
expansion = {}
|
|
311
|
+
for names in groups:
|
|
312
|
+
for name in names:
|
|
313
|
+
for term in tokenize(name):
|
|
314
|
+
expansion.setdefault(term, set()).update(
|
|
315
|
+
t for other in names for t in tokenize(other)
|
|
316
|
+
)
|
|
317
|
+
return {term: sorted(terms) for term, terms in expansion.items()}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def search(cache, query, limit, expand=True):
|
|
321
|
+
terms = tokenize(query)
|
|
322
|
+
if not terms:
|
|
323
|
+
return [], []
|
|
324
|
+
|
|
325
|
+
expanded = list(terms)
|
|
326
|
+
if expand:
|
|
327
|
+
mapping = alias_map(cache, cache.get("roles") or DEFAULT_ROLES)
|
|
328
|
+
for term in terms:
|
|
329
|
+
for extra in mapping.get(term, []):
|
|
330
|
+
if extra not in expanded:
|
|
331
|
+
expanded.append(extra)
|
|
332
|
+
|
|
333
|
+
n_docs = cache["n_docs"] or 1
|
|
334
|
+
avg_length = cache["avg_length"] or 1.0
|
|
335
|
+
df = cache["df"]
|
|
336
|
+
scored = []
|
|
337
|
+
|
|
338
|
+
for rel, record in cache["docs"].items():
|
|
339
|
+
score = 0.0
|
|
340
|
+
matched = []
|
|
341
|
+
for term in expanded:
|
|
342
|
+
freq = record["terms"].get(term)
|
|
343
|
+
if not freq:
|
|
344
|
+
continue
|
|
345
|
+
# Terms brought in by alias expansion count half: a synonym is
|
|
346
|
+
# evidence, not the thing the user typed.
|
|
347
|
+
weight = 1.0 if term in terms else 0.5
|
|
348
|
+
idf = math.log(1 + (n_docs - df.get(term, 0) + 0.5) / (df.get(term, 0) + 0.5))
|
|
349
|
+
norm = freq * (K1 + 1) / (freq + K1 * (1 - B + B * record["length"] / avg_length))
|
|
350
|
+
score += idf * norm * weight
|
|
351
|
+
matched.append(term)
|
|
352
|
+
if score > 0:
|
|
353
|
+
scored.append({
|
|
354
|
+
"path": rel, "title": record["title"], "type": record["type"],
|
|
355
|
+
"score": round(score, 3), "matched": sorted(set(matched)),
|
|
356
|
+
"words": record["words"],
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
scored.sort(key=lambda r: (-r["score"], r["path"]))
|
|
360
|
+
return scored[:limit] if limit > 0 else scored, expanded
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def related(cache, target, limit):
|
|
364
|
+
"""
|
|
365
|
+
Notes closest to one note by tf-idf cosine over the shared vocabulary.
|
|
366
|
+
|
|
367
|
+
This is the honest middle ground between search and embeddings. It answers
|
|
368
|
+
"what else talks about this" without a model, and it is what feeds a review
|
|
369
|
+
session looking for connections. What it cannot do is connect two notes that
|
|
370
|
+
share no words: for that you need embeddings, and this file does not have
|
|
371
|
+
them. It says so rather than pretending.
|
|
372
|
+
"""
|
|
373
|
+
docs = cache["docs"]
|
|
374
|
+
if target not in docs:
|
|
375
|
+
matches = [rel for rel in docs
|
|
376
|
+
if os.path.splitext(os.path.basename(rel))[0].lower() == target.lower()]
|
|
377
|
+
if len(matches) != 1:
|
|
378
|
+
return None, matches
|
|
379
|
+
target = matches[0]
|
|
380
|
+
|
|
381
|
+
n_docs = cache["n_docs"] or 1
|
|
382
|
+
df = cache["df"]
|
|
383
|
+
|
|
384
|
+
def vector(record):
|
|
385
|
+
out = {}
|
|
386
|
+
for term, freq in record["terms"].items():
|
|
387
|
+
idf = math.log(1 + n_docs / (df.get(term, 0) + 0.5))
|
|
388
|
+
out[term] = freq * idf
|
|
389
|
+
norm = math.sqrt(sum(v * v for v in out.values())) or 1.0
|
|
390
|
+
return {term: value / norm for term, value in out.items()}
|
|
391
|
+
|
|
392
|
+
base = vector(docs[target])
|
|
393
|
+
scored = []
|
|
394
|
+
for rel, record in docs.items():
|
|
395
|
+
if rel == target:
|
|
396
|
+
continue
|
|
397
|
+
other = vector(record)
|
|
398
|
+
shared = set(base) & set(other)
|
|
399
|
+
if not shared:
|
|
400
|
+
continue
|
|
401
|
+
score = sum(base[term] * other[term] for term in shared)
|
|
402
|
+
if score <= 0:
|
|
403
|
+
continue
|
|
404
|
+
top = sorted(shared, key=lambda term: -(base[term] * other[term]))[:5]
|
|
405
|
+
scored.append({"path": rel, "title": record["title"], "type": record["type"],
|
|
406
|
+
"score": round(score, 3), "shared": top})
|
|
407
|
+
|
|
408
|
+
scored.sort(key=lambda r: (-r["score"], r["path"]))
|
|
409
|
+
return {"target": target, "results": scored[:limit] if limit > 0 else scored}, None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def inventory(cache, roles):
|
|
413
|
+
docs = cache["docs"]
|
|
414
|
+
|
|
415
|
+
def in_role(rel, role):
|
|
416
|
+
prefix = roles.get(role)
|
|
417
|
+
return bool(prefix) and rel.startswith(prefix + "/")
|
|
418
|
+
|
|
419
|
+
def names(role):
|
|
420
|
+
out = []
|
|
421
|
+
for rel, record in sorted(docs.items()):
|
|
422
|
+
if in_role(rel, role):
|
|
423
|
+
entry = {"path": rel, "title": record["title"]}
|
|
424
|
+
if record.get("aliases"):
|
|
425
|
+
entry["aliases"] = record["aliases"]
|
|
426
|
+
out.append(entry)
|
|
427
|
+
return out
|
|
428
|
+
|
|
429
|
+
def dates(role):
|
|
430
|
+
return sorted(
|
|
431
|
+
m.group(0)
|
|
432
|
+
for rel in docs if in_role(rel, role)
|
|
433
|
+
for m in [DATE_IN_NAME.search(os.path.basename(rel))] if m
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
dumps, dailies = dates("inbox"), set(dates("daily"))
|
|
437
|
+
return {
|
|
438
|
+
"built_at": cache.get("built_at"),
|
|
439
|
+
"roles": roles,
|
|
440
|
+
"counts": {
|
|
441
|
+
"notes": len(docs),
|
|
442
|
+
**{role: len([r for r in docs if in_role(r, role)]) for role in roles},
|
|
443
|
+
},
|
|
444
|
+
"hubs": names("hubs"),
|
|
445
|
+
"projects": names("projects"),
|
|
446
|
+
"areas": names("areas"),
|
|
447
|
+
# The one thing a review session always needs first: which days were
|
|
448
|
+
# captured but never processed.
|
|
449
|
+
"unprocessed_dumps": [d for d in dumps if d not in dailies],
|
|
450
|
+
"latest": {
|
|
451
|
+
"daily": (sorted(dailies) or [None])[-1],
|
|
452
|
+
"weekly": (dates("weekly") or [None])[-1],
|
|
453
|
+
"monthly": (dates("monthly") or [None])[-1],
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def format_search_text(results, expanded):
|
|
459
|
+
if not results:
|
|
460
|
+
return "no matches"
|
|
461
|
+
out = ["query terms: {0}".format(" ".join(expanded)), ""]
|
|
462
|
+
for rank, hit in enumerate(results, 1):
|
|
463
|
+
out.append("{0:>2}. {1:<6} {2}".format(rank, hit["score"], hit["path"]))
|
|
464
|
+
out.append(" matched: {0}".format(", ".join(hit["matched"])))
|
|
465
|
+
return "\n".join(out)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def format_related_text(found):
|
|
469
|
+
if not found["results"]:
|
|
470
|
+
return "nothing shares vocabulary with {0}".format(found["target"])
|
|
471
|
+
out = ["related to {0}:".format(found["target"]), ""]
|
|
472
|
+
for rank, hit in enumerate(found["results"], 1):
|
|
473
|
+
out.append("{0:>2}. {1:<6} {2}".format(rank, hit["score"], hit["path"]))
|
|
474
|
+
out.append(" shared: {0}".format(", ".join(hit["shared"])))
|
|
475
|
+
return "\n".join(out)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def format_inventory_text(inv):
|
|
479
|
+
out = [
|
|
480
|
+
"built: {0}".format(inv["built_at"]),
|
|
481
|
+
"{0} notes".format(inv["counts"]["notes"]),
|
|
482
|
+
"hubs: {0} | projects: {1} | areas: {2}".format(
|
|
483
|
+
len(inv["hubs"]), len(inv["projects"]), len(inv["areas"])),
|
|
484
|
+
"latest daily: {0} | weekly: {1} | monthly: {2}".format(
|
|
485
|
+
inv["latest"]["daily"], inv["latest"]["weekly"], inv["latest"]["monthly"]),
|
|
486
|
+
]
|
|
487
|
+
if inv["unprocessed_dumps"]:
|
|
488
|
+
out.append("unprocessed dumps ({0}): {1}".format(
|
|
489
|
+
len(inv["unprocessed_dumps"]), ", ".join(inv["unprocessed_dumps"])))
|
|
490
|
+
else:
|
|
491
|
+
out.append("unprocessed dumps: none")
|
|
492
|
+
if inv["hubs"]:
|
|
493
|
+
out.append("")
|
|
494
|
+
out.append("hubs:")
|
|
495
|
+
for hub in inv["hubs"]:
|
|
496
|
+
alias = " ({0})".format(", ".join(hub["aliases"])) if hub.get("aliases") else ""
|
|
497
|
+
out.append(" {0}{1}".format(hub["title"], alias))
|
|
498
|
+
return "\n".join(out)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def parse_roles(pairs):
|
|
502
|
+
roles = dict(DEFAULT_ROLES)
|
|
503
|
+
for pair in pairs or []:
|
|
504
|
+
if "=" not in pair:
|
|
505
|
+
write_error("--role expects name=path, got: {0}".format(pair), "BAD_ROLE")
|
|
506
|
+
raise SystemExit(1)
|
|
507
|
+
name, _, path = pair.partition("=")
|
|
508
|
+
name, path = name.strip(), path.strip().strip("/").replace("\\", "/")
|
|
509
|
+
if name not in DEFAULT_ROLES:
|
|
510
|
+
write_error("unknown role '{0}'; known roles: {1}".format(
|
|
511
|
+
name, ", ".join(sorted(DEFAULT_ROLES))), "BAD_ROLE")
|
|
512
|
+
raise SystemExit(1)
|
|
513
|
+
roles[name] = path
|
|
514
|
+
return roles
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class JsonErrorParser(argparse.ArgumentParser):
|
|
518
|
+
"""Keep the CLI's error contract: a JSON line on stderr and exit code 1."""
|
|
519
|
+
|
|
520
|
+
def error(self, message):
|
|
521
|
+
code = "NO_VAULT" if "vault" in message and "required" in message else "BAD_ARG"
|
|
522
|
+
write_error(message, code)
|
|
523
|
+
raise SystemExit(1)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def build_parser():
|
|
527
|
+
parser = JsonErrorParser(
|
|
528
|
+
prog="vault_index.py",
|
|
529
|
+
description="Ranked retrieval and cached inventory for an Obsidian vault. "
|
|
530
|
+
"Writes only its own index dotfile, never a note.",
|
|
531
|
+
)
|
|
532
|
+
parser.add_argument("vault", help="path to the vault")
|
|
533
|
+
parser.add_argument("--role", action="append", metavar="NAME=PATH",
|
|
534
|
+
help="folder role override (repeatable). Roles: " +
|
|
535
|
+
", ".join(sorted(DEFAULT_ROLES)))
|
|
536
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
537
|
+
|
|
538
|
+
sub.add_parser("refresh", help="build or update the index incrementally")
|
|
539
|
+
|
|
540
|
+
search_cmd = sub.add_parser("search", help="ranked search over the index")
|
|
541
|
+
search_cmd.add_argument("query")
|
|
542
|
+
search_cmd.add_argument("-n", "--limit", type=int, default=10,
|
|
543
|
+
help="max results; 0 for all. Default: 10")
|
|
544
|
+
search_cmd.add_argument("--no-expand", action="store_true",
|
|
545
|
+
help="do not expand the query with hub aliases")
|
|
546
|
+
search_cmd.add_argument("--format", choices=["json", "text"], default="json")
|
|
547
|
+
|
|
548
|
+
rel = sub.add_parser("related", help="notes closest to one note by shared vocabulary")
|
|
549
|
+
rel.add_argument("note", help="path relative to the vault, or the note title")
|
|
550
|
+
rel.add_argument("-n", "--limit", type=int, default=10,
|
|
551
|
+
help="max results; 0 for all. Default: 10")
|
|
552
|
+
rel.add_argument("--format", choices=["json", "text"], default="json")
|
|
553
|
+
|
|
554
|
+
inv = sub.add_parser("inventory", help="compact listing of hubs, projects and areas")
|
|
555
|
+
inv.add_argument("--format", choices=["json", "text"], default="json")
|
|
556
|
+
|
|
557
|
+
return parser
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def force_utf8_streams():
|
|
561
|
+
for stream in (sys.stdout, sys.stderr):
|
|
562
|
+
if hasattr(stream, "reconfigure"):
|
|
563
|
+
stream.reconfigure(encoding="utf-8")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def main():
|
|
567
|
+
force_utf8_streams()
|
|
568
|
+
args = build_parser().parse_args()
|
|
569
|
+
roles = parse_roles(args.role)
|
|
570
|
+
|
|
571
|
+
root = os.path.abspath(os.path.expanduser(args.vault))
|
|
572
|
+
if not os.path.isdir(root):
|
|
573
|
+
write_error("no such directory: {0}".format(args.vault), "NO_VAULT")
|
|
574
|
+
sys.exit(1)
|
|
575
|
+
|
|
576
|
+
if args.command == "refresh":
|
|
577
|
+
_, stats = refresh(root, roles)
|
|
578
|
+
print(json.dumps(stats, indent=2))
|
|
579
|
+
return 0
|
|
580
|
+
|
|
581
|
+
cache = load_index(root)
|
|
582
|
+
if cache is None:
|
|
583
|
+
# Refreshing on demand beats failing: the first search in a vault should
|
|
584
|
+
# just work, and after that it is incremental anyway.
|
|
585
|
+
sys.stderr.write(json.dumps(
|
|
586
|
+
{"warning": "no usable index, building it now", "code": "INDEX_MISSING"}) + "\n")
|
|
587
|
+
cache, _ = refresh(root, roles)
|
|
588
|
+
if args.role:
|
|
589
|
+
cache["roles"] = roles
|
|
590
|
+
|
|
591
|
+
if args.command == "search":
|
|
592
|
+
results, expanded = search(cache, args.query, args.limit, not args.no_expand)
|
|
593
|
+
if args.format == "text":
|
|
594
|
+
print(format_search_text(results, expanded))
|
|
595
|
+
else:
|
|
596
|
+
print(json.dumps({"query": args.query, "terms": expanded, "results": results},
|
|
597
|
+
indent=2, ensure_ascii=False))
|
|
598
|
+
return 0
|
|
599
|
+
|
|
600
|
+
if args.command == "related":
|
|
601
|
+
found, ambiguous = related(cache, args.note, args.limit)
|
|
602
|
+
if found is None:
|
|
603
|
+
write_error(
|
|
604
|
+
"no single note matches '{0}'{1}".format(
|
|
605
|
+
args.note,
|
|
606
|
+
": " + ", ".join(ambiguous) if ambiguous else "",
|
|
607
|
+
),
|
|
608
|
+
"AMBIGUOUS_NOTE" if ambiguous else "NO_NOTE",
|
|
609
|
+
)
|
|
610
|
+
return 1
|
|
611
|
+
if args.format == "text":
|
|
612
|
+
print(format_related_text(found))
|
|
613
|
+
else:
|
|
614
|
+
print(json.dumps(found, indent=2, ensure_ascii=False))
|
|
615
|
+
return 0
|
|
616
|
+
|
|
617
|
+
inv = inventory(cache, cache.get("roles") or roles)
|
|
618
|
+
if args.format == "text":
|
|
619
|
+
print(format_inventory_text(inv))
|
|
620
|
+
else:
|
|
621
|
+
print(json.dumps(inv, indent=2, ensure_ascii=False))
|
|
622
|
+
return 0
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
if __name__ == "__main__":
|
|
626
|
+
main()
|