claude-code-arcane 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -4
  3. package/agents/audio/audio-director.md +90 -0
  4. package/agents/audio/audio-programmer.md +97 -0
  5. package/agents/audio/audio-qa.md +95 -0
  6. package/agents/audio/composer.md +88 -0
  7. package/agents/audio/technical-sound-designer.md +90 -0
  8. package/agents/audio/voice-director.md +96 -0
  9. package/docs/SKILLS-CATALOG.md +45 -2
  10. package/docs/USER-GUIDE.md +2 -2
  11. package/package.json +1 -1
  12. package/profiles/audio.yaml +45 -0
  13. package/profiles/second-brain.yaml +37 -0
  14. package/rules/gamedev/audio-code.md +45 -0
  15. package/rules/vault-conventions.md +144 -0
  16. package/skills/adaptive-music/SKILL.md +171 -0
  17. package/skills/adaptive-music/references/anti-patterns.md +53 -0
  18. package/skills/audio-audit/SKILL.md +164 -0
  19. package/skills/audio-audit/references/anti-patterns.md +65 -0
  20. package/skills/audio-audit/scripts/check_assets.py +208 -0
  21. package/skills/audio-bible/SKILL.md +119 -0
  22. package/skills/audio-bible/references/anti-patterns.md +39 -0
  23. package/skills/audio-mix/SKILL.md +151 -0
  24. package/skills/audio-mix/references/anti-patterns.md +65 -0
  25. package/skills/audio-spec/SKILL.md +107 -0
  26. package/skills/audio-spec/references/anti-patterns.md +45 -0
  27. package/skills/brain-dump/SKILL.md +53 -0
  28. package/skills/hub-note/SKILL.md +68 -0
  29. package/skills/middleware-integration/SKILL.md +186 -0
  30. package/skills/middleware-integration/references/anti-patterns.md +69 -0
  31. package/skills/midi-compose/SKILL.md +136 -0
  32. package/skills/midi-compose/references/smf-format.md +129 -0
  33. package/skills/midi-compose/scripts/compose_midi.py +326 -0
  34. package/skills/midi-compose/templates/example-composition.json +69 -0
  35. package/skills/music-composition/SKILL.md +106 -0
  36. package/skills/music-composition/references/form.md +57 -0
  37. package/skills/music-composition/references/game-music.md +82 -0
  38. package/skills/music-composition/references/harmony.md +82 -0
  39. package/skills/music-composition/references/melody.md +48 -0
  40. package/skills/music-composition/references/orchestration.md +69 -0
  41. package/skills/music-composition/references/rhythm-groove.md +49 -0
  42. package/skills/obsidian-bases/SKILL.md +85 -0
  43. package/skills/obsidian-bases/references/bases-syntax.md +123 -0
  44. package/skills/obsidian-canvas/SKILL.md +91 -0
  45. package/skills/obsidian-markdown/SKILL.md +110 -0
  46. package/skills/pdf-generator/scripts/fill_inspect.py +2 -0
  47. package/skills/pdf-generator/scripts/render_body.py +2 -0
  48. package/skills/procedural-audio/SKILL.md +157 -0
  49. package/skills/procedural-audio/references/anti-patterns.md +45 -0
  50. package/skills/review-dump/SKILL.md +108 -0
  51. package/skills/review-monthly/SKILL.md +56 -0
  52. package/skills/review-weekly/SKILL.md +91 -0
  53. package/skills/second-brain/SKILL.md +211 -0
  54. package/skills/second-brain/hooks/hooks.json +16 -0
  55. package/skills/second-brain/hooks/validate-note.sh +85 -0
  56. package/skills/second-brain/references/cross-project.md +57 -0
  57. package/skills/second-brain/references/templates/Atomic.md +22 -0
  58. package/skills/second-brain/references/templates/Daily.md +25 -0
  59. package/skills/second-brain/references/templates/Dashboard.base +70 -0
  60. package/skills/second-brain/references/templates/Dump.md +11 -0
  61. package/skills/second-brain/references/templates/Hub.md +22 -0
  62. package/skills/second-brain/references/templates/Monthly.md +29 -0
  63. package/skills/second-brain/references/templates/Weekly.md +29 -0
  64. package/skills/second-brain/references/templates/codebases.md +37 -0
  65. package/skills/second-brain/references/templates/hot.md +37 -0
  66. package/skills/second-brain/references/templates/vault-CLAUDE.md +127 -0
  67. package/skills/sfx-design/SKILL.md +130 -0
  68. package/skills/sfx-design/references/anti-patterns.md +53 -0
  69. package/skills/spatial-audio/SKILL.md +139 -0
  70. package/skills/spatial-audio/references/anti-patterns.md +53 -0
  71. package/skills/ui-sound-design/SKILL.md +133 -0
  72. package/skills/ui-sound-design/references/anti-patterns.md +57 -0
  73. package/skills/vault-audit/SKILL.md +72 -0
  74. package/skills/vault-audit/scripts/vault_audit.py +539 -0
  75. package/skills/vault-clip/SKILL.md +73 -0
  76. package/skills/vault-recall/SKILL.md +82 -0
  77. package/skills/vault-recall/scripts/vault_index.py +626 -0
  78. package/skills/vault-tidy/SKILL.md +76 -0
  79. package/skills/voice-pipeline/SKILL.md +150 -0
  80. package/skills/voice-pipeline/references/anti-patterns.md +69 -0
  81. package/skills/xlsx-generator/scripts/formula_check.py +2 -0
  82. package/skills/xlsx-generator/scripts/libreoffice_recalc.py +2 -0
  83. package/skills/xlsx-generator/scripts/xlsx_add_column.py +2 -0
  84. package/skills/xlsx-generator/scripts/xlsx_insert_row.py +2 -0
  85. package/skills/xlsx-generator/scripts/xlsx_reader.py +2 -0
  86. 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()