Files changed: .gitignore CLAUDE.md credentials engine/.embcache.json engine/changelog.md engine/config.yaml engine/lib/__pycache__/agent.cpython-312.pyc engine/lib/agent.py engine/lib/lint.py engine/lint-report.md engine/sweep-full.log engine/sweep.sh vault/.obsidian/graph.json vault/.obsidian/workspace.json vault/INDEX.md vault/archive/projects/mmud/last-ember-chronicle.html vault/archive/projects/mmud/last-ember-howto.html vault/archive/projects/mmud/last-ember.html vault/archive/projects/mmud/mmud-phase5-prompt.md vault/archive/projects/mmud/mmud-phase6-prompt.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/01-update-planned.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/02-npc-nodes.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/03-darkcragg.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/04-dcrg-node.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/05-phase5.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/06-phase6.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/README.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/mmud-project.md vault/docs/hardware/environment.md vault/docs/hardware/ip-allocation.md vault/docs/matrix/archivist.md vault/docs/matrix/matrix_host.md vault/docs/matrix/mautrix_signal.md vault/docs/matrix/synapse.md vault/docs/matrix/synapse_retention_discovery.md vault/docs/navi/cc-rules.md vault/docs/navi/deployment.md vault/docs/navi/themes.md vault/docs/services/ots-setup.md vault/docs/services/services.md vault/docs/services/usenet.md vault/docs/software/authentik.md vault/docs/software/caddy.md vault/docs/software/dns.md vault/docs/software/geo-tools.md vault/docs/software/recon.md vault/docs/software/searxng.md vault/glossary.md vault/notes/echo6-landing-page-data-export.md vault/notes/ia-download-queue.md vault/plans/vaultwarden-plan.md vault/projects/advbbs-project.md vault/projects/argus.md vault/projects/deploy-livesync.md vault/projects/matrix-synapse-deployment.md vault/projects/meshtastic-headscale-runbook.md vault/projects/mmud-project.md vault/runbooks/add-peertube-channel.md vault/runbooks/authentik-access-groups.md vault/runbooks/authentik-create-invitation.md vault/runbooks/authentik-oidc-application.md vault/runbooks/authentik-upgrade.md vault/runbooks/ct-runbook.md vault/runbooks/edge2-access-reference.md vault/runbooks/expose-service-contabo.md vault/runbooks/expose-service-edge2.md vault/runbooks/expose-service-home.md vault/runbooks/headscale-onboard-node.md vault/runbooks/ia-cli-reference.md vault/runbooks/ia-download-mirror.md vault/runbooks/idahomesh-bridge-setup.md vault/runbooks/idahomesh-vpn-device-setup.md vault/runbooks/lxc-service-migration.md vault/runbooks/mailcow-create-mailbox.md vault/runbooks/meshmonitor-password-reset.md vault/runbooks/meshtastic-sidecar-node.md vault/runbooks/meshtasticd-sim-nodes-runbook.md vault/runbooks/nordvpn-lxc.md vault/runbooks/peertube-remote-runner.md vault/runbooks/pg-backup.md vault/runbooks/pi-nas-omv-runbook.md vault/runbooks/pipeline-patterns.md vault/runbooks/proxmox-create-ubuntu-vm.md vault/runbooks/proxmox-onboard-node.md vault/runbooks/recon-operations.md vault/runbooks/recon-service-integration.md vault/runbooks/syncthing-add-node.md vault/session-resume/SESSION-HANDOFF-meshai-test.md
920 lines
32 KiB
Python
920 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
agent.py — Vault Tagger + Existing-Doc Linker (v4 — simple model)
|
|
|
|
The agreed model: documentation library only.
|
|
- Never create entity/concept/node pages.
|
|
- Per-doc: (1) assign 1-3 category tags from topic_categories, (2) inline [[wikilinks]]
|
|
to EXISTING docs only (most-specific match, first prose occurrence).
|
|
- Keep: related (bge-m3 nearest existing docs), title, updated.
|
|
- Preserve all human frontmatter keys.
|
|
|
|
Usage:
|
|
python3 engine/lib/agent.py --dry-run <path> # print diff, write nothing
|
|
python3 engine/lib/agent.py <path> # apply
|
|
|
|
stdlib only — no pip. All HTTP via urllib.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import difflib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config loader (minimal stdlib YAML parser)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _parse_simple_yaml(text: str) -> dict:
|
|
result: dict = {}
|
|
current_key = None
|
|
current_list: list | None = None
|
|
for raw_line in text.splitlines():
|
|
line = raw_line.rstrip()
|
|
stripped = line.lstrip()
|
|
if not stripped or stripped.startswith("#"):
|
|
if current_list is not None and not line.startswith(" "):
|
|
result[current_key] = current_list
|
|
current_list = None
|
|
current_key = None
|
|
continue
|
|
if stripped.startswith("- ") and current_list is not None:
|
|
current_list.append(stripped[2:].strip().strip('"').strip("'"))
|
|
continue
|
|
if current_list is not None and not stripped.startswith("-"):
|
|
result[current_key] = current_list
|
|
current_list = None
|
|
current_key = None
|
|
if ":" in stripped:
|
|
key, _, value = stripped.partition(":")
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if value.startswith("[") and value.endswith("]"):
|
|
inner = value[1:-1]
|
|
result[key] = [v.strip().strip('"').strip("'") for v in inner.split(",") if v.strip()]
|
|
elif value == "":
|
|
current_key = key
|
|
current_list = []
|
|
elif value.startswith("#"):
|
|
result[key] = ""
|
|
else:
|
|
value = value.split(" #")[0].strip().strip('"').strip("'")
|
|
result[key] = value
|
|
if current_list is not None and current_key:
|
|
result[current_key] = current_list
|
|
return result
|
|
|
|
|
|
def load_config(config_path: str | Path | None = None) -> dict:
|
|
"""Load config.yaml; fall back to defaults."""
|
|
defaults: dict = {
|
|
"vault_dir": "/home/zvx/projects/.ref/vault",
|
|
"engine_dir": "/home/zvx/projects/.ref/engine",
|
|
"topic_categories": [
|
|
"mesh", "matrix", "recon", "media", "auth",
|
|
"dns", "vpn", "storage", "proxmox", "ai", "mail",
|
|
],
|
|
"models": {
|
|
"tagger": {
|
|
"ollama_endpoint": "http://localhost:11434",
|
|
"model": "vault-tagger",
|
|
"temperature": 0.1,
|
|
},
|
|
"embeddings": {
|
|
"tei_endpoint": "http://localhost:8090",
|
|
},
|
|
},
|
|
"behavior": {
|
|
"auto_apply": True,
|
|
"changelog": "/home/zvx/projects/.ref/engine/changelog.md",
|
|
"confidence_threshold": 0.6,
|
|
},
|
|
}
|
|
|
|
if config_path is None:
|
|
here = Path(__file__).parent
|
|
for c in [here.parent / "config.yaml", Path("/home/zvx/projects/.ref/engine/config.yaml")]:
|
|
if c.exists():
|
|
config_path = c
|
|
break
|
|
|
|
if config_path is None or not Path(config_path).exists():
|
|
return defaults
|
|
|
|
cfg = dict(defaults)
|
|
try:
|
|
parsed = _parse_simple_yaml(Path(config_path).read_text(encoding="utf-8"))
|
|
if "vault_dir" in parsed:
|
|
cfg["vault_dir"] = parsed["vault_dir"]
|
|
if "engine_dir" in parsed:
|
|
cfg["engine_dir"] = parsed["engine_dir"]
|
|
if "topic_categories" in parsed:
|
|
cfg["topic_categories"] = parsed["topic_categories"]
|
|
# Models
|
|
if "models" in parsed:
|
|
pass # nested — handled by line-scan below
|
|
# Line-scan for nested values
|
|
lines = Path(config_path).read_text(encoding="utf-8").splitlines()
|
|
in_tagger = in_embed = in_behavior = False
|
|
for line in lines:
|
|
if re.match(r" tagger:", line):
|
|
in_tagger, in_embed = True, False
|
|
elif re.match(r" embeddings:", line):
|
|
in_embed, in_tagger = True, False
|
|
elif re.match(r"^behavior:", line):
|
|
in_tagger = in_embed = False
|
|
in_behavior = True
|
|
elif line and not line.startswith(" "):
|
|
in_tagger = in_embed = in_behavior = False
|
|
|
|
if in_tagger:
|
|
m = re.match(r"\s+ollama_endpoint:\s*(\S+)", line)
|
|
if m:
|
|
cfg["models"]["tagger"]["ollama_endpoint"] = m.group(1).strip()
|
|
m = re.match(r"\s+model:\s*(\S+)", line)
|
|
if m:
|
|
cfg["models"]["tagger"]["model"] = m.group(1).strip()
|
|
m = re.match(r"\s+temperature:\s*([\d.]+)", line)
|
|
if m:
|
|
cfg["models"]["tagger"]["temperature"] = float(m.group(1))
|
|
if in_embed:
|
|
m = re.match(r"\s+tei_endpoint:\s*(\S+)", line)
|
|
if m:
|
|
cfg["models"]["embeddings"]["tei_endpoint"] = m.group(1).strip()
|
|
if in_behavior:
|
|
m = re.match(r"\s+auto_apply:\s*(\S+)", line)
|
|
if m:
|
|
cfg["behavior"]["auto_apply"] = m.group(1).strip().lower() == "true"
|
|
m = re.match(r"\s+confidence_threshold:\s*([\d.]+)", line)
|
|
if m:
|
|
cfg["behavior"]["confidence_threshold"] = float(m.group(1))
|
|
m = re.match(r"\s+changelog:\s*(.+)", line)
|
|
if m:
|
|
cfg["behavior"]["changelog"] = m.group(1).strip()
|
|
except Exception as e:
|
|
print(f"[warn] Could not fully parse {config_path}: {e}", file=sys.stderr)
|
|
return cfg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frontmatter parser
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
"""Parse YAML frontmatter. Returns (fm_dict, body_text)."""
|
|
fm: dict = {}
|
|
m = re.match(r"^---\r?\n(.*?)\r?\n---\r?\n?(.*)", text, re.DOTALL)
|
|
if not m:
|
|
return fm, text
|
|
|
|
fm_raw = m.group(1)
|
|
body = m.group(2)
|
|
|
|
lines = fm_raw.splitlines()
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
if not line.strip() or line.strip().startswith("#"):
|
|
i += 1
|
|
continue
|
|
kv = re.match(r"^(\w[\w-]*):\s*(.*)", line)
|
|
if not kv:
|
|
i += 1
|
|
continue
|
|
key = kv.group(1)
|
|
val_raw = kv.group(2).strip()
|
|
|
|
if val_raw.startswith("["):
|
|
inner = re.sub(r"^\[|\]$", "", val_raw).strip()
|
|
fm[key] = [x.strip().strip("\"'") for x in inner.split(",") if x.strip()] if inner else []
|
|
i += 1
|
|
continue
|
|
|
|
if not val_raw:
|
|
lst = []
|
|
j = i + 1
|
|
while j < len(lines) and re.match(r"^\s+-\s+(.*)", lines[j]):
|
|
item_m = re.match(r"^\s+-\s+(.*)", lines[j])
|
|
lst.append(item_m.group(1).strip().strip("\"'"))
|
|
j += 1
|
|
fm[key] = lst
|
|
i = j
|
|
continue
|
|
|
|
fm[key] = val_raw.strip("\"'")
|
|
i += 1
|
|
|
|
return fm, body
|
|
|
|
|
|
def render_frontmatter(fm: dict) -> str:
|
|
"""Render frontmatter dict to YAML string (between --- markers)."""
|
|
lines = ["---"]
|
|
key_order = ["title", "type", "tags", "aliases", "related", "updated"]
|
|
written: set[str] = set()
|
|
|
|
for key in key_order:
|
|
if key not in fm:
|
|
continue
|
|
written.add(key)
|
|
val = fm[key]
|
|
if isinstance(val, list):
|
|
if not val:
|
|
lines.append(f"{key}: []")
|
|
else:
|
|
lines.append(f"{key}:")
|
|
for item in val:
|
|
lines.append(f" - {item}")
|
|
else:
|
|
sv = str(val)
|
|
if any(c in sv for c in ':#{}[]|>&*!,?'):
|
|
lines.append(f'{key}: "{sv}"')
|
|
else:
|
|
lines.append(f"{key}: {sv}")
|
|
|
|
for key, val in fm.items():
|
|
if key in written:
|
|
continue
|
|
if isinstance(val, list):
|
|
if not val:
|
|
lines.append(f"{key}: []")
|
|
else:
|
|
lines.append(f"{key}:")
|
|
for item in val:
|
|
lines.append(f" - {item}")
|
|
else:
|
|
sv = str(val)
|
|
if any(c in sv for c in ':#{}[]|>&*!,?'):
|
|
lines.append(f'{key}: "{sv}"')
|
|
else:
|
|
lines.append(f"{key}: {sv}")
|
|
|
|
lines.append("---")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _http_post(url: str, payload: dict, timeout: int = 120) -> dict | list:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
url, data=data,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Existing-doc index (the core of v4)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _norm(s: str) -> str:
|
|
"""Normalize: lowercase, spaces/underscores → hyphens."""
|
|
return re.sub(r"[\s_]+", "-", s.strip().lower())
|
|
|
|
|
|
def build_doc_index(vault_dir: Path) -> dict[str, Path]:
|
|
"""
|
|
Build a mapping of normalized name → Path for ALL vault docs.
|
|
Keys: normalized basename, normalized frontmatter title, normalized aliases.
|
|
Longer/more-specific keys win over shorter on conflict.
|
|
Archive excluded.
|
|
"""
|
|
index: dict[str, Path] = {}
|
|
|
|
for p in sorted(vault_dir.rglob("*.md")):
|
|
if "archive" in p.parts:
|
|
continue
|
|
try:
|
|
text = p.read_text(encoding="utf-8", errors="replace")
|
|
fm, _ = parse_frontmatter(text)
|
|
except Exception:
|
|
fm = {}
|
|
|
|
keys: list[str] = [_norm(p.stem)]
|
|
title = fm.get("title", "")
|
|
if title:
|
|
keys.append(_norm(str(title)))
|
|
for alias in fm.get("aliases", []) or []:
|
|
if alias:
|
|
keys.append(_norm(str(alias)))
|
|
|
|
for key in keys:
|
|
if key and key not in index:
|
|
index[key] = p
|
|
elif key and key in index:
|
|
# Keep longer stem (more specific)
|
|
if len(p.stem) > len(index[key].stem):
|
|
index[key] = p
|
|
|
|
return index
|
|
|
|
|
|
def resolve_term_to_doc(term: str, doc_index: dict[str, Path]) -> Path | None:
|
|
"""
|
|
Given a surface term, find the most-specific existing doc.
|
|
Returns Path or None.
|
|
"""
|
|
norm = _norm(term)
|
|
return doc_index.get(norm)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ollama tagger — assigns 1-3 category tags
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TAGGER_SYSTEM = """You are the Echo6 vault tagger. Given a markdown document and a list of allowed topic categories, assign tags that honestly describe what the doc is primarily about. Output ONLY valid JSON. No prose, no markdown fences.
|
|
|
|
Rules:
|
|
- Only use tags from the provided topic_categories list.
|
|
- Output the FEWEST tags that are accurate. DEFAULT to exactly ONE tag — the document's primary subject.
|
|
- Add a SECOND tag ONLY if the document is genuinely, substantially about two co-equal subjects.
|
|
- NEVER add a tag for something merely mentioned, proxied, or tangentially related.
|
|
- A third tag is almost never correct.
|
|
- Primary topic first (most central).
|
|
- confidence: 0.0-1.0
|
|
|
|
Negative examples (DO NOT do this):
|
|
- A Caddy reverse-proxy config doc is ["dns"], NOT ["dns", "matrix"] — even if it proxies Matrix.
|
|
- A PeerTube channel guide is ["media"], NOT ["media", "recon"] — even if recon uses a channel.
|
|
- A generic container runbook is ["proxmox"], NOT ["proxmox", "mesh"] — even if the CT joins the mesh.
|
|
- An Authentik access-groups doc is ["auth"], NOT ["auth", "mesh"] — tangential mesh mention does not earn a tag."""
|
|
|
|
|
|
def ollama_tag(doc_text: str, topic_categories: list[str], config: dict) -> dict:
|
|
"""
|
|
Call Qwen vault-tagger to assign 1-3 category tags.
|
|
Returns {tags: [...], confidence: float}.
|
|
"""
|
|
tagger_cfg = config.get("models", {}).get("tagger", {})
|
|
endpoint = tagger_cfg.get("ollama_endpoint", "http://localhost:11434")
|
|
model = tagger_cfg.get("model", "vault-tagger")
|
|
temperature = tagger_cfg.get("temperature", 0.1)
|
|
|
|
cats_str = ", ".join(topic_categories)
|
|
doc_truncated = doc_text[:5000]
|
|
if len(doc_text) > 5000:
|
|
doc_truncated += "\n\n[... truncated ...]"
|
|
|
|
user_msg = (
|
|
f"topic_categories (ONLY use these): {cats_str}\n\n"
|
|
f"## Document\n\n{doc_truncated}\n\n"
|
|
f"Return ONLY this JSON:\n"
|
|
f'{{\n "tags": ["primary_tag"],\n "confidence": 0.85\n}}'
|
|
)
|
|
|
|
payload = {
|
|
"model": model,
|
|
"messages": [
|
|
{"role": "system", "content": _TAGGER_SYSTEM},
|
|
{"role": "user", "content": user_msg},
|
|
],
|
|
"format": "json",
|
|
"stream": False,
|
|
"options": {"temperature": temperature},
|
|
}
|
|
|
|
try:
|
|
resp = _http_post(f"{endpoint}/api/chat", payload, timeout=120)
|
|
# /api/chat returns {"message": {"role": "assistant", "content": "..."}}
|
|
if isinstance(resp, dict):
|
|
raw = resp.get("message", {}).get("content", "") or resp.get("response", "")
|
|
else:
|
|
raw = ""
|
|
result = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
result = json.loads(m.group(0)) if m else {}
|
|
except Exception as e:
|
|
print(f"[warn] Tagger call failed: {e}", file=sys.stderr)
|
|
return {"tags": [], "confidence": 0.0}
|
|
|
|
valid_cats = set(topic_categories)
|
|
tags = []
|
|
if isinstance(result.get("tags"), list):
|
|
for t in result["tags"]:
|
|
if isinstance(t, str) and t.strip() in valid_cats and t.strip() not in tags:
|
|
tags.append(t.strip())
|
|
tags = tags[:3]
|
|
|
|
try:
|
|
conf = float(result.get("confidence", 0.0))
|
|
conf = max(0.0, min(1.0, conf))
|
|
except (TypeError, ValueError):
|
|
conf = 0.0
|
|
|
|
return {"tags": tags, "confidence": conf}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Body wikilinking — existing docs only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_skip_spans(body: str) -> list[tuple[int, int]]:
|
|
"""
|
|
Return spans that must NOT be modified:
|
|
fenced code blocks, inline code, existing [[wikilinks]], headings, URLs,
|
|
domain-like patterns.
|
|
"""
|
|
spans: list[tuple[int, int]] = []
|
|
|
|
for m in re.finditer(r"```.*?```", body, re.DOTALL):
|
|
spans.append((m.start(), m.end()))
|
|
for m in re.finditer(r"`[^`\n]+`", body):
|
|
spans.append((m.start(), m.end()))
|
|
for m in re.finditer(r"\[\[.*?\]\]", body):
|
|
spans.append((m.start(), m.end()))
|
|
for m in re.finditer(r"^#+\s+.*$", body, re.MULTILINE):
|
|
spans.append((m.start(), m.end()))
|
|
for m in re.finditer(r"https?://\S+", body):
|
|
spans.append((m.start(), m.end()))
|
|
# Domain-like: prevent "mesh" in "mesh.echo6.co"
|
|
for m in re.finditer(r"\b\w[\w\-]*\.\w+\b", body):
|
|
spans.append((m.start(), m.end()))
|
|
|
|
return spans
|
|
|
|
|
|
def _in_skip_span(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
|
|
for s, e in spans:
|
|
if start < e and end > s:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _collect_link_candidates(
|
|
body: str,
|
|
doc_index: dict[str, Path],
|
|
self_stems: set[str],
|
|
) -> list[tuple[str, str, Path]]:
|
|
"""
|
|
Scan body for terms that match existing docs (excluding self and headings/code).
|
|
Returns list of (surface_text, display_name, target_path) sorted longest-first.
|
|
Only returns candidates that actually appear in the prose.
|
|
"""
|
|
skip_spans = _build_skip_spans(body)
|
|
body_lower = body.lower()
|
|
candidates: list[tuple[str, str, Path]] = []
|
|
seen_targets: set[str] = set()
|
|
|
|
# Build list of (norm_key, display, path) sorted by key length descending
|
|
# (longest/most-specific match first)
|
|
sorted_keys = sorted(doc_index.keys(), key=len, reverse=True)
|
|
|
|
for norm_key in sorted_keys:
|
|
path = doc_index[norm_key]
|
|
stem_norm = _norm(path.stem)
|
|
|
|
# Skip self
|
|
if stem_norm in self_stems:
|
|
continue
|
|
# Skip INDEX (being deleted)
|
|
if path.stem.upper() == "INDEX":
|
|
continue
|
|
|
|
# Skip docs in archive
|
|
if "archive" in path.parts:
|
|
continue
|
|
|
|
# Derive surface variations to search for
|
|
surfaces = [norm_key, norm_key.replace("-", " ")]
|
|
# Remove duplicates
|
|
surfaces = list(dict.fromkeys(surfaces))
|
|
|
|
# Get display name (frontmatter title or stem)
|
|
try:
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
fm, _ = parse_frontmatter(text)
|
|
display = fm.get("title", "") or path.stem
|
|
except Exception:
|
|
display = path.stem
|
|
|
|
target_key = str(path)
|
|
if target_key in seen_targets:
|
|
continue
|
|
|
|
for surface in surfaces:
|
|
if len(surface) < 3:
|
|
continue
|
|
# Check if this surface appears in body (quick scan)
|
|
if surface not in body_lower:
|
|
continue
|
|
|
|
# Build word-boundary pattern
|
|
esc = re.escape(surface).replace(r"\ ", r"[\s\-]").replace(r"\-", r"[\s\-]")
|
|
pattern = rf"(?<![a-zA-Z0-9\-_\[\]])({esc})(?![a-zA-Z0-9\-_\[\]\.])"
|
|
for m in re.finditer(pattern, body, re.IGNORECASE):
|
|
s, e = m.start(1), m.end(1)
|
|
if not _in_skip_span(s, e, skip_spans):
|
|
candidates.append((m.group(1), str(display), path))
|
|
seen_targets.add(target_key)
|
|
break # found at least one prose occurrence — add candidate
|
|
if target_key in seen_targets:
|
|
break
|
|
|
|
return candidates
|
|
|
|
|
|
def apply_wikilinks(
|
|
body: str,
|
|
doc_index: dict[str, Path],
|
|
self_stems: set[str],
|
|
) -> str:
|
|
"""
|
|
Insert [[wikilinks]] for FIRST prose occurrence of each existing-doc name.
|
|
- Longest match wins (most-specific doc).
|
|
- Skip code blocks, inline code, headings, URLs, existing links.
|
|
- Idempotent.
|
|
- [[Doc|surface]] when surface differs from doc stem; [[Doc]] when same.
|
|
"""
|
|
skip_spans = _build_skip_spans(body)
|
|
result_chars = list(body)
|
|
replaced_targets: set[str] = set() # target paths already linked
|
|
offset = 0 # cumulative character offset
|
|
|
|
# Sort candidates: longest surface text first (most specific wins)
|
|
sorted_keys = sorted(doc_index.keys(), key=len, reverse=True)
|
|
|
|
replacements: list[tuple[int, int, str]] = []
|
|
replacement_spans: list[tuple[int, int]] = []
|
|
|
|
for norm_key in sorted_keys:
|
|
path = doc_index[norm_key]
|
|
stem_norm = _norm(path.stem)
|
|
|
|
if stem_norm in self_stems:
|
|
continue
|
|
if path.stem.upper() == "INDEX":
|
|
continue
|
|
if "archive" in path.parts:
|
|
continue
|
|
|
|
target_key = str(path)
|
|
if target_key in replaced_targets:
|
|
continue
|
|
|
|
surfaces = list(dict.fromkeys([norm_key, norm_key.replace("-", " ")]))
|
|
|
|
try:
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
fm, _ = parse_frontmatter(text)
|
|
display = str(fm.get("title", "") or path.stem)
|
|
except Exception:
|
|
display = path.stem
|
|
|
|
found = False
|
|
for surface in surfaces:
|
|
if len(surface) < 3:
|
|
continue
|
|
|
|
esc = re.escape(surface).replace(r"\ ", r"[\s\-]").replace(r"\-", r"[\s\-]")
|
|
pattern = rf"(?<![a-zA-Z0-9\-_\[\]])({esc})(?![a-zA-Z0-9\-_\[\]\.])"
|
|
|
|
for m in re.finditer(pattern, body, re.IGNORECASE):
|
|
s, e = m.start(1), m.end(1)
|
|
matched_text = m.group(1)
|
|
|
|
if _in_skip_span(s, e, skip_spans):
|
|
continue
|
|
if _in_skip_span(s, e, replacement_spans):
|
|
continue
|
|
|
|
# Build wikilink
|
|
if matched_text.lower() == display.lower():
|
|
wiki = f"[[{display}]]"
|
|
elif _norm(matched_text) == _norm(path.stem):
|
|
wiki = f"[[{path.stem}]]"
|
|
else:
|
|
wiki = f"[[{path.stem}|{matched_text}]]"
|
|
|
|
replacements.append((s, e, wiki))
|
|
replacement_spans.append((s, e))
|
|
replaced_targets.add(target_key)
|
|
found = True
|
|
break
|
|
|
|
if found:
|
|
break
|
|
|
|
# Apply in reverse order
|
|
replacements.sort(key=lambda x: x[0], reverse=True)
|
|
chars = list(body)
|
|
for s, e, wiki in replacements:
|
|
chars[s:e] = list(wiki)
|
|
|
|
return "".join(chars)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Type inference
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def infer_type(doc_path: Path, vault_dir: Path) -> str | None:
|
|
try:
|
|
rel = doc_path.relative_to(vault_dir)
|
|
except ValueError:
|
|
return None
|
|
parts = rel.parts
|
|
folder = parts[0].lower() if len(parts) > 1 else ""
|
|
folder_map = {
|
|
"runbooks": "runbook",
|
|
"projects": "project",
|
|
"docs": "reference",
|
|
"notes": "note",
|
|
"session-resume": "session",
|
|
"plans": "note",
|
|
}
|
|
return folder_map.get(folder, "reference")
|
|
|
|
|
|
def extract_title(fm: dict, body: str, doc_path: Path) -> str:
|
|
if fm.get("title"):
|
|
return str(fm["title"])
|
|
m = re.search(r"^#\s+(.+)", body, re.MULTILINE)
|
|
if m:
|
|
return m.group(1).strip()
|
|
return re.sub(r"[-_]+", " ", doc_path.stem).title()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TEI embedding + related
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _detect_tei_route(endpoint: str) -> str:
|
|
for route in ["/embed", "/embeddings"]:
|
|
try:
|
|
resp = _http_post(f"{endpoint}{route}", {"inputs": "test"}, timeout=10)
|
|
if isinstance(resp, list):
|
|
return route
|
|
except urllib.error.HTTPError as e:
|
|
if e.code not in (404, 405):
|
|
return route
|
|
except Exception:
|
|
continue
|
|
return "/embed"
|
|
|
|
|
|
_TEI_ROUTE_CACHE: dict[str, str] = {}
|
|
|
|
|
|
def tei_embed(text: str, endpoint: str) -> list[float]:
|
|
if endpoint not in _TEI_ROUTE_CACHE:
|
|
_TEI_ROUTE_CACHE[endpoint] = _detect_tei_route(endpoint)
|
|
route = _TEI_ROUTE_CACHE[endpoint]
|
|
resp = _http_post(f"{endpoint}{route}", {"inputs": text}, timeout=30)
|
|
if isinstance(resp, list):
|
|
return resp[0] if resp and isinstance(resp[0], list) else resp
|
|
raise RuntimeError(f"Unexpected TEI response: {type(resp)}")
|
|
|
|
|
|
def cosine_sim(a: list[float], b: list[float]) -> float:
|
|
if len(a) != len(b):
|
|
return 0.0
|
|
dot = sum(x * y for x, y in zip(a, b))
|
|
mag_a = math.sqrt(sum(x * x for x in a))
|
|
mag_b = math.sqrt(sum(y * y for y in b))
|
|
return dot / (mag_a * mag_b) if mag_a and mag_b else 0.0
|
|
|
|
|
|
def _cache_path(engine_dir: Path) -> Path:
|
|
return engine_dir / ".embcache.json"
|
|
|
|
|
|
def load_embed_cache(engine_dir: Path) -> dict:
|
|
p = _cache_path(engine_dir)
|
|
if p.exists():
|
|
try:
|
|
return json.loads(p.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def save_embed_cache(cache: dict, engine_dir: Path) -> None:
|
|
_cache_path(engine_dir).write_text(json.dumps(cache), encoding="utf-8")
|
|
|
|
|
|
def get_or_embed(
|
|
doc_path: Path, vault_dir: Path, tei_endpoint: str, cache: dict,
|
|
doc_text: str | None = None,
|
|
) -> tuple[list[float], bool]:
|
|
rel = str(doc_path.relative_to(vault_dir))
|
|
mtime = doc_path.stat().st_mtime
|
|
key = f"{rel}::{mtime:.3f}"
|
|
if key in cache:
|
|
return cache[key], True
|
|
if doc_text is None:
|
|
doc_text = doc_path.read_text(encoding="utf-8", errors="replace")
|
|
vec = tei_embed(doc_text[:4000], tei_endpoint)
|
|
cache[key] = vec
|
|
return vec, False
|
|
|
|
|
|
def find_related(
|
|
doc_path: Path, doc_vec: list[float], vault_dir: Path,
|
|
tei_endpoint: str, cache: dict, k: int = 5,
|
|
) -> list[str]:
|
|
sims: list[tuple[float, str]] = []
|
|
for md in vault_dir.rglob("*.md"):
|
|
if md == doc_path or "archive" in md.parts:
|
|
continue
|
|
try:
|
|
vec, _ = get_or_embed(md, vault_dir, tei_endpoint, cache)
|
|
sims.append((cosine_sim(doc_vec, vec), md.stem))
|
|
except Exception:
|
|
continue
|
|
sims.sort(reverse=True)
|
|
return [f"[[{stem}]]" for _, stem in sims[:k]]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core: process_doc
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def process_doc(
|
|
doc_path: Path | str,
|
|
config: dict,
|
|
dry_run: bool = True,
|
|
cache: dict | None = None,
|
|
) -> dict:
|
|
"""
|
|
Process a single vault document.
|
|
Steps:
|
|
1. Assign 1-3 category tags via Qwen tagger.
|
|
2. Build existing-doc index; insert inline wikilinks to existing docs only.
|
|
3. Update related: (bge-m3 nearest docs).
|
|
4. Write updated frontmatter (title, type, tags, related, updated).
|
|
Returns result dict. dry_run=True → nothing written.
|
|
"""
|
|
doc_path = Path(doc_path).resolve()
|
|
vault_dir = Path(config["vault_dir"]).resolve()
|
|
engine_dir = Path(config["engine_dir"]).resolve()
|
|
tei_endpoint = config["models"]["embeddings"]["tei_endpoint"]
|
|
topic_categories = config.get("topic_categories", [])
|
|
|
|
if cache is None:
|
|
cache = load_embed_cache(engine_dir)
|
|
|
|
doc_text = doc_path.read_text(encoding="utf-8", errors="replace")
|
|
existing_fm, body = parse_frontmatter(doc_text)
|
|
|
|
# --- Step 1: Tags via Qwen ---
|
|
print(f"[tag] Calling tagger on {doc_path.name} ...", file=sys.stderr)
|
|
tag_result = ollama_tag(doc_text, topic_categories, config)
|
|
print(f"[tag] tags={tag_result['tags']} confidence={tag_result['confidence']:.2f}", file=sys.stderr)
|
|
|
|
# --- Step 2: Build doc index + wikilinks ---
|
|
print(f"[link] Building doc index ...", file=sys.stderr)
|
|
doc_index = build_doc_index(vault_dir)
|
|
|
|
# Self stems to exclude (don't self-link)
|
|
self_stems = {_norm(doc_path.stem)}
|
|
title_val = existing_fm.get("title", "")
|
|
if title_val:
|
|
self_stems.add(_norm(str(title_val)))
|
|
|
|
print(f"[link] Applying wikilinks to {doc_path.name} ...", file=sys.stderr)
|
|
linked_body = apply_wikilinks(body, doc_index, self_stems)
|
|
|
|
body_diff = "".join(difflib.unified_diff(
|
|
body.splitlines(keepends=True),
|
|
linked_body.splitlines(keepends=True),
|
|
fromfile=f"a/{doc_path.name}",
|
|
tofile=f"b/{doc_path.name} (linked)",
|
|
n=2,
|
|
))
|
|
|
|
# --- Step 3: related (bge-m3) ---
|
|
print(f"[embed] Embedding {doc_path.name} ...", file=sys.stderr)
|
|
related_links: list[str] = []
|
|
try:
|
|
doc_vec, cache_hit = get_or_embed(doc_path, vault_dir, tei_endpoint, cache, doc_text=doc_text)
|
|
if not cache_hit:
|
|
save_embed_cache(cache, engine_dir)
|
|
related_links = find_related(doc_path, doc_vec, vault_dir, tei_endpoint, cache, k=5)
|
|
save_embed_cache(cache, engine_dir)
|
|
print(f"[embed] {len(related_links)} related", file=sys.stderr)
|
|
except Exception as e:
|
|
print(f"[warn] Embedding failed: {e}", file=sys.stderr)
|
|
|
|
# --- Step 4: Frontmatter ---
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
new_fm = dict(existing_fm)
|
|
|
|
if not new_fm.get("title"):
|
|
new_fm["title"] = extract_title(existing_fm, body, doc_path)
|
|
|
|
inferred = infer_type(doc_path, vault_dir)
|
|
if inferred:
|
|
new_fm["type"] = inferred
|
|
|
|
# Tags: use tagger result; fall back to existing tags if tagger returns empty
|
|
if tag_result["tags"]:
|
|
new_fm["tags"] = tag_result["tags"]
|
|
elif not new_fm.get("tags"):
|
|
new_fm["tags"] = []
|
|
|
|
if "aliases" not in new_fm:
|
|
new_fm["aliases"] = []
|
|
|
|
# related: replace with embedding results (or keep existing if embed failed)
|
|
if related_links:
|
|
new_fm["related"] = related_links
|
|
|
|
new_fm["updated"] = today
|
|
|
|
new_fm_text = render_frontmatter(new_fm)
|
|
new_doc_text = new_fm_text + linked_body
|
|
|
|
full_diff = "".join(difflib.unified_diff(
|
|
doc_text.splitlines(keepends=True),
|
|
new_doc_text.splitlines(keepends=True),
|
|
fromfile=f"a/{doc_path.name}",
|
|
tofile=f"b/{doc_path.name}",
|
|
n=2,
|
|
))
|
|
|
|
applied = False
|
|
if not dry_run:
|
|
if config["behavior"].get("auto_apply", True):
|
|
doc_path.write_text(new_doc_text, encoding="utf-8")
|
|
applied = True
|
|
print(f"[apply] Wrote {doc_path}", file=sys.stderr)
|
|
|
|
return {
|
|
"path": str(doc_path),
|
|
"tag_result": tag_result,
|
|
"body_diff": body_diff,
|
|
"full_diff": full_diff,
|
|
"related_links": related_links,
|
|
"proposed_frontmatter": new_fm_text,
|
|
"applied": applied,
|
|
"dry_run": dry_run,
|
|
"existing_fm": existing_fm,
|
|
"new_fm": new_fm,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Vault tagger + existing-doc linker (v4).")
|
|
parser.add_argument("path", help="Path to the vault markdown document")
|
|
parser.add_argument("--dry-run", action="store_true", help="Print diff, write nothing")
|
|
args = parser.parse_args()
|
|
|
|
here = Path(__file__).parent
|
|
config_path = here.parent / "config.yaml"
|
|
if not config_path.exists():
|
|
config_path = Path("/home/zvx/projects/.ref/engine/config.yaml")
|
|
config = load_config(config_path)
|
|
|
|
doc_path = Path(args.path).resolve()
|
|
if not doc_path.exists():
|
|
print(f"[error] File not found: {doc_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Skip symlinks — vault/CLAUDE-baseline.md and vault/rules point outside the
|
|
# repo (to ~/.claude/). Editing them would silently modify external files.
|
|
if os.path.islink(args.path) or doc_path != Path(args.path).resolve():
|
|
print(f"[skip] Symlink — refusing to edit external target: {doc_path}", file=sys.stderr)
|
|
sys.exit(0)
|
|
|
|
result = process_doc(doc_path, config, dry_run=args.dry_run)
|
|
|
|
print("\n" + "=" * 72)
|
|
print(f"VAULT TAGGER v4 {'DRY-RUN ' if args.dry_run else ''}— {doc_path.name}")
|
|
print("=" * 72)
|
|
print(f"\n### Tags")
|
|
print(f" {result['tag_result']['tags']} (confidence={result['tag_result']['confidence']:.2f})")
|
|
print(f"\n### Body wikilink diff")
|
|
print(result["body_diff"] or " (no new wikilinks)")
|
|
print(f"\n### Related (bge-m3 top-5)")
|
|
for r in result["related_links"]:
|
|
print(f" {r}")
|
|
print(f"\n### Proposed frontmatter")
|
|
print(result["proposed_frontmatter"])
|
|
if args.dry_run:
|
|
print("[DRY-RUN] Nothing written.")
|
|
else:
|
|
print(f"[APPLY] applied={result['applied']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|