2026-06-18 06:00:06 +00:00
#!/usr/bin/env python3
2026-06-18 05:49:07 +00:00
"""
2026-06-18 18:00:10 +00:00
agent . py — Vault Tagger + Existing - Doc Linker ( v4 — simple model )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
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 .
2026-06-18 06:00:06 +00:00
Usage :
python3 engine / lib / agent . py - - dry - run < path > # print diff, write nothing
2026-06-18 18:00:10 +00:00
python3 engine / lib / agent . py < path > # apply
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
stdlib only — no pip . All HTTP via urllib .
2026-06-18 05:49:07 +00:00
"""
2026-06-18 06:00:06 +00:00
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
2026-06-18 18:00:10 +00:00
2026-06-18 06:00:06 +00:00
# ---------------------------------------------------------------------------
2026-06-18 12:00:08 +00:00
# Config loader (minimal stdlib YAML parser)
2026-06-18 06:00:06 +00:00
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
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
2026-06-18 06:00:06 +00:00
def load_config ( config_path : str | Path | None = None ) - > dict :
2026-06-18 18:00:10 +00:00
""" Load config.yaml; fall back to defaults. """
2026-06-18 06:00:06 +00:00
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
2026-06-18 18:00:10 +00:00
for c in [ here . parent / " config.yaml " , Path ( " /home/zvx/projects/.ref/engine/config.yaml " ) ] :
2026-06-18 06:00:06 +00:00
if c . exists ( ) :
config_path = c
break
if config_path is None or not Path ( config_path ) . exists ( ) :
return defaults
cfg = dict ( defaults )
try :
2026-06-18 18:00:10 +00:00
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
2026-06-18 06:00:06 +00:00
for line in lines :
2026-06-18 18:00:10 +00:00
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
2026-06-18 06:00:06 +00:00
in_behavior = True
2026-06-18 18:00:10 +00:00
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 ( )
2026-06-18 06:00:06 +00:00
if in_behavior :
2026-06-18 18:00:10 +00:00
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 ( )
2026-06-18 06:00:06 +00:00
except Exception as e :
2026-06-18 18:00:10 +00:00
print ( f " [warn] Could not fully parse { config_path } : { e } " , file = sys . stderr )
2026-06-18 06:00:06 +00:00
return cfg
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
# Frontmatter parser
2026-06-18 06:00:06 +00:00
# ---------------------------------------------------------------------------
def parse_frontmatter ( text : str ) - > tuple [ dict , str ] :
2026-06-18 12:00:08 +00:00
""" Parse YAML frontmatter. Returns (fm_dict, body_text). """
2026-06-18 06:00:06 +00:00
fm : dict = { }
2026-06-18 18:00:10 +00:00
m = re . match ( r " ^--- \ r? \ n(.*?) \ r? \ n--- \ r? \ n?(.*) " , text , re . DOTALL )
2026-06-18 06:00:06 +00:00
if not m :
2026-06-18 18:00:10 +00:00
return fm , text
2026-06-18 06:00:06 +00:00
fm_raw = m . group ( 1 )
body = m . group ( 2 )
lines = fm_raw . splitlines ( )
i = 0
while i < len ( lines ) :
line = lines [ i ]
2026-06-18 18:00:10 +00:00
if not line . strip ( ) or line . strip ( ) . startswith ( " # " ) :
2026-06-18 06:00:06 +00:00
i + = 1
continue
2026-06-18 18:00:10 +00:00
kv = re . match ( r " ^( \ w[ \ w-]*): \ s*(.*) " , line )
2026-06-18 06:00:06 +00:00
if not kv :
i + = 1
continue
key = kv . group ( 1 )
val_raw = kv . group ( 2 ) . strip ( )
2026-06-18 18:00:10 +00:00
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 [ ]
2026-06-18 06:00:06 +00:00
i + = 1
continue
if not val_raw :
lst = [ ]
j = i + 1
2026-06-18 18:00:10 +00:00
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 ( " \" ' " ) )
2026-06-18 06:00:06 +00:00
j + = 1
2026-06-18 18:00:10 +00:00
fm [ key ] = lst
i = j
continue
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
fm [ key ] = val_raw . strip ( " \" ' " )
2026-06-18 06:00:06 +00:00
i + = 1
return fm , body
def render_frontmatter ( fm : dict ) - > str :
2026-06-18 12:00:08 +00:00
""" Render frontmatter dict to YAML string (between --- markers). """
2026-06-18 06:00:06 +00:00
lines = [ " --- " ]
key_order = [ " title " , " type " , " tags " , " aliases " , " related " , " updated " ]
2026-06-18 18:00:10 +00:00
written : set [ str ] = set ( )
2026-06-18 06:00:06 +00:00
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 "
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
# HTTP helpers
2026-06-18 06:00:06 +00:00
# ---------------------------------------------------------------------------
def _http_post ( url : str , payload : dict , timeout : int = 120 ) - > dict | list :
data = json . dumps ( payload ) . encode ( " utf-8 " )
req = urllib . request . Request (
2026-06-18 18:00:10 +00:00
url , data = data ,
2026-06-18 06:00:06 +00:00
headers = { " Content-Type " : " application/json " } ,
method = " POST " ,
)
with urllib . request . urlopen ( req , timeout = timeout ) as resp :
return json . loads ( resp . read ( ) . decode ( " utf-8 " ) )
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
# Existing-doc index (the core of v4)
2026-06-18 06:00:06 +00:00
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
def _norm ( s : str ) - > str :
""" Normalize: lowercase, spaces/underscores → hyphens. """
return re . sub ( r " [ \ s_]+ " , " - " , s . strip ( ) . lower ( ) )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
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 .
2026-06-18 12:00:08 +00:00
"""
2026-06-18 18:00:10 +00:00
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 = { }
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
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 ) ) )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
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
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
return index
def resolve_term_to_doc ( term : str , doc_index : dict [ str , Path ] ) - > Path | None :
2026-06-18 12:00:08 +00:00
"""
2026-06-18 18:00:10 +00:00
Given a surface term , find the most - specific existing doc .
Returns Path or None .
2026-06-18 12:00:08 +00:00
"""
2026-06-18 18:00:10 +00:00
norm = _norm ( term )
return doc_index . get ( norm )
2026-06-18 12:00:08 +00:00
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
# Ollama tagger — assigns 1-3 category tags
2026-06-18 12:00:08 +00:00
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
_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 . """
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
def ollama_tag ( doc_text : str , topic_categories : list [ str ] , config : dict ) - > dict :
2026-06-18 12:00:08 +00:00
"""
2026-06-18 18:00:10 +00:00
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 )
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
cats_str = " , " . join ( topic_categories )
doc_truncated = doc_text [ : 5000 ]
if len ( doc_text ) > 5000 :
doc_truncated + = " \n \n [... truncated ...] "
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
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 }} '
)
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
payload = {
" model " : model ,
" messages " : [
{ " role " : " system " , " content " : _TAGGER_SYSTEM } ,
{ " role " : " user " , " content " : user_msg } ,
] ,
" format " : " json " ,
" stream " : False ,
" options " : { " temperature " : temperature } ,
}
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
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 }
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
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 ]
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
try :
conf = float ( result . get ( " confidence " , 0.0 ) )
conf = max ( 0.0 , min ( 1.0 , conf ) )
except ( TypeError , ValueError ) :
conf = 0.0
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
return { " tags " : tags , " confidence " : conf }
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
# ---------------------------------------------------------------------------
# Body wikilinking — existing docs only
# ---------------------------------------------------------------------------
def _build_skip_spans ( body : str ) - > list [ tuple [ int , int ] ] :
2026-06-18 12:00:08 +00:00
"""
2026-06-18 18:00:10 +00:00
Return spans that must NOT be modified :
fenced code blocks , inline code , existing [ [ wikilinks ] ] , headings , URLs ,
domain - like patterns .
2026-06-18 12:00:08 +00:00
"""
2026-06-18 18:00:10 +00:00
spans : list [ tuple [ int , int ] ] = [ ]
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
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 ( ) ) )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
return spans
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
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
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
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 ( )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
# 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 )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
for norm_key in sorted_keys :
path = doc_index [ norm_key ]
stem_norm = _norm ( path . stem )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
# 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
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
# 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
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
target_key = str ( path )
if target_key in seen_targets :
continue
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
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
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
# 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
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
return candidates
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
def apply_wikilinks (
body : str ,
doc_index : dict [ str , Path ] ,
self_stems : set [ str ] ,
) - > str :
2026-06-18 05:49:07 +00:00
"""
2026-06-18 18:00:10 +00:00
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 .
2026-06-18 06:00:06 +00:00
"""
2026-06-18 18:00:10 +00:00
skip_spans = _build_skip_spans ( body )
result_chars = list ( body )
replaced_targets : set [ str ] = set ( ) # target paths already linked
offset = 0 # cumulative character offset
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
# Sort candidates: longest surface text first (most specific wins)
sorted_keys = sorted ( doc_index . keys ( ) , key = len , reverse = True )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
replacements : list [ tuple [ int , int , str ] ] = [ ]
replacement_spans : list [ tuple [ int , int ] ] = [ ]
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
for norm_key in sorted_keys :
path = doc_index [ norm_key ]
stem_norm = _norm ( path . stem )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
if stem_norm in self_stems :
continue
if path . stem . upper ( ) == " INDEX " :
continue
if " archive " in path . parts :
continue
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
target_key = str ( path )
if target_key in replaced_targets :
continue
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
surfaces = list ( dict . fromkeys ( [ norm_key , norm_key . replace ( " - " , " " ) ] ) )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
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 :
2026-06-18 12:00:08 +00:00
continue
2026-06-18 18:00:10 +00:00
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
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
if found :
break
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
# 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 )
2026-06-18 12:00:08 +00:00
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
# Type inference
2026-06-18 12:00:08 +00:00
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
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 " )
2026-06-18 12:00:08 +00:00
2026-06-18 18:00:10 +00:00
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 ( )
2026-06-18 06:00:06 +00:00
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
# TEI embedding + related
2026-06-18 06:00:06 +00:00
# ---------------------------------------------------------------------------
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 ) :
2026-06-18 12:00:08 +00:00
return route
2026-06-18 06:00:06 +00:00
except Exception :
continue
2026-06-18 12:00:08 +00:00
return " /embed "
2026-06-18 06:00:06 +00:00
_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 ) :
2026-06-18 18:00:10 +00:00
return resp [ 0 ] if resp and isinstance ( resp [ 0 ] , list ) else resp
raise RuntimeError ( f " Unexpected TEI response: { type ( resp ) } " )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
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
2026-06-18 06:00:06 +00:00
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 :
2026-06-18 18:00:10 +00:00
return json . loads ( p . read_text ( encoding = " utf-8 " ) )
2026-06-18 06:00:06 +00:00
except Exception :
return { }
return { }
def save_embed_cache ( cache : dict , engine_dir : Path ) - > None :
2026-06-18 18:00:10 +00:00
_cache_path ( engine_dir ) . write_text ( json . dumps ( cache ) , encoding = " utf-8 " )
2026-06-18 05:49:07 +00:00
2026-06-18 06:00:06 +00:00
def get_or_embed (
2026-06-18 18:00:10 +00:00
doc_path : Path , vault_dir : Path , tei_endpoint : str , cache : dict ,
2026-06-18 06:00:06 +00:00
doc_text : str | None = None ,
) - > tuple [ list [ float ] , bool ] :
rel = str ( doc_path . relative_to ( vault_dir ) )
mtime = doc_path . stat ( ) . st_mtime
2026-06-18 18:00:10 +00:00
key = f " { rel } :: { mtime : .3f } "
2026-06-18 06:00:06 +00:00
if key in cache :
return cache [ key ] , True
if doc_text is None :
2026-06-18 18:00:10 +00:00
doc_text = doc_path . read_text ( encoding = " utf-8 " , errors = " replace " )
vec = tei_embed ( doc_text [ : 4000 ] , tei_endpoint )
2026-06-18 06:00:06 +00:00
cache [ key ] = vec
return vec , False
2026-06-18 05:49:07 +00:00
2026-06-18 06:00:06 +00:00
def find_related (
2026-06-18 18:00:10 +00:00
doc_path : Path , doc_vec : list [ float ] , vault_dir : Path ,
tei_endpoint : str , cache : dict , k : int = 5 ,
2026-06-18 06:00:06 +00:00
) - > list [ str ] :
sims : list [ tuple [ float , str ] ] = [ ]
for md in vault_dir . rglob ( " *.md " ) :
2026-06-18 18:00:10 +00:00
if md == doc_path or " archive " in md . parts :
2026-06-18 06:00:06 +00:00
continue
try :
vec , _ = get_or_embed ( md , vault_dir , tei_endpoint , cache )
2026-06-18 18:00:10 +00:00
sims . append ( ( cosine_sim ( doc_vec , vec ) , md . stem ) )
2026-06-18 06:00:06 +00:00
except Exception :
continue
sims . sort ( reverse = True )
return [ f " [[ { stem } ]] " for _ , stem in sims [ : k ] ]
# ---------------------------------------------------------------------------
2026-06-18 18:00:10 +00:00
# Core: process_doc
2026-06-18 06:00:06 +00:00
# ---------------------------------------------------------------------------
def process_doc (
doc_path : Path | str ,
config : dict ,
dry_run : bool = True ,
cache : dict | None = None ,
) - > dict :
"""
2026-06-18 18:00:10 +00:00
Process a single vault document .
2026-06-18 12:00:08 +00:00
Steps :
2026-06-18 18:00:10 +00:00
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 .
2026-06-18 06:00:06 +00:00
"""
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 " ]
2026-06-18 18:00:10 +00:00
topic_categories = config . get ( " topic_categories " , [ ] )
2026-06-18 06:00:06 +00:00
if cache is None :
cache = load_embed_cache ( engine_dir )
2026-06-18 18:00:10 +00:00
doc_text = doc_path . read_text ( encoding = " utf-8 " , errors = " replace " )
2026-06-18 06:00:06 +00:00
existing_fm , body = parse_frontmatter ( doc_text )
2026-06-18 18:00:10 +00:00
# --- 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 )
2026-06-18 06:00:06 +00:00
2026-06-18 12:00:08 +00:00
body_diff = " " . join ( difflib . unified_diff (
body . splitlines ( keepends = True ) ,
linked_body . splitlines ( keepends = True ) ,
2026-06-18 18:00:10 +00:00
fromfile = f " a/ { doc_path . name } " ,
tofile = f " b/ { doc_path . name } (linked) " ,
2026-06-18 12:00:08 +00:00
n = 2 ,
) )
2026-06-18 18:00:10 +00:00
# --- Step 3: related (bge-m3) ---
2026-06-18 06:00:06 +00:00
print ( f " [embed] Embedding { doc_path . name } ... " , file = sys . stderr )
2026-06-18 12:00:08 +00:00
related_links : list [ str ] = [ ]
2026-06-18 06:00:06 +00:00
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 )
2026-06-18 12:00:08 +00:00
save_embed_cache ( cache , engine_dir )
2026-06-18 18:00:10 +00:00
print ( f " [embed] { len ( related_links ) } related " , file = sys . stderr )
2026-06-18 06:00:06 +00:00
except Exception as e :
2026-06-18 18:00:10 +00:00
print ( f " [warn] Embedding failed: { e } " , file = sys . stderr )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
# --- Step 4: Frontmatter ---
2026-06-18 12:00:08 +00:00
today = datetime . now ( timezone . utc ) . strftime ( " % Y- % m- %d " )
new_fm = dict ( existing_fm )
2026-06-18 06:00:06 +00:00
2026-06-18 12:00:08 +00:00
if not new_fm . get ( " title " ) :
new_fm [ " title " ] = extract_title ( existing_fm , body , doc_path )
2026-06-18 18:00:10 +00:00
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 " ) :
2026-06-18 12:00:08 +00:00
new_fm [ " tags " ] = [ ]
if " aliases " not in new_fm :
new_fm [ " aliases " ] = [ ]
2026-06-18 18:00:10 +00:00
# related: replace with embedding results (or keep existing if embed failed)
if related_links :
2026-06-18 12:00:08 +00:00
new_fm [ " related " ] = related_links
2026-06-18 18:00:10 +00:00
2026-06-18 12:00:08 +00:00
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 ,
) )
2026-06-18 06:00:06 +00:00
applied = False
if not dry_run :
2026-06-18 18:00:10 +00:00
if config [ " behavior " ] . get ( " auto_apply " , True ) :
doc_path . write_text ( new_doc_text , encoding = " utf-8 " )
2026-06-18 06:00:06 +00:00
applied = True
print ( f " [apply] Wrote { doc_path } " , file = sys . stderr )
return {
" path " : str ( doc_path ) ,
2026-06-18 18:00:10 +00:00
" tag_result " : tag_result ,
2026-06-18 12:00:08 +00:00
" body_diff " : body_diff ,
2026-06-18 18:00:10 +00:00
" full_diff " : full_diff ,
2026-06-18 06:00:06 +00:00
" related_links " : related_links ,
" proposed_frontmatter " : new_fm_text ,
" applied " : applied ,
" dry_run " : dry_run ,
" existing_fm " : existing_fm ,
" new_fm " : new_fm ,
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
2026-06-18 05:49:07 +00:00
def main ( ) - > None :
2026-06-18 18:00:10 +00:00
parser = argparse . ArgumentParser ( description = " Vault tagger + existing-doc linker (v4). " )
2026-06-18 06:00:06 +00:00
parser . add_argument ( " path " , help = " Path to the vault markdown document " )
2026-06-18 18:00:10 +00:00
parser . add_argument ( " --dry-run " , action = " store_true " , help = " Print diff, write nothing " )
2026-06-18 06:00:06 +00:00
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 )
2026-06-18 18:00:10 +00:00
# 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 )
2026-06-18 06:00:06 +00:00
2026-06-18 18:00:10 +00:00
result = process_doc ( doc_path , config , dry_run = args . dry_run )
2026-06-18 12:00:08 +00:00
print ( " \n " + " = " * 72 )
2026-06-18 18:00:10 +00:00
print ( f " VAULT TAGGER v4 { ' DRY-RUN ' if args . dry_run else ' ' } — { doc_path . name } " )
2026-06-18 12:00:08 +00:00
print ( " = " * 72 )
2026-06-18 18:00:10 +00:00
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 " )
2026-06-18 06:00:06 +00:00
print ( result [ " proposed_frontmatter " ] )
if args . dry_run :
2026-06-18 18:00:10 +00:00
print ( " [DRY-RUN] Nothing written. " )
2026-06-18 06:00:06 +00:00
else :
2026-06-18 18:00:10 +00:00
print ( f " [APPLY] applied= { result [ ' applied ' ] } " )
2026-06-18 05:49:07 +00:00
if __name__ == " __main__ " :
main ( )