forked from jason/echo
ver 0.9
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bootstrap.py — stand up (or repair) an ECHO vault deterministically.
|
||||
|
||||
Idempotent and additive: every write is probe-before-write and NEVER overwrites
|
||||
an existing file. The marker (_agent/echo-vault.md) is written LAST, so the vault
|
||||
is only flagged "set up" once every piece is in place. Re-running is the repair
|
||||
path (it fills in only what is missing). Cross-platform: pure Python via echo.py.
|
||||
|
||||
Usage:
|
||||
bootstrap.py [--dry-run]
|
||||
|
||||
Env: ECHO_BASE, ECHO_KEY (via echo.py), ECHO_TODAY (YYYY-MM-DD for {{DATE}}).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SKILL_DIR = SCRIPT_DIR.parent
|
||||
SCAFFOLD = SKILL_DIR / "scaffold"
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import echo # noqa: E402
|
||||
|
||||
LEAVES = [
|
||||
"inbox/captures", "inbox/imports", "inbox/processing-log",
|
||||
"journal/daily", "journal/weekly", "journal/monthly", "journal/quarterly",
|
||||
"journal/annual", "journal/templates",
|
||||
"projects/active", "projects/incubating", "projects/on-hold", "projects/archived",
|
||||
"areas/business", "areas/personal", "areas/learning", "areas/systems",
|
||||
"resources/concepts", "resources/references", "resources/people",
|
||||
"resources/companies", "resources/meetings",
|
||||
"decisions/by-date",
|
||||
"_agent/context", "_agent/memory/working", "_agent/memory/episodic",
|
||||
"_agent/memory/semantic", "_agent/sessions", "_agent/health", "_agent/templates",
|
||||
"_agent/heartbeat", "_agent/skills/active", "_agent/skills/archived", "_agent/locks",
|
||||
"_agent/index",
|
||||
]
|
||||
|
||||
|
||||
def exists(path: str) -> bool:
|
||||
status, _ = echo.request("GET", echo.vault_url(path))
|
||||
return status == 200
|
||||
|
||||
|
||||
def put_text(path: str, text: str) -> None:
|
||||
status, body = echo.request("PUT", echo.vault_url(path), data=text.encode(),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
echo.check(status, body, f"bootstrap put {path}")
|
||||
|
||||
|
||||
def seed(path: str, source: Path, dry_run: bool) -> None:
|
||||
if exists(path):
|
||||
print(f"bootstrap: skip (exists) {path}")
|
||||
return
|
||||
if dry_run:
|
||||
print(f"bootstrap: would seed {path} <- {source.relative_to(SKILL_DIR)}")
|
||||
return
|
||||
text = source.read_text(encoding="utf-8").replace("{{DATE}}", echo.today())
|
||||
put_text(path, text)
|
||||
print(f"bootstrap: seeded {path}")
|
||||
|
||||
|
||||
def leaf_readme(folder: str, dry_run: bool) -> None:
|
||||
path = f"{folder}/README.md"
|
||||
if exists(path):
|
||||
return
|
||||
if dry_run:
|
||||
print(f"bootstrap: would readme {path}")
|
||||
return
|
||||
name = folder.rsplit("/", 1)[-1]
|
||||
put_text(path, f"# {name}\n\nMemory vault folder. See the echo-memory plugin for conventions.\n")
|
||||
print(f"bootstrap: readme {path}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Bootstrap or repair an ECHO vault")
|
||||
parser.add_argument("--dry-run", "-n", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not SCAFFOLD.is_dir():
|
||||
print(f"bootstrap: scaffold not found at {SCAFFOLD}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if exists("_agent/echo-vault.md"):
|
||||
status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
|
||||
version = "unknown"
|
||||
if status == 200:
|
||||
version = next((ln.split(":", 1)[1].strip()
|
||||
for ln in body.decode(errors="replace").splitlines()
|
||||
if ln.startswith("schema_version:")), "unknown")
|
||||
print(f"bootstrap: marker present (schema_version={version}). Running repair pass (fills only missing files).")
|
||||
|
||||
for folder in LEAVES:
|
||||
leaf_readme(folder, args.dry_run)
|
||||
|
||||
templates = SCAFFOLD / "templates"
|
||||
if templates.is_dir():
|
||||
for source in sorted(templates.rglob("*.md")):
|
||||
seed(source.relative_to(templates).as_posix(), source, args.dry_run)
|
||||
|
||||
seed("_agent/memory/semantic/operator-preferences.md", SCAFFOLD / "anchors" / "operator-preferences.seed.md", args.dry_run)
|
||||
seed("_agent/context/current-context.md", SCAFFOLD / "anchors" / "current-context.seed.md", args.dry_run)
|
||||
seed("inbox/captures/inbox.md", SCAFFOLD / "anchors" / "inbox.seed.md", args.dry_run)
|
||||
seed("README.md", SCAFFOLD / "README.vault.md", args.dry_run)
|
||||
seed("_agent/echo-vault.md", SCAFFOLD / "echo-vault.md", args.dry_run) # marker LAST
|
||||
|
||||
print(f"bootstrap: done ({'DRY-RUN ' if args.dry_run else ''}{echo.today()}).")
|
||||
print("bootstrap: next: create today's daily note + a bootstrap session log + heartbeat (see SKILL.md).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# bootstrap.sh — stand up (or repair) an ECHO vault deterministically.
|
||||
#
|
||||
# Idempotent and additive: every write is probe-before-write and NEVER overwrites an
|
||||
# existing file. The marker (_agent/echo-vault.md) is written LAST, so the vault is
|
||||
# only flagged "set up" once every piece is in place. Safe to re-run any time — that
|
||||
# is also the "repair" path (it fills in only what is missing).
|
||||
#
|
||||
# All scaffold is resolved relative to THIS script's location, so it works regardless
|
||||
# of the caller's CWD (fixes the old `@scaffold/...` relative-path assumption).
|
||||
#
|
||||
# Usage:
|
||||
# bootstrap.sh [--dry-run]
|
||||
#
|
||||
# Env: ECHO_BASE, ECHO_KEY (passed through to echo.sh), ECHO_TODAY (YYYY-MM-DD for {{DATE}}).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SCAFFOLD="$SKILL_DIR/scaffold"
|
||||
ECHO="$SCRIPT_DIR/echo.sh"
|
||||
TODAY="${ECHO_TODAY:-$(date +%Y-%m-%d)}"
|
||||
DRY=0
|
||||
[ "${1:-}" = "--dry-run" ] || [ "${1:-}" = "-n" ] && DRY=1
|
||||
|
||||
[ -d "$SCAFFOLD" ] || { echo "bootstrap: scaffold not found at $SCAFFOLD" >&2; exit 1; }
|
||||
[ -x "$ECHO" ] || chmod +x "$ECHO" 2>/dev/null || true
|
||||
|
||||
say() { echo "bootstrap: $*"; }
|
||||
exists() { ECHO_VERIFY=0 "$ECHO" get "$1" >/dev/null 2>&1; } # 0 = present(200), nonzero = absent/404
|
||||
|
||||
# seed VAULT_PATH from LOCAL_FILE (with {{DATE}} substitution), only if absent
|
||||
seed() {
|
||||
local vpath="$1" local_file="$2"
|
||||
if exists "$vpath"; then say "skip (exists) $vpath"; return 0; fi
|
||||
if [ "$DRY" = "1" ]; then say "would seed $vpath <- ${local_file#$SKILL_DIR/}"; return 0; fi
|
||||
sed "s/{{DATE}}/$TODAY/g" "$local_file" | ECHO_VERIFY=1 "$ECHO" put "$vpath" - >/dev/null
|
||||
say "seeded $vpath"
|
||||
}
|
||||
|
||||
# write a one-line leaf README only if absent
|
||||
leaf_readme() {
|
||||
local dir="$1" name="${1##*/}"
|
||||
local vpath="$dir/README.md"
|
||||
if exists "$vpath"; then return 0; fi
|
||||
if [ "$DRY" = "1" ]; then say "would readme $vpath"; return 0; fi
|
||||
printf '# %s\n\nMemory vault folder. See the echo-memory plugin for conventions.\n' "$name" \
|
||||
| ECHO_VERIFY=0 "$ECHO" put "$vpath" - >/dev/null
|
||||
say "readme $vpath"
|
||||
}
|
||||
|
||||
# ---- Pre-flight: is the vault already bootstrapped? --------------------------
|
||||
if exists "_agent/echo-vault.md"; then
|
||||
ver="$("$ECHO" get _agent/echo-vault.md 2>/dev/null | sed -n 's/^schema_version:[[:space:]]*//p' | head -1)"
|
||||
say "marker present (schema_version=${ver:-unknown}). Running repair pass (fills only missing files)."
|
||||
CUR_SCHEMA=2
|
||||
if [ -n "$ver" ] && [ "$ver" -lt "$CUR_SCHEMA" ] 2>/dev/null; then
|
||||
say "NOTE: schema_version $ver < $CUR_SCHEMA — run migrate.sh before relying on the vault."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 1. Folder tree (leaf READMEs guarantee non-empty dirs) ------------------
|
||||
LEAVES=(
|
||||
inbox/captures inbox/imports inbox/processing-log
|
||||
journal/daily journal/weekly journal/monthly journal/quarterly journal/annual journal/templates
|
||||
projects/active projects/incubating projects/on-hold projects/archived
|
||||
areas/business areas/personal areas/learning areas/systems
|
||||
resources/concepts resources/references resources/people resources/companies resources/meetings
|
||||
decisions/by-date
|
||||
_agent/context _agent/memory/working _agent/memory/episodic _agent/memory/semantic
|
||||
_agent/sessions _agent/health _agent/templates _agent/heartbeat
|
||||
_agent/skills/active _agent/skills/archived _agent/locks
|
||||
)
|
||||
for d in "${LEAVES[@]}"; do leaf_readme "$d"; done
|
||||
|
||||
# ---- 2. Templates (mirror scaffold/templates/ 1:1 into the vault) ------------
|
||||
if [ -d "$SCAFFOLD/templates" ]; then
|
||||
while IFS= read -r f; do
|
||||
rel="${f#$SCAFFOLD/templates/}"
|
||||
seed "$rel" "$f"
|
||||
done < <(find "$SCAFFOLD/templates" -type f -name '*.md')
|
||||
fi
|
||||
|
||||
# ---- 3. Anchor seeds (only if absent — never fabricate facts) ----------------
|
||||
seed "_agent/memory/semantic/operator-preferences.md" "$SCAFFOLD/anchors/operator-preferences.seed.md"
|
||||
seed "_agent/context/current-context.md" "$SCAFFOLD/anchors/current-context.seed.md"
|
||||
seed "inbox/captures/inbox.md" "$SCAFFOLD/anchors/inbox.seed.md"
|
||||
|
||||
# ---- 4. Vault README (human signpost) ----------------------------------------
|
||||
seed "README.md" "$SCAFFOLD/README.vault.md"
|
||||
|
||||
# ---- 5. Marker (write LAST) --------------------------------------------------
|
||||
seed "_agent/echo-vault.md" "$SCAFFOLD/echo-vault.md"
|
||||
|
||||
say "done (${DRY:+DRY-RUN }$TODAY)."
|
||||
say "Next: create today's daily note + a bootstrap session log + heartbeat (see SKILL.md First-run trace)."
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""check_routing.py — keep the routing VIEWS in sync with routing.json.
|
||||
|
||||
routing.json is the canonical route manifest. SKILL.md ("Where to Write"),
|
||||
references/routing-map.md, and references/api-reference.md are hand-maintained
|
||||
human views of it, guarded until now only by a prose "routing.json wins"
|
||||
convention. This check makes that convention mechanically true:
|
||||
|
||||
CHECK A (validity, all three docs): every concrete vault path mentioned in the
|
||||
docs must match some route OR retired pattern in routing.json. Catches
|
||||
a doc pointing at a path the manifest does not define (typo, stale row).
|
||||
|
||||
CHECK B (coverage, routing-map.md): every route in routing.json must be named
|
||||
by at least one path in routing-map.md, which claims to be the complete
|
||||
canonical map. Catches a route added to the manifest but never documented.
|
||||
|
||||
Exit: 0 = in sync, 1 = drift found, 2 = could not read inputs. No network, no vault.
|
||||
Run it in CI / from the eval harness; it is pure local file parsing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SKILL_DIR = SCRIPT_DIR.parent
|
||||
ROUTING_JSON = SCRIPT_DIR / "routing.json"
|
||||
DOCS = {
|
||||
"SKILL.md": SKILL_DIR / "SKILL.md",
|
||||
"routing-map.md": SKILL_DIR / "references" / "routing-map.md",
|
||||
"api-reference.md": SKILL_DIR / "references" / "api-reference.md",
|
||||
}
|
||||
CANONICAL_MAP = "routing-map.md"
|
||||
|
||||
ROOTS = ("_agent", "inbox", "journal", "projects", "areas", "resources", "decisions")
|
||||
FILE_SUFFIXES = (".md", ".lock", ".json")
|
||||
|
||||
# Order matters: substitute the most specific placeholder first.
|
||||
DATE_SUBS = [
|
||||
("YYYY-MM-DD", "2026-01-02"),
|
||||
("YYYY-Www", "2026-W01"),
|
||||
("YYYY-MM", "2026-01"),
|
||||
("YYYY-Qn", "2026-Q1"),
|
||||
("HHMM", "0900"),
|
||||
("YYYY", "2026"),
|
||||
]
|
||||
|
||||
# Named placeholders whose route constrains the value to a fixed vocabulary, so a
|
||||
# generic "sample" would not match the pattern.
|
||||
NAMED_SUBS = {
|
||||
"<lifecycle>": "active",
|
||||
"<domain>": "business",
|
||||
}
|
||||
|
||||
|
||||
def load_routing():
|
||||
import json
|
||||
data = json.loads(ROUTING_JSON.read_text(encoding="utf-8"))
|
||||
routes = [(r["id"], re.compile(r["pattern"]), stem(r["pattern"])) for r in data.get("routes", [])]
|
||||
retired = [re.compile(r["pattern"]) for r in data.get("retired", [])]
|
||||
return routes, retired
|
||||
|
||||
|
||||
def stem(pattern: str) -> str:
|
||||
"""Literal directory prefix of a regex pattern, e.g. ^projects/active/[^/]+\\.md$ -> projects/active/."""
|
||||
body = pattern[1:] if pattern.startswith("^") else pattern
|
||||
literal = []
|
||||
for ch in body:
|
||||
if ch in "\\([{+*?.|^$":
|
||||
break
|
||||
literal.append(ch)
|
||||
text = "".join(literal)
|
||||
return text[: text.rfind("/") + 1] if "/" in text else ""
|
||||
|
||||
|
||||
def strip_fenced_blocks(text: str) -> str:
|
||||
out, in_fence = [], False
|
||||
for line in text.splitlines():
|
||||
if line.lstrip().startswith("```"):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if not in_fence:
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def expand_braces(token: str) -> list[str]:
|
||||
"""Expand a single brace group: a/{x,y}/b -> [a/x/b, a/y/b]. Recurses for nested groups."""
|
||||
match = re.search(r"\{([^{}]*)\}", token)
|
||||
if not match:
|
||||
return [token]
|
||||
pre, post = token[: match.start()], token[match.end():]
|
||||
results = []
|
||||
for part in match.group(1).split(","):
|
||||
results.extend(expand_braces(pre + part.strip() + post))
|
||||
return results
|
||||
|
||||
|
||||
def concretize(token: str) -> str:
|
||||
for placeholder, sample in DATE_SUBS:
|
||||
token = token.replace(placeholder, sample)
|
||||
for placeholder, sample in NAMED_SUBS.items():
|
||||
token = token.replace(placeholder, sample)
|
||||
return re.sub(r"<[^>]+>", "sample", token)
|
||||
|
||||
|
||||
def looks_like_path(token: str) -> bool:
|
||||
if " " in token or "::" in token or token.count("`"):
|
||||
return False
|
||||
# README signposts can live under any folder, including placeholder folders.
|
||||
if token == "README.md" or token.endswith("/README.md"):
|
||||
return True
|
||||
# Otherwise must sit under a known vault root directory (root + "/"), which
|
||||
# excludes shorthand like `inbox.md` and non-paths like `application/vnd...`.
|
||||
if not any(token.startswith(root + "/") for root in ROOTS):
|
||||
return False
|
||||
return token.endswith("/") or token.endswith(FILE_SUFFIXES) or "<" in token or "{" in token
|
||||
|
||||
|
||||
def extract_tokens(text: str) -> set[str]:
|
||||
text = strip_fenced_blocks(text)
|
||||
tokens: set[str] = set()
|
||||
for raw in re.findall(r"`([^`]+)`", text):
|
||||
raw = raw.strip()
|
||||
for piece in expand_braces(raw):
|
||||
if looks_like_path(piece):
|
||||
tokens.add(piece)
|
||||
return tokens
|
||||
|
||||
|
||||
def classify(tokens: set[str]):
|
||||
files, dirs = set(), set()
|
||||
for token in tokens:
|
||||
if token.endswith("/"):
|
||||
dirs.add(token)
|
||||
else:
|
||||
files.add(concretize(token))
|
||||
return files, dirs
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
routes, retired = load_routing()
|
||||
except Exception as exc:
|
||||
print(f"check-routing: cannot read routing.json ({exc})", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
problems: list[str] = []
|
||||
doc_tokens: dict[str, tuple[set[str], set[str]]] = {}
|
||||
for name, path in DOCS.items():
|
||||
try:
|
||||
files, dirs = classify(extract_tokens(path.read_text(encoding="utf-8")))
|
||||
except Exception as exc:
|
||||
print(f"check-routing: cannot read {name} ({exc})", file=sys.stderr)
|
||||
return 2
|
||||
doc_tokens[name] = (files, dirs)
|
||||
|
||||
route_stems = {st for _, _, st in routes if st}
|
||||
|
||||
# CHECK A — every documented path is valid under the manifest.
|
||||
for name, (files, dirs) in doc_tokens.items():
|
||||
for f in sorted(files):
|
||||
if not (any(rx.match(f) for _, rx, _ in routes) or any(rx.match(f) for rx in retired)):
|
||||
problems.append(f"[A] {name}: path `{f}` matches no route or retired pattern in routing.json")
|
||||
for d in sorted(dirs):
|
||||
ok = (any(rx.match(d) for rx in retired)
|
||||
or any(st == d or st.startswith(d) or d.startswith(st) for st in route_stems))
|
||||
if not ok:
|
||||
problems.append(f"[A] {name}: folder `{d}` matches no route stem or retired pattern")
|
||||
|
||||
# CHECK B — every route appears in the canonical map.
|
||||
map_files, map_dirs = doc_tokens[CANONICAL_MAP]
|
||||
for rid, rx, st in routes:
|
||||
covered = any(rx.match(f) for f in map_files) or (st and st in map_dirs)
|
||||
if not covered:
|
||||
problems.append(f"[B] route '{rid}' ({rx.pattern}) is not documented in {CANONICAL_MAP}")
|
||||
|
||||
if problems:
|
||||
print(f"check-routing: {len(problems)} drift issue(s) found\n")
|
||||
for p in problems:
|
||||
print(f" - {p}")
|
||||
return 1
|
||||
print(f"check-routing: in sync — {len(routes)} routes documented and all doc paths are valid.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env python3
|
||||
"""echo.py — the single validated, cross-platform client for the ECHO Obsidian
|
||||
Local REST API.
|
||||
|
||||
Replaces the original echo.sh so the toolchain runs identically on Windows,
|
||||
macOS, and Linux with no bash / GNU-vs-BSD `date` dependency — only a Python 3
|
||||
interpreter (which the linter already requires).
|
||||
|
||||
Every read/write to the vault should go through this script. It centralizes:
|
||||
auth injection, HTTP-status checking (non-zero exit on >=400 so a failed write
|
||||
can never look like success), one bounded retry on 5xx/connection errors,
|
||||
WHOLE-LINE idempotent append (a new line that is merely a substring of an
|
||||
existing one is no longer wrongly skipped), correct `::` heading-target handling,
|
||||
frontmatter field patches, a read-back-confirmed advisory multi-writer lock, a
|
||||
one-call cold-start `load`, and scope show/set.
|
||||
|
||||
Config (env overrides; defaults match the rest of the plugin):
|
||||
ECHO_BASE default https://echoapi.alwisp.com
|
||||
ECHO_KEY default the plugin bearer token (env overrides it)
|
||||
ECHO_VERIFY default 1 — read-back verify after a PUT
|
||||
ECHO_LOCK_TTL default 900 — seconds before an advisory lock is considered stale
|
||||
ECHO_TODAY YYYY-MM-DD to use as "today" (pass the conversation's currentDate)
|
||||
|
||||
Usage:
|
||||
echo.py load # cold-start: the 6 orientation reads in one call
|
||||
echo.py get <path> # print file contents (404 -> exit 44)
|
||||
echo.py map <path> # document-map JSON (headings/blocks/frontmatter)
|
||||
echo.py ls <dir> # directory listing JSON
|
||||
echo.py search <query...> # /search/simple
|
||||
echo.py put <path> [file] # create/overwrite (body from file or stdin)
|
||||
echo.py post <path> [file] # raw append (NON-idempotent; prefer `append`)
|
||||
echo.py append <path> <line> # idempotent append: skips only on an exact whole-line match
|
||||
echo.py patch <path> <append|prepend|replace> <heading|frontmatter|block> <target> [file]
|
||||
echo.py fm <path> <field> <json-value> # PATCH a frontmatter scalar
|
||||
echo.py bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date)
|
||||
echo.py delete <path> # DELETE (destructive; explicit use only)
|
||||
echo.py lock <owner-id> # acquire advisory lock (exit 75 if held & fresh, or lost the race)
|
||||
echo.py unlock <owner-id> # release advisory lock if owned by <owner-id>
|
||||
echo.py scope show # print active scope, its freshness, and sessions-since
|
||||
echo.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
|
||||
|
||||
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 2 usage · 1 other HTTP/transport error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# Never let a non-ASCII byte (em-dash in our own output, or Unicode in a fetched
|
||||
# note) raise UnicodeEncodeError on a legacy Windows console.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
BASE = os.environ.get("ECHO_BASE", "https://echoapi.alwisp.com").rstrip("/")
|
||||
# The token stays hardcoded for this personal plugin; ECHO_KEY env still overrides.
|
||||
DEFAULT_KEY = "241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab"
|
||||
KEY = os.environ.get("ECHO_KEY") or DEFAULT_KEY
|
||||
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
|
||||
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
|
||||
|
||||
LOCK_PATH = "_agent/locks/vault.lock"
|
||||
|
||||
|
||||
class EchoError(RuntimeError):
|
||||
def __init__(self, message: str, code: int = 1) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def today() -> str:
|
||||
return os.environ.get("ECHO_TODAY") or dt.date.today().isoformat()
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def vault_url(path: str) -> str:
|
||||
safe = "/".join(urllib.parse.quote(part) for part in path.split("/"))
|
||||
if path.endswith("/") and not safe.endswith("/"):
|
||||
safe += "/"
|
||||
return f"{BASE}/vault/{safe}"
|
||||
|
||||
|
||||
def request(method: str, url: str, data: bytes | None = None,
|
||||
headers: dict[str, str] | None = None) -> tuple[int, bytes]:
|
||||
"""One bounded retry on transport failure (status 0) or 5xx. Returns (status, body)."""
|
||||
merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
|
||||
last_status, last_body = 0, b""
|
||||
for attempt in range(2):
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=merged)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
return response.status, response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read()
|
||||
if exc.code >= 500 and attempt == 0:
|
||||
time.sleep(1)
|
||||
continue
|
||||
return exc.code, body
|
||||
except Exception as exc: # connection error, timeout, DNS, ...
|
||||
last_status, last_body = 0, str(exc).encode()
|
||||
if attempt == 0:
|
||||
time.sleep(1)
|
||||
continue
|
||||
return last_status, last_body
|
||||
|
||||
|
||||
def check(status: int, body: bytes, context: str) -> None:
|
||||
if status == 404:
|
||||
raise EchoError(f"{context}: not found", 44)
|
||||
if status == 0:
|
||||
raise EchoError(f"{context}: vault unreachable ({body.decode(errors='replace')}) [{BASE}]", 1)
|
||||
if status >= 400:
|
||||
raise EchoError(f"{context}: HTTP {status} - {body.decode(errors='replace')}", 1)
|
||||
|
||||
|
||||
def read_body(arg: str | None) -> bytes:
|
||||
if arg in (None, "-"):
|
||||
raw = sys.stdin.buffer.read()
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = raw.decode(locale.getpreferredencoding(False) or "cp1252", errors="replace")
|
||||
return text.encode("utf-8")
|
||||
return Path(arg).read_bytes()
|
||||
|
||||
|
||||
def normalize_patch_body(data: bytes, op: str, target_type: str) -> bytes:
|
||||
"""Heading PATCH bodies need a leading newline on replace and a trailing
|
||||
newline on append/prepend so the API does not concatenate onto adjacent text."""
|
||||
if target_type != "heading":
|
||||
return data
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
if op == "replace":
|
||||
text = text.strip("\r\n")
|
||||
text = f"\n{text}\n" if text else "\n"
|
||||
elif text and not text.endswith("\n"):
|
||||
text += "\n"
|
||||
return text.encode("utf-8")
|
||||
|
||||
|
||||
def normalize_json_scalar(value: str) -> bytes:
|
||||
"""Accept either a raw scalar ('Fabrication Lead') or pre-formed JSON ('"2026-06-20"')."""
|
||||
value = value.strip()
|
||||
try:
|
||||
json.loads(value)
|
||||
return value.encode("utf-8")
|
||||
except json.JSONDecodeError:
|
||||
return json.dumps(value).encode("utf-8")
|
||||
|
||||
|
||||
def temp_file(data: bytes) -> str:
|
||||
fh = tempfile.NamedTemporaryFile(delete=False)
|
||||
try:
|
||||
fh.write(data)
|
||||
return fh.name
|
||||
finally:
|
||||
fh.close()
|
||||
|
||||
|
||||
# ---- commands ----------------------------------------------------------------
|
||||
|
||||
def cmd_get(path: str) -> int:
|
||||
status, body = request("GET", vault_url(path))
|
||||
check(status, body, f"get {path}")
|
||||
sys.stdout.buffer.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_map(path: str) -> int:
|
||||
status, body = request("GET", vault_url(path),
|
||||
headers={"Accept": "application/vnd.olrapi.document-map+json"})
|
||||
check(status, body, f"map {path}")
|
||||
sys.stdout.buffer.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_ls(path: str) -> int:
|
||||
if not path.endswith("/"):
|
||||
path += "/"
|
||||
status, body = request("GET", vault_url(path))
|
||||
check(status, body, f"ls {path}")
|
||||
sys.stdout.buffer.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_search(query: list[str]) -> int:
|
||||
q = urllib.parse.quote_plus(" ".join(query))
|
||||
status, body = request("POST", f"{BASE}/search/simple/?query={q}")
|
||||
check(status, body, "search")
|
||||
sys.stdout.buffer.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_put(path: str, file_arg: str | None) -> int:
|
||||
data = read_body(file_arg)
|
||||
status, body = request("PUT", vault_url(path), data=data,
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
check(status, body, f"put {path}")
|
||||
if VERIFY:
|
||||
status, _ = request("GET", vault_url(path))
|
||||
if status != 200:
|
||||
raise EchoError(f"put {path}: write did not verify (GET returned {status})")
|
||||
print(f"ok: PUT {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_post(path: str, file_arg: str | None) -> int:
|
||||
data = read_body(file_arg)
|
||||
status, body = request("POST", vault_url(path), data=data,
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
check(status, body, f"post {path}")
|
||||
print(f"ok: POST {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_append(path: str, line: str) -> int:
|
||||
"""Idempotent append. Skips ONLY when the exact whole line already exists —
|
||||
a new line that is a substring of an existing one is still appended."""
|
||||
status, body = request("GET", vault_url(path))
|
||||
if status == 200:
|
||||
existing = [ln.rstrip("\r") for ln in body.decode(errors="replace").splitlines()]
|
||||
if line.rstrip("\r") in existing:
|
||||
print(f"skip: line already present in {path}")
|
||||
return 0
|
||||
elif status != 404:
|
||||
check(status, body, f"append(read) {path}")
|
||||
status, body = request("POST", vault_url(path), data=f"{line}\n".encode(),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
check(status, body, f"append {path}")
|
||||
print(f"ok: APPEND {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_patch(path: str, op: str, target_type: str, target: str, file_arg: str | None) -> int:
|
||||
if op not in {"append", "prepend", "replace"}:
|
||||
raise EchoError("patch: op must be append, prepend, or replace", 2)
|
||||
if target_type not in {"heading", "frontmatter", "block"}:
|
||||
raise EchoError("patch: target-type must be heading, frontmatter, or block", 2)
|
||||
data = normalize_patch_body(read_body(file_arg), op, target_type)
|
||||
status, body = request(
|
||||
"PATCH",
|
||||
vault_url(path),
|
||||
data=data,
|
||||
headers={
|
||||
"Operation": op,
|
||||
"Target-Type": target_type,
|
||||
"Target": target,
|
||||
"Content-Type": "application/json" if target_type == "frontmatter" else "text/markdown",
|
||||
},
|
||||
)
|
||||
check(status, body, f"patch {path} ({target})")
|
||||
print(f"ok: PATCH {op} {target_type} '{target}' -> {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_fm(path: str, field: str, value: str) -> int:
|
||||
return cmd_patch(path, "replace", "frontmatter", field, temp_file(normalize_json_scalar(value)))
|
||||
|
||||
|
||||
def cmd_delete(path: str) -> int:
|
||||
status, body = request("DELETE", vault_url(path))
|
||||
check(status, body, f"delete {path}")
|
||||
print(f"ok: DELETE {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def parse_lock_time(value: str) -> int:
|
||||
try:
|
||||
return int(dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
||||
.replace(tzinfo=dt.timezone.utc).timestamp())
|
||||
except Exception:
|
||||
# Unparseable timestamp -> treat the lock as FRESH (return "now"), so a
|
||||
# garbled lock is respected rather than silently stomped.
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def cmd_lock(owner: str) -> int:
|
||||
status, body = request("GET", vault_url(LOCK_PATH))
|
||||
if status == 200 and body.strip():
|
||||
current = body.decode(errors="replace").strip()
|
||||
held_owner, _, held_iso = current.partition(" @ ")
|
||||
if held_owner != owner and int(time.time()) - parse_lock_time(held_iso) < LOCK_TTL:
|
||||
print(f"lock held by '{held_owner}' since {held_iso} (fresh)", file=sys.stderr)
|
||||
return 75
|
||||
status, body = request("PUT", vault_url(LOCK_PATH), data=f"{owner} @ {now_iso()}\n".encode(),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
check(status, body, "lock")
|
||||
# Read-back confirm we actually hold it. The REST API has no compare-and-swap,
|
||||
# so two racers can both PUT; last writer wins. Whoever does not own the file
|
||||
# on read-back backs off instead of proceeding under a false lock.
|
||||
status, body = request("GET", vault_url(LOCK_PATH))
|
||||
if status == 200:
|
||||
held_owner = body.decode(errors="replace").split(" @ ", 1)[0].strip()
|
||||
if held_owner != owner:
|
||||
print(f"lock race: now held by '{held_owner}', not '{owner}' — backing off", file=sys.stderr)
|
||||
return 75
|
||||
print(f"ok: locked by {owner}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_unlock(owner: str) -> int:
|
||||
status, body = request("GET", vault_url(LOCK_PATH))
|
||||
if status == 200:
|
||||
held_owner = body.decode(errors="replace").split(" @ ", 1)[0].strip()
|
||||
if held_owner != owner:
|
||||
print(f"lock owned by '{held_owner}', not '{owner}' — not releasing", file=sys.stderr)
|
||||
return 75
|
||||
status, body = request("DELETE", vault_url(LOCK_PATH))
|
||||
if status != 404:
|
||||
check(status, body, "unlock")
|
||||
print("ok: unlocked")
|
||||
return 0
|
||||
|
||||
|
||||
def extract_heading(markdown: str, heading: str) -> str:
|
||||
"""Return the body under a `## <heading>` up to the next `## ` heading."""
|
||||
out: list[str] = []
|
||||
capture = False
|
||||
marker = f"## {heading}"
|
||||
for line in markdown.splitlines():
|
||||
if line.strip() == marker:
|
||||
capture = True
|
||||
continue
|
||||
if capture and line.startswith("## "):
|
||||
break
|
||||
if capture:
|
||||
out.append(line)
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def cmd_scope(subcommand: str, text: str | None = None) -> int:
|
||||
path = "_agent/context/current-context.md"
|
||||
status, body = request("GET", vault_url(path))
|
||||
check(status, body, f"scope {subcommand}")
|
||||
current = body.decode(errors="replace")
|
||||
|
||||
if subcommand == "show":
|
||||
print("-- Active scope --")
|
||||
print(extract_heading(current, "Scope"))
|
||||
scope_updated = ""
|
||||
for line in current.splitlines():
|
||||
if line.startswith("scope_updated:"):
|
||||
scope_updated = line.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
break
|
||||
print(f"scope_updated: {scope_updated or '<missing — drift cannot be detected; run scope set or repair>'}")
|
||||
# Count session logs dated after scope_updated (the drift signal).
|
||||
if scope_updated:
|
||||
status, body = request("GET", vault_url("_agent/sessions/"))
|
||||
if status == 200:
|
||||
try:
|
||||
files = json.loads(body).get("files", [])
|
||||
n = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated)
|
||||
print(f"sessions logged since: {n}")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
if subcommand != "set":
|
||||
raise EchoError("scope: use 'show' or 'set \"<text>\"'", 2)
|
||||
if not text:
|
||||
raise EchoError("scope set needs the new scope text", 2)
|
||||
prior = extract_heading(current, "Scope").replace("\n", " ").strip()[:140] or "(prior scope)"
|
||||
cmd_patch(path, "prepend", "heading", "Current Context::Scope History",
|
||||
temp_file(f"- {today()}: {prior}\n".encode()))
|
||||
cmd_patch(path, "replace", "heading", "Current Context::Scope",
|
||||
temp_file(f"{text}\n".encode()))
|
||||
try:
|
||||
cmd_patch(path, "replace", "frontmatter", "scope_updated",
|
||||
temp_file(json.dumps(today()).encode()))
|
||||
except EchoError as exc:
|
||||
raise EchoError(
|
||||
"scope set: body switched, but scope_updated frontmatter is missing "
|
||||
f"(run bootstrap.py to add it) [{exc}]"
|
||||
)
|
||||
print(f"ok: scope switched (prior archived to Scope History; scope_updated={today()})")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_load() -> int:
|
||||
"""Cold-start orientation: the canonical 6 reads in one call. 404s on today's
|
||||
daily note and the inbox are normal (printed as absent, not errors)."""
|
||||
targets = [
|
||||
("marker", "_agent/echo-vault.md"),
|
||||
("preferences", "_agent/memory/semantic/operator-preferences.md"),
|
||||
("context", "_agent/context/current-context.md"),
|
||||
("heartbeat", "_agent/heartbeat/last-session.md"),
|
||||
("today", f"journal/daily/{today()}.md"),
|
||||
("inbox", "inbox/captures/inbox.md"),
|
||||
]
|
||||
marker_missing = False
|
||||
for label, path in targets:
|
||||
status, body = request("GET", vault_url(path))
|
||||
print(f"===== {label}: {path} (HTTP {status}) =====")
|
||||
if status == 200:
|
||||
text = body.decode(errors="replace")
|
||||
sys.stdout.write(text)
|
||||
if not text.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
elif status == 404:
|
||||
print("(absent — fine)")
|
||||
if label == "marker":
|
||||
marker_missing = True
|
||||
else:
|
||||
print(f"(error HTTP {status}: {body.decode(errors='replace')[:200]})")
|
||||
print()
|
||||
if marker_missing:
|
||||
print("NOTE: marker absent — vault not bootstrapped. Run bootstrap.py before relying on memory.")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Validated cross-platform ECHO vault client")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("load")
|
||||
sub.add_parser("get").add_argument("path")
|
||||
sub.add_parser("map").add_argument("path")
|
||||
sub.add_parser("ls").add_argument("path")
|
||||
sub.add_parser("search").add_argument("query", nargs="+")
|
||||
p = sub.add_parser("put"); p.add_argument("path"); p.add_argument("file", nargs="?")
|
||||
p = sub.add_parser("post"); p.add_argument("path"); p.add_argument("file", nargs="?")
|
||||
p = sub.add_parser("append"); p.add_argument("path"); p.add_argument("line")
|
||||
p = sub.add_parser("patch")
|
||||
p.add_argument("path"); p.add_argument("op"); p.add_argument("target_type")
|
||||
p.add_argument("target"); p.add_argument("file", nargs="?")
|
||||
p = sub.add_parser("fm"); p.add_argument("path"); p.add_argument("field"); p.add_argument("value")
|
||||
p = sub.add_parser("bump"); p.add_argument("path"); p.add_argument("date", nargs="?")
|
||||
sub.add_parser("delete").add_argument("path")
|
||||
sub.add_parser("lock").add_argument("owner")
|
||||
sub.add_parser("unlock").add_argument("owner")
|
||||
p = sub.add_parser("scope")
|
||||
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
|
||||
p.add_argument("text", nargs="?")
|
||||
|
||||
# High-level operations (entity index + cross-links + recall). See echo_ops.py.
|
||||
sub.add_parser("resolve").add_argument("mention", nargs="+")
|
||||
p = sub.add_parser("recall"); p.add_argument("query", nargs="+"); p.add_argument("--limit", type=int, default=6)
|
||||
p = sub.add_parser("link"); p.add_argument("a"); p.add_argument("b")
|
||||
p = sub.add_parser("capture")
|
||||
p.add_argument("title")
|
||||
p.add_argument("file", nargs="?")
|
||||
p.add_argument("--kind")
|
||||
p.add_argument("--inbox", action="store_true")
|
||||
p.add_argument("--status", default="")
|
||||
p.add_argument("--aliases", default="")
|
||||
p.add_argument("--source", default="")
|
||||
p.add_argument("--date")
|
||||
p.add_argument("--domain", default="business")
|
||||
p.add_argument("--no-log", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
if args.cmd == "load":
|
||||
return cmd_load()
|
||||
if args.cmd == "get":
|
||||
return cmd_get(args.path)
|
||||
if args.cmd == "map":
|
||||
return cmd_map(args.path)
|
||||
if args.cmd == "ls":
|
||||
return cmd_ls(args.path)
|
||||
if args.cmd == "search":
|
||||
return cmd_search(args.query)
|
||||
if args.cmd == "put":
|
||||
return cmd_put(args.path, args.file)
|
||||
if args.cmd == "post":
|
||||
return cmd_post(args.path, args.file)
|
||||
if args.cmd == "append":
|
||||
return cmd_append(args.path, args.line)
|
||||
if args.cmd == "patch":
|
||||
return cmd_patch(args.path, args.op, args.target_type, args.target, args.file)
|
||||
if args.cmd == "fm":
|
||||
return cmd_fm(args.path, args.field, args.value)
|
||||
if args.cmd == "bump":
|
||||
return cmd_fm(args.path, "updated", json.dumps(args.date or today()))
|
||||
if args.cmd == "delete":
|
||||
return cmd_delete(args.path)
|
||||
if args.cmd == "lock":
|
||||
return cmd_lock(args.owner)
|
||||
if args.cmd == "unlock":
|
||||
return cmd_unlock(args.owner)
|
||||
if args.cmd == "scope":
|
||||
return cmd_scope(args.subcommand, args.text)
|
||||
if args.cmd in ("resolve", "recall", "link", "capture"):
|
||||
import echo_ops # lazy: only the high-level ops pull in the index/link modules
|
||||
if args.cmd == "resolve":
|
||||
return echo_ops.resolve(" ".join(args.mention))
|
||||
if args.cmd == "recall":
|
||||
return echo_ops.recall(args.query, limit=args.limit)
|
||||
if args.cmd == "link":
|
||||
return echo_ops.link(args.a, args.b)
|
||||
return echo_ops.capture(
|
||||
args.kind, args.title, args.file, status_v=args.status,
|
||||
aliases=args.aliases.split(",") if args.aliases else [],
|
||||
sources=args.source.split(",") if args.source else [],
|
||||
date=args.date, domain=args.domain, inbox=args.inbox, no_log=args.no_log)
|
||||
except EchoError as exc:
|
||||
print(f"echo.py: {exc}", file=sys.stderr)
|
||||
return exc.code
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# echo.sh — the single validated client for the ECHO Obsidian Local REST API.
|
||||
#
|
||||
# Every read/write to the vault should go through this script instead of hand-built
|
||||
# curl. It centralizes: auth injection, HTTP-status checking (non-zero exit on >=400
|
||||
# so a failed write can never look like success), one bounded retry on 5xx/connection
|
||||
# errors, idempotent append (read-before-POST), correct `::` heading-target handling,
|
||||
# frontmatter field patches, and an advisory multi-writer lock.
|
||||
#
|
||||
# Config (env overrides; defaults match the rest of the plugin):
|
||||
# ECHO_BASE default https://echoapi.alwisp.com
|
||||
# ECHO_KEY default the plugin bearer token
|
||||
# ECHO_VERIFY default 1 — read-back verify after a PUT
|
||||
# ECHO_LOCK_TTL default 900 — seconds before an advisory lock is considered stale
|
||||
#
|
||||
# Usage:
|
||||
# echo.sh get <path> # print file contents (404 -> exit 44)
|
||||
# echo.sh map <path> # document-map JSON (headings/blocks/frontmatter)
|
||||
# echo.sh ls <dir> # directory listing JSON
|
||||
# echo.sh search <query...> # /search/simple
|
||||
# echo.sh put <path> [file] # create/overwrite (body from file or stdin)
|
||||
# echo.sh post <path> [file] # raw append (NON-idempotent; prefer `append`)
|
||||
# echo.sh append <path> <line> # idempotent append: skips if the exact line exists
|
||||
# echo.sh patch <path> <append|prepend|replace> <heading|frontmatter|block> <target> [file]
|
||||
# echo.sh fm <path> <field> <json-value> # PATCH a frontmatter scalar (e.g. fm p.md updated '"2026-06-19"')
|
||||
# echo.sh bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date)
|
||||
# echo.sh delete <path> # DELETE (destructive; explicit use only)
|
||||
# echo.sh lock <owner-id> # acquire advisory lock (exit 75 if held by someone else & fresh)
|
||||
# echo.sh unlock <owner-id> # release advisory lock if owned by <owner-id>
|
||||
# echo.sh scope show # print active scope, its freshness, and sessions-since
|
||||
# echo.sh scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
|
||||
#
|
||||
# Exit codes: 0 ok · 44 not-found(404) · 75 lock-held · 2 usage · 1 other HTTP/transport error.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ECHO_BASE="${ECHO_BASE:-https://echoapi.alwisp.com}"
|
||||
ECHO_BASE="${ECHO_BASE%/}"
|
||||
ECHO_KEY="${ECHO_KEY:-241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab}"
|
||||
ECHO_VERIFY="${ECHO_VERIFY:-1}"
|
||||
ECHO_LOCK_TTL="${ECHO_LOCK_TTL:-900}"
|
||||
AUTH="Authorization: Bearer ${ECHO_KEY}"
|
||||
|
||||
# response-body scratch file (filled by every _curl; read by callers via $RESP)
|
||||
RESP="$(mktemp)"; BODY=""
|
||||
cleanup() { rm -f "$RESP" "${BODY:-}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
die() { echo "echo.sh: $*" >&2; exit 1; }
|
||||
usage() { sed -n '2,40p' "$0" >&2; exit 2; }
|
||||
|
||||
_today() { echo "${ECHO_TODAY:-$(date +%Y-%m-%d)}"; }
|
||||
_now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
_vault_url() { echo "${ECHO_BASE}/vault/$1"; }
|
||||
|
||||
# _curl METHOD URL [extra curl args...]
|
||||
# Writes the response body to $RESP and the status code to $HTTP — BOTH IN THE PARENT
|
||||
# SHELL (never call this in $(...) or on the right of a pipe, or those globals are lost).
|
||||
# One bounded retry on transport failure (000) or 5xx.
|
||||
HTTP=""
|
||||
_curl() {
|
||||
local method="$1" url="$2"; shift 2
|
||||
local code attempt=0
|
||||
while :; do
|
||||
code="$(curl -sS -X "$method" -H "$AUTH" -o "$RESP" -w '%{http_code}' "$@" "$url" 2>/dev/null || echo 000)"
|
||||
if { [ "$code" = "000" ] || [ "${code:0:1}" = "5" ]; } && [ "$attempt" -lt 1 ]; then
|
||||
attempt=$((attempt+1)); sleep 1; continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
HTTP="$code"
|
||||
}
|
||||
|
||||
# assert $HTTP is acceptable; 404 -> exit 44, other >=400 -> exit 1
|
||||
_check() {
|
||||
local ctx="$1"
|
||||
[ "$HTTP" = "404" ] && exit 44
|
||||
[ "$HTTP" = "000" ] && die "$ctx: vault unreachable (connection failed) [$ECHO_BASE]"
|
||||
[ "${HTTP:-000}" -ge 400 ] && die "$ctx: HTTP $HTTP — $(cat "$RESP")"
|
||||
return 0
|
||||
}
|
||||
|
||||
# capture a body argument (file path or '-'/empty for stdin) into $BODY (a temp file)
|
||||
_capture_body() {
|
||||
BODY="$(mktemp)"
|
||||
if [ "${1:-}" = "" ] || [ "${1:-}" = "-" ]; then cat > "$BODY"; else cat "$1" > "$BODY"; fi
|
||||
}
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
case "$cmd" in
|
||||
get)
|
||||
[ $# -ge 1 ] || usage
|
||||
_curl GET "$(_vault_url "$1")"; _check "get $1"; cat "$RESP" ;;
|
||||
|
||||
map)
|
||||
[ $# -ge 1 ] || usage
|
||||
_curl GET "$(_vault_url "$1")" -H 'Accept: application/vnd.olrapi.document-map+json'
|
||||
_check "map $1"; cat "$RESP" ;;
|
||||
|
||||
ls)
|
||||
[ $# -ge 1 ] || usage
|
||||
p="$1"; [ "${p%/}" = "$p" ] && p="$p/"
|
||||
_curl GET "$(_vault_url "$p")"; _check "ls $1"; cat "$RESP" ;;
|
||||
|
||||
search)
|
||||
[ $# -ge 1 ] || usage
|
||||
q="$*"; q="${q// /+}"
|
||||
_curl POST "${ECHO_BASE}/search/simple/?query=${q}"; _check "search"; cat "$RESP" ;;
|
||||
|
||||
put)
|
||||
[ $# -ge 1 ] || usage
|
||||
path="$1"; _capture_body "${2:-}"
|
||||
_curl PUT "$(_vault_url "$path")" -H 'Content-Type: text/markdown' --data-binary @"$BODY"
|
||||
_check "put $path"
|
||||
if [ "$ECHO_VERIFY" = "1" ]; then
|
||||
_curl GET "$(_vault_url "$path")"
|
||||
[ "$HTTP" = "200" ] || die "put $path: write did not verify (GET returned $HTTP)"
|
||||
fi
|
||||
echo "ok: PUT $path" ;;
|
||||
|
||||
post)
|
||||
[ $# -ge 1 ] || usage
|
||||
path="$1"; _capture_body "${2:-}"
|
||||
_curl POST "$(_vault_url "$path")" -H 'Content-Type: text/markdown' --data-binary @"$BODY"
|
||||
_check "post $path"; echo "ok: POST $path" ;;
|
||||
|
||||
append)
|
||||
# idempotent: GET the file, skip the POST if the exact line is already present.
|
||||
[ $# -ge 2 ] || usage
|
||||
path="$1"; line="$2"
|
||||
_curl GET "$(_vault_url "$path")"
|
||||
if [ "$HTTP" = "200" ] && grep -qF -- "$line" "$RESP"; then
|
||||
echo "skip: line already present in $path"; exit 0
|
||||
fi
|
||||
[ "$HTTP" = "200" ] || [ "$HTTP" = "404" ] || _check "append(read) $path"
|
||||
BODY="$(mktemp)"; printf '%s\n' "$line" > "$BODY"
|
||||
_curl POST "$(_vault_url "$path")" -H 'Content-Type: text/markdown' --data-binary @"$BODY"
|
||||
_check "append $path"; echo "ok: APPEND $path" ;;
|
||||
|
||||
patch)
|
||||
[ $# -ge 4 ] || usage
|
||||
path="$1"; op="$2"; ttype="$3"; target="$4"; _capture_body "${5:-}"
|
||||
case "$op" in append|prepend|replace) ;; *) die "patch: op must be append|prepend|replace";; esac
|
||||
case "$ttype" in heading|frontmatter|block) ;; *) die "patch: target-type must be heading|frontmatter|block";; esac
|
||||
_curl PATCH "$(_vault_url "$path")" \
|
||||
-H "Operation: $op" -H "Target-Type: $ttype" -H "Target: $target" \
|
||||
-H 'Content-Type: text/markdown' --data-binary @"$BODY"
|
||||
_check "patch $path ($target)"
|
||||
echo "ok: PATCH $op $ttype '$target' -> $path" ;;
|
||||
|
||||
fm)
|
||||
[ $# -ge 3 ] || usage
|
||||
path="$1"; field="$2"; value="$3"
|
||||
BODY="$(mktemp)"; printf '%s' "$value" > "$BODY"
|
||||
_curl PATCH "$(_vault_url "$path")" \
|
||||
-H 'Operation: replace' -H 'Target-Type: frontmatter' -H "Target: $field" \
|
||||
-H 'Content-Type: application/json' --data-binary @"$BODY"
|
||||
_check "fm $path.$field"; echo "ok: frontmatter $field -> $path" ;;
|
||||
|
||||
bump)
|
||||
[ $# -ge 1 ] || usage
|
||||
path="$1"; d="${2:-$(_today)}"
|
||||
exec "$0" fm "$path" updated "\"$d\"" ;;
|
||||
|
||||
delete)
|
||||
[ $# -ge 1 ] || usage
|
||||
_curl DELETE "$(_vault_url "$1")"; _check "delete $1"; echo "ok: DELETE $1" ;;
|
||||
|
||||
lock)
|
||||
# advisory lock: _agent/locks/vault.lock holds "<owner> @ <iso>". Honored cooperatively.
|
||||
[ $# -ge 1 ] || usage
|
||||
owner="$1"; lockpath="_agent/locks/vault.lock"
|
||||
_curl GET "$(_vault_url "$lockpath")"
|
||||
if [ "$HTTP" = "200" ] && [ -s "$RESP" ]; then
|
||||
cur="$(cat "$RESP")"; held_owner="${cur%% @ *}"; held_iso="${cur##* @ }"; held_iso="${held_iso%$'\n'}"
|
||||
held_epoch="$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$held_iso" +%s 2>/dev/null \
|
||||
|| date -u -d "$held_iso" +%s 2>/dev/null || echo 0)"
|
||||
now_epoch="$(date -u +%s)"
|
||||
if [ "$held_owner" != "$owner" ] && [ $((now_epoch - held_epoch)) -lt "$ECHO_LOCK_TTL" ]; then
|
||||
echo "lock held by '$held_owner' since $held_iso (fresh)" >&2; exit 75
|
||||
fi
|
||||
fi
|
||||
BODY="$(mktemp)"; printf '%s @ %s\n' "$owner" "$(_now_iso)" > "$BODY"
|
||||
_curl PUT "$(_vault_url "$lockpath")" -H 'Content-Type: text/markdown' --data-binary @"$BODY"
|
||||
_check "lock"; echo "ok: locked by $owner" ;;
|
||||
|
||||
unlock)
|
||||
[ $# -ge 1 ] || usage
|
||||
owner="$1"; lockpath="_agent/locks/vault.lock"
|
||||
_curl GET "$(_vault_url "$lockpath")"
|
||||
if [ "$HTTP" = "200" ]; then
|
||||
cur="$(cat "$RESP")"; held_owner="${cur%% @ *}"
|
||||
[ "$held_owner" = "$owner" ] || { echo "lock owned by '$held_owner', not '$owner' — not releasing" >&2; exit 75; }
|
||||
fi
|
||||
_curl DELETE "$(_vault_url "$lockpath")"
|
||||
[ "$HTTP" = "404" ] || _check "unlock"
|
||||
echo "ok: unlocked" ;;
|
||||
|
||||
scope)
|
||||
# scope show | scope set "<new scope text>"
|
||||
# 'set' archives the prior scope to ## Scope History, replaces ## Scope, and stamps
|
||||
# the scope_updated freshness timestamp — one command instead of three error-prone PATCHes.
|
||||
sub="${1:-show}"; shift || true
|
||||
ccpath="_agent/context/current-context.md"
|
||||
case "$sub" in
|
||||
show)
|
||||
_curl GET "$(_vault_url "$ccpath")"; _check "scope show"
|
||||
cur="$RESP"
|
||||
echo "── Active scope ──"
|
||||
awk '/^## Scope[[:space:]]*$/{f=1;next} /^## /{if(f)exit} f' "$cur"
|
||||
su="$(sed -n 's/^scope_updated:[[:space:]]*//p' "$cur" | head -1)"
|
||||
su="${su//\"/}"
|
||||
echo "scope_updated: ${su:-<missing — drift cannot be detected; run scope set or repair>}"
|
||||
_curl GET "$(_vault_url "_agent/sessions/")"
|
||||
if [ "$HTTP" = "200" ] && [ -n "$su" ]; then
|
||||
n="$(python3 -c "import json,sys;f=json.load(open('$RESP'))['files'];print(sum(1 for x in f if x.endswith('.md') and x[:10]>'$su'))" 2>/dev/null || echo '?')"
|
||||
echo "sessions logged since: ${n}"
|
||||
fi ;;
|
||||
set)
|
||||
[ $# -ge 1 ] || die "scope set needs the new scope text"
|
||||
new="$1"
|
||||
_curl GET "$(_vault_url "$ccpath")"; _check "scope set(read)"
|
||||
prior="$(awk '/^## Scope[[:space:]]*$/{f=1;next} /^## /{if(f)exit} f' "$RESP" \
|
||||
| tr '\n' ' ' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | cut -c1-140)"
|
||||
BODY="$(mktemp)"; printf -- '- %s: %s\n' "$(_today)" "${prior:-(prior scope)}" > "$BODY"
|
||||
_curl PATCH "$(_vault_url "$ccpath")" -H 'Operation: prepend' -H 'Target-Type: heading' \
|
||||
-H 'Target: Current Context::Scope History' -H 'Content-Type: text/markdown' --data-binary @"$BODY"
|
||||
_check "scope set(history)"
|
||||
BODY="$(mktemp)"; printf '%s\n' "$new" > "$BODY"
|
||||
_curl PATCH "$(_vault_url "$ccpath")" -H 'Operation: replace' -H 'Target-Type: heading' \
|
||||
-H 'Target: Current Context::Scope' -H 'Content-Type: text/markdown' --data-binary @"$BODY"
|
||||
_check "scope set(replace)"
|
||||
BODY="$(mktemp)"; printf '"%s"' "$(_today)" > "$BODY"
|
||||
_curl PATCH "$(_vault_url "$ccpath")" -H 'Operation: replace' -H 'Target-Type: frontmatter' \
|
||||
-H 'Target: scope_updated' -H 'Content-Type: application/json' --data-binary @"$BODY"
|
||||
if [ "${HTTP:-000}" -ge 400 ]; then
|
||||
die "scope set: body switched, but scope_updated frontmatter is missing (run bootstrap.sh repair to add it) [HTTP $HTTP]"
|
||||
fi
|
||||
echo "ok: scope switched (prior archived to Scope History; scope_updated=$(_today))" ;;
|
||||
*) die "scope: use 'show' or 'set \"<text>\"'" ;;
|
||||
esac ;;
|
||||
|
||||
""|-h|--help|help) usage ;;
|
||||
*) die "unknown command '$cmd' (try: get map ls search put post append patch fm bump delete lock unlock scope)" ;;
|
||||
esac
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""echo_index.py — the ECHO entity index.
|
||||
|
||||
A small machine-readable registry at `_agent/index/entities.json` that turns
|
||||
routing/resolve into an O(1) lookup (no fuzzy search per write) and supplies the
|
||||
name->path map used for cross-linking and recall.
|
||||
|
||||
{
|
||||
"schema": 1,
|
||||
"updated": "YYYY-MM-DD",
|
||||
"entities": {
|
||||
"<slug>": {"path": "...", "kind": "...", "title": "...",
|
||||
"aliases": [...], "last_seen": "YYYY-MM-DD"}
|
||||
}
|
||||
}
|
||||
|
||||
Imported by echo_ops.py, sweep.py, and vault_lint.py. Network goes through echo.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import echo # noqa: E402
|
||||
|
||||
INDEX_PATH = "_agent/index/entities.json"
|
||||
|
||||
# kind -> destination folder (the canonical home for that entity kind)
|
||||
KIND_FOLDER = {
|
||||
"person": "resources/people",
|
||||
"company": "resources/companies",
|
||||
"concept": "resources/concepts",
|
||||
"reference": "resources/references",
|
||||
"meeting": "resources/meetings",
|
||||
"project": "projects/active", # new projects default to active
|
||||
"area": "areas", # needs a domain segment
|
||||
"semantic": "_agent/memory/semantic",
|
||||
"episodic": "_agent/memory/episodic",
|
||||
"working": "_agent/memory/working",
|
||||
"skill": "_agent/skills/active",
|
||||
"decision": "decisions/by-date",
|
||||
}
|
||||
# kind -> frontmatter `type:` value
|
||||
KIND_TYPE = {
|
||||
"person": "person", "company": "company", "concept": "concept",
|
||||
"reference": "reference", "meeting": "meeting", "project": "project",
|
||||
"area": "area", "semantic": "semantic-memory", "episodic": "episodic-memory",
|
||||
"working": "working-memory", "skill": "skill", "decision": "decision",
|
||||
}
|
||||
DATE_KINDS = {"meeting", "decision"} # filename carries a YYYY-MM-DD prefix
|
||||
AREA_DOMAINS = ("business", "personal", "learning", "systems")
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
text = str(text).strip().lower()
|
||||
text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
||||
return text[:40] or "untitled"
|
||||
|
||||
|
||||
def derive_path(kind: str, slug: str, date: str | None = None, domain: str = "business") -> str:
|
||||
folder = KIND_FOLDER.get(kind)
|
||||
if folder is None:
|
||||
raise echo.EchoError(f"unknown kind '{kind}' (one of: {', '.join(sorted(KIND_FOLDER))})", 2)
|
||||
if kind == "area":
|
||||
if domain not in AREA_DOMAINS:
|
||||
raise echo.EchoError(f"area domain must be one of {AREA_DOMAINS}", 2)
|
||||
return f"areas/{domain}/{slug}.md"
|
||||
if kind in DATE_KINDS:
|
||||
return f"{folder}/{date or echo.today()}-{slug}.md"
|
||||
return f"{folder}/{slug}.md"
|
||||
|
||||
|
||||
_KIND_RULES = [
|
||||
(r"^resources/people/", "person"),
|
||||
(r"^resources/companies/", "company"),
|
||||
(r"^resources/concepts/", "concept"),
|
||||
(r"^resources/references/", "reference"),
|
||||
(r"^resources/meetings/", "meeting"),
|
||||
(r"^projects/(active|incubating|on-hold|archived)/", "project"),
|
||||
(r"^areas/[^/]+/", "area"),
|
||||
(r"^decisions/by-date/", "decision"),
|
||||
(r"^_agent/memory/semantic/", "semantic"),
|
||||
(r"^_agent/memory/episodic/", "episodic"),
|
||||
(r"^_agent/skills/(active|archived)/", "skill"),
|
||||
]
|
||||
|
||||
|
||||
def kind_for_path(path: str) -> str | None:
|
||||
"""The entity kind a vault path belongs to (the inverse of derive_path), or None
|
||||
if the path is not an indexable entity note (journal, sessions, inbox, ...)."""
|
||||
for rx, kind in _KIND_RULES:
|
||||
if re.match(rx, path):
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
def empty_index() -> dict:
|
||||
return {"schema": 1, "updated": echo.today(), "entities": {}}
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
status, body = echo.request("GET", echo.vault_url(INDEX_PATH))
|
||||
if status == 404:
|
||||
return empty_index()
|
||||
echo.check(status, body, "index load")
|
||||
try:
|
||||
d = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return empty_index()
|
||||
d.setdefault("entities", {})
|
||||
return d
|
||||
|
||||
|
||||
def save(index: dict) -> None:
|
||||
index["updated"] = echo.today()
|
||||
body = json.dumps(index, indent=2, ensure_ascii=False).encode("utf-8")
|
||||
status, b = echo.request("PUT", echo.vault_url(INDEX_PATH), data=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
echo.check(status, b, "index save")
|
||||
|
||||
|
||||
def _norm(s) -> str:
|
||||
return slugify(s)
|
||||
|
||||
|
||||
def resolve(index: dict, mention: str):
|
||||
"""Return (slug, entry) for a mention matched by slug, title, or alias — else (None, None)."""
|
||||
key = _norm(mention)
|
||||
ents = index.get("entities", {})
|
||||
if key in ents:
|
||||
return key, ents[key]
|
||||
plain = str(mention).strip().lower()
|
||||
for slug, e in ents.items():
|
||||
cands = {slug, _norm(e.get("title", "")), str(e.get("title", "")).strip().lower()}
|
||||
cands |= {_norm(a) for a in e.get("aliases", [])}
|
||||
cands |= {str(a).strip().lower() for a in e.get("aliases", [])}
|
||||
if key in cands or plain in cands:
|
||||
return slug, e
|
||||
return None, None
|
||||
|
||||
|
||||
def upsert(index: dict, slug: str, path: str, kind: str, title: str | None = None,
|
||||
aliases=None) -> dict:
|
||||
ents = index.setdefault("entities", {})
|
||||
e = ents.get(slug, {})
|
||||
e["path"] = path
|
||||
e["kind"] = kind
|
||||
e["title"] = title or e.get("title") or slug
|
||||
merged = set(e.get("aliases", [])) | set(aliases or [])
|
||||
e["aliases"] = sorted(a for a in merged if a)
|
||||
e["last_seen"] = echo.today()
|
||||
ents[slug] = e
|
||||
return e
|
||||
|
||||
|
||||
def name_map(index: dict) -> dict:
|
||||
"""Map every resolvable name -> path: slug, normalized title, basename, aliases.
|
||||
Used to resolve `[[wikilinks]]` and entity mentions back to vault paths."""
|
||||
m: dict[str, str] = {}
|
||||
for slug, e in index.get("entities", {}).items():
|
||||
path = e.get("path")
|
||||
if not path:
|
||||
continue
|
||||
base = path.rsplit("/", 1)[-1]
|
||||
base = base[:-3] if base.endswith(".md") else base
|
||||
keys = {slug, _norm(e.get("title", "")), _norm(base)}
|
||||
keys |= {_norm(a) for a in e.get("aliases", [])}
|
||||
for k in keys:
|
||||
if k:
|
||||
m.setdefault(k, path)
|
||||
return m
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""echo_links.py — cross-link primitives.
|
||||
|
||||
Parse and add `## Related` wikilinks, and create **bidirectional** links between
|
||||
notes (read-modify-write, idempotent). Links use path-style targets without the
|
||||
`.md` extension (e.g. `[[resources/people/bob-smith]]`), which are unambiguous
|
||||
and resolve in Obsidian. Network goes through echo.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import echo # noqa: E402
|
||||
|
||||
WIKILINK = re.compile(r"\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]")
|
||||
|
||||
|
||||
def first_h1(text: str) -> str:
|
||||
for line in text.splitlines():
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def all_wikilinks(text: str) -> list[str]:
|
||||
return [m.group(1).strip() for m in WIKILINK.finditer(text)]
|
||||
|
||||
|
||||
def related_targets(text: str) -> list[str]:
|
||||
"""Wikilink targets under the `## Related` heading only."""
|
||||
out, cap = [], False
|
||||
for line in text.splitlines():
|
||||
if line.strip().lower() == "## related":
|
||||
cap = True
|
||||
continue
|
||||
if cap and line.startswith("## "):
|
||||
break
|
||||
if cap:
|
||||
out += [m.group(1).strip() for m in WIKILINK.finditer(line)]
|
||||
return out
|
||||
|
||||
|
||||
def source_notes(text: str) -> list[str]:
|
||||
"""Plain relative paths listed in the frontmatter `source_notes:` field."""
|
||||
if not text.startswith("---"):
|
||||
return []
|
||||
end = text.find("\n---", 3)
|
||||
fm = text[:end] if end != -1 else text
|
||||
m = re.search(r"(?m)^source_notes:\s*\[(.*?)\]", fm)
|
||||
if not m:
|
||||
return []
|
||||
return [p.strip().strip('"').strip("'") for p in m.group(1).split(",") if p.strip()]
|
||||
|
||||
|
||||
def link_token(path: str) -> str:
|
||||
"""Path-style wikilink target without the .md extension."""
|
||||
return path[:-3] if path.endswith(".md") else path
|
||||
|
||||
|
||||
def add_related(text: str, target_path: str):
|
||||
"""Return (new_text, changed): ensure `- [[<target>]]` exists under ## Related."""
|
||||
token = link_token(target_path)
|
||||
if token in related_targets(text):
|
||||
return text, False
|
||||
bullet = f"- [[{token}]]"
|
||||
trailing_nl = text.endswith("\n")
|
||||
lines = text.splitlines()
|
||||
idx = next((i for i, l in enumerate(lines) if l.strip().lower() == "## related"), None)
|
||||
if idx is None:
|
||||
sep = "" if trailing_nl else "\n"
|
||||
return text + f"{sep}\n## Related\n{bullet}\n", True
|
||||
# insert after the section's existing content (before the next heading / EOF)
|
||||
j = idx + 1
|
||||
while j < len(lines) and not lines[j].startswith("## "):
|
||||
j += 1
|
||||
k = j
|
||||
while k > idx + 1 and not lines[k - 1].strip():
|
||||
k -= 1
|
||||
lines.insert(k, bullet)
|
||||
return "\n".join(lines) + ("\n" if trailing_nl else ""), True
|
||||
|
||||
|
||||
def get_text(path: str) -> str | None:
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 404:
|
||||
return None
|
||||
echo.check(status, body, f"get {path}")
|
||||
return body.decode(errors="replace")
|
||||
|
||||
|
||||
def put_text(path: str, text: str) -> None:
|
||||
status, body = echo.request("PUT", echo.vault_url(path), data=text.encode("utf-8"),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
echo.check(status, body, f"put {path}")
|
||||
|
||||
|
||||
def add_one(path: str, target_path: str) -> bool:
|
||||
"""Add [[target]] under path's ## Related. Returns True if the file changed."""
|
||||
text = get_text(path)
|
||||
if text is None:
|
||||
return False
|
||||
new, changed = add_related(text, target_path)
|
||||
if changed:
|
||||
put_text(path, new)
|
||||
return changed
|
||||
|
||||
|
||||
def link_bidirectional(a_path: str, b_path: str):
|
||||
"""Add A<->B reciprocal links. Returns (a_changed, b_changed)."""
|
||||
if a_path == b_path:
|
||||
return (False, False)
|
||||
return (add_one(a_path, b_path), add_one(b_path, a_path))
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""echo_ops.py — high-level ECHO operations layered on the client + index + links.
|
||||
|
||||
resolve — map a mention to its canonical note path (or where to create it)
|
||||
recall — search, then expand one hop along links/source_notes for connected context
|
||||
link — create bidirectional `## Related` links between two notes
|
||||
capture — one-call write: route + canonical frontmatter + index + auto-link + agent-log
|
||||
|
||||
Network goes through echo.py; routing/aliases through echo_index; links through echo_links.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import echo # noqa: E402
|
||||
import echo_index as idx_mod # noqa: E402
|
||||
import echo_links as links # noqa: E402
|
||||
|
||||
# Existing-entity capture appends a dated bullet under this per-kind heading.
|
||||
LOG_HEADING = {
|
||||
"project": "Session History", "person": "Log", "company": "Log",
|
||||
"semantic": "Observations", "area": "Log", "concept": "Notes",
|
||||
"reference": "Notes", "meeting": "Notes", "decision": "Notes",
|
||||
"skill": "Notes", "episodic": "Notes", "working": "Notes",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- resolve -----
|
||||
def resolve(mention: str) -> int:
|
||||
index = idx_mod.load()
|
||||
slug, e = idx_mod.resolve(index, mention)
|
||||
if e:
|
||||
print(json.dumps({"match": True, "slug": slug, **e}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(json.dumps({"match": False, "mention": mention,
|
||||
"suggest_slug": idx_mod.slugify(mention),
|
||||
"note": "no index entry — derive a path with the right --kind and create it"},
|
||||
ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- link -----
|
||||
def link(a_path: str, b_path: str) -> int:
|
||||
a_changed, b_changed = links.link_bidirectional(a_path, b_path)
|
||||
print(f"ok: linked {a_path} <-> {b_path} "
|
||||
f"(added: {'A' if a_changed else '-'}{'B' if b_changed else '-'})")
|
||||
return 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- recall -----
|
||||
def _resolve_link_to_path(target: str, nmap: dict) -> str | None:
|
||||
target = target.strip()
|
||||
if "/" in target:
|
||||
return target if target.endswith(".md") else target + ".md"
|
||||
return nmap.get(idx_mod.slugify(target))
|
||||
|
||||
|
||||
def _brief(path: str) -> None:
|
||||
text = links.get_text(path)
|
||||
if text is None:
|
||||
return
|
||||
print(f"\n### {path}")
|
||||
fm_type = re.search(r"(?m)^type:\s*(.+)$", text)
|
||||
if fm_type:
|
||||
print(f"_type: {fm_type.group(1).strip()}_")
|
||||
# Prefer a Status paragraph; else the first handful of non-empty body lines.
|
||||
body = text.split("\n---", 2)[-1] if text.startswith("---") else text
|
||||
status = re.search(r"(?ms)^##\s+Status\s*$(.*?)(?=^##\s|\Z)", body)
|
||||
if status and status.group(1).strip():
|
||||
print(status.group(1).strip()[:400])
|
||||
return
|
||||
shown = 0
|
||||
for line in body.splitlines():
|
||||
if not line.strip() or line.startswith("# "):
|
||||
continue
|
||||
print(line[:200])
|
||||
shown += 1
|
||||
if shown >= 8:
|
||||
break
|
||||
|
||||
|
||||
def recall(query, limit: int = 6) -> int:
|
||||
q = " ".join(query) if isinstance(query, list) else query
|
||||
index = idx_mod.load()
|
||||
nmap = idx_mod.name_map(index)
|
||||
|
||||
status, body = echo.request("POST", f"{echo.BASE}/search/simple/?query={urllib.parse.quote_plus(q)}")
|
||||
echo.check(status, body, "recall search")
|
||||
try:
|
||||
results = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
results = []
|
||||
hits = []
|
||||
for r in results[:limit]:
|
||||
fn = r.get("filename") or r.get("path")
|
||||
if fn and fn not in hits:
|
||||
hits.append(fn)
|
||||
rslug, e = idx_mod.resolve(index, q)
|
||||
if e and e.get("path") and e["path"] not in hits:
|
||||
hits.insert(0, e["path"])
|
||||
|
||||
seen = set(hits)
|
||||
neighbors = [] # (via, path)
|
||||
for p in hits:
|
||||
text = links.get_text(p)
|
||||
if text is None:
|
||||
continue
|
||||
targets = set(links.all_wikilinks(text)) | set(links.source_notes(text))
|
||||
for t in targets:
|
||||
tp = _resolve_link_to_path(t, nmap)
|
||||
if tp and tp not in seen:
|
||||
seen.add(tp)
|
||||
neighbors.append((p, tp))
|
||||
|
||||
print(f"== recall: {q} ==")
|
||||
print(f"\n# Primary hits ({len(hits)})")
|
||||
for p in hits:
|
||||
_brief(p)
|
||||
print(f"\n# Linked context ({len(neighbors)})")
|
||||
for via, p in neighbors:
|
||||
print(f"\n(via {via})")
|
||||
_brief(p)
|
||||
return 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------- agent log -------
|
||||
def ensure_daily_log(line: str) -> None:
|
||||
"""Resilient: ensure today's daily note + its `## Agent Log` heading exist, then
|
||||
idempotently append the line. Best-effort — never raises into the caller."""
|
||||
try:
|
||||
date = echo.today()
|
||||
path = f"journal/daily/{date}.md"
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 404:
|
||||
ts, tb = echo.request("GET", echo.vault_url("journal/templates/daily-note-template.md"))
|
||||
tmpl = tb.decode(errors="replace") if ts == 200 else f"# {date}\n\n## Agent Log\n\n## Related\n"
|
||||
tmpl = tmpl.replace("{{date:YYYY-MM-DD}}", date).replace("{{DATE}}", date)
|
||||
echo.request("PUT", echo.vault_url(path), data=tmpl.encode(),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
text = tmpl
|
||||
else:
|
||||
text = body.decode(errors="replace")
|
||||
if not re.search(r"(?m)^## Agent Log\s*$", text):
|
||||
echo.request("POST", echo.vault_url(path), data=b"\n\n## Agent Log\n",
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 200 and line in body.decode(errors="replace"):
|
||||
return
|
||||
echo.request("PATCH", echo.vault_url(path),
|
||||
data=echo.normalize_patch_body((line + "\n").encode(), "append", "heading"),
|
||||
headers={"Operation": "append", "Target-Type": "heading",
|
||||
"Target": f"{date}::Agent Log", "Content-Type": "text/markdown"})
|
||||
except Exception as exc:
|
||||
print(f"echo_ops: agent-log skipped ({exc})", file=sys.stderr)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- capture ---
|
||||
def _build_note(fm_type: str, status_v: str, today_s: str, title: str,
|
||||
body_text: str, sources: list[str]) -> str:
|
||||
src = "[" + ", ".join(json.dumps(s) for s in sources) + "]"
|
||||
fm = ["---", f"type: {fm_type}"]
|
||||
if status_v:
|
||||
fm.append(f"status: {status_v}")
|
||||
fm += [f"created: {today_s}", f"updated: {today_s}", "tags: []",
|
||||
"agent_written: true", f"source_notes: {src}", "---", "", f"# {title}", ""]
|
||||
out = "\n".join(fm)
|
||||
if body_text.strip():
|
||||
out += body_text.strip() + "\n"
|
||||
out += "\n## Related\n"
|
||||
return out
|
||||
|
||||
|
||||
def _append_to_existing(path: str, kind: str, today_s: str, body_text: str) -> None:
|
||||
text = links.get_text(path) or ""
|
||||
h1 = links.first_h1(text) or path.rsplit("/", 1)[-1][:-3]
|
||||
heading = LOG_HEADING.get(kind, "Notes")
|
||||
if not re.search(rf"(?m)^##\s+{re.escape(heading)}\s*$", text):
|
||||
echo.request("POST", echo.vault_url(path), data=f"\n\n## {heading}\n".encode(),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
firstline = body_text.strip().splitlines()[0] if body_text.strip() else "(update)"
|
||||
bullet = f"- {today_s}: {firstline}"
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 200 and bullet in body.decode(errors="replace"):
|
||||
return
|
||||
echo.request("PATCH", echo.vault_url(path),
|
||||
data=echo.normalize_patch_body((bullet + "\n").encode(), "append", "heading"),
|
||||
headers={"Operation": "append", "Target-Type": "heading",
|
||||
"Target": f"{h1}::{heading}", "Content-Type": "text/markdown"})
|
||||
echo.cmd_fm(path, "updated", json.dumps(today_s))
|
||||
|
||||
|
||||
def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "",
|
||||
aliases=None, sources=None, date: str | None = None, domain: str = "business",
|
||||
inbox: bool = False, no_log: bool = False) -> int:
|
||||
body_text = echo.read_body(file_arg).decode("utf-8", errors="replace")
|
||||
today_s = echo.today()
|
||||
aliases = [a.strip() for a in (aliases or []) if a.strip()]
|
||||
sources = [s.strip() for s in (sources or []) if s.strip()]
|
||||
|
||||
# Unknown home -> defer to the inbox (a single idempotent capture line).
|
||||
if inbox or not kind:
|
||||
line = f"- {today_s}: {title}"
|
||||
if body_text.strip():
|
||||
line += f" — {body_text.strip().splitlines()[0]}"
|
||||
return echo.cmd_append("inbox/captures/inbox.md", line)
|
||||
|
||||
slug = idx_mod.slugify(title)
|
||||
index = idx_mod.load()
|
||||
_, existing = idx_mod.resolve(index, title)
|
||||
|
||||
if existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200:
|
||||
path = existing["path"]
|
||||
kind = existing.get("kind", kind)
|
||||
_append_to_existing(path, kind, today_s, body_text)
|
||||
action = "updated"
|
||||
else:
|
||||
if not status_v and kind == "project":
|
||||
status_v = "active"
|
||||
path = idx_mod.derive_path(kind, slug, date=date, domain=domain)
|
||||
note = _build_note(idx_mod.KIND_TYPE.get(kind, kind), status_v, today_s, title, body_text, sources)
|
||||
echo.cmd_put(path, echo.temp_file(note.encode()))
|
||||
action = "created"
|
||||
|
||||
idx_mod.upsert(index, slug, path, kind, title, aliases)
|
||||
idx_mod.save(index)
|
||||
|
||||
# auto-link: any other known entity whose name/alias appears in the body, matched
|
||||
# on word boundaries (and >=3 chars) so a short alias can't match inside a word.
|
||||
linked = 0
|
||||
for s2, e2 in index.get("entities", {}).items():
|
||||
if e2.get("path") in (None, path):
|
||||
continue
|
||||
names = [n for n in [e2.get("title", ""), s2, *e2.get("aliases", [])] if len(n) >= 3]
|
||||
if any(re.search(rf"\b{re.escape(n)}\b", body_text, re.IGNORECASE) for n in names):
|
||||
ca, cb = links.link_bidirectional(path, e2["path"])
|
||||
linked += int(ca or cb)
|
||||
|
||||
if not no_log:
|
||||
ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]")
|
||||
|
||||
print(f"ok: {action} {kind} -> {path}" + (f"; auto-linked {linked}" if linked else ""))
|
||||
return 0
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""migrate.py — bring an existing ECHO vault up to the plugin's current schema.
|
||||
|
||||
Reads the marker's schema_version and applies each intervening migration in order.
|
||||
Migrations are idempotent and additive; every destructive step (DELETE/move) is
|
||||
gated behind --apply AND printed first. Default mode is a DRY-RUN plan.
|
||||
Cross-platform: pure Python via echo.py.
|
||||
|
||||
Usage:
|
||||
migrate.py # print the migration plan (no changes)
|
||||
migrate.py --apply # perform the migration (moves/deletes included)
|
||||
|
||||
Env: ECHO_BASE, ECHO_KEY (via echo.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import echo # noqa: E402
|
||||
|
||||
CURRENT_SCHEMA = 3
|
||||
|
||||
|
||||
def get_text(path: str) -> str | None:
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 404:
|
||||
return None
|
||||
echo.check(status, body, f"get {path}")
|
||||
return body.decode(errors="replace")
|
||||
|
||||
|
||||
def list_files(path: str) -> list[str]:
|
||||
if not path.endswith("/"):
|
||||
path += "/"
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 404:
|
||||
return []
|
||||
echo.check(status, body, f"ls {path}")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [entry for entry in payload.get("files", []) if not entry.endswith("/")]
|
||||
|
||||
|
||||
def put_text(path: str, text: str) -> None:
|
||||
status, body = echo.request("PUT", echo.vault_url(path), data=text.encode(),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
echo.check(status, body, f"put {path}")
|
||||
|
||||
|
||||
def delete(path: str) -> None:
|
||||
status, body = echo.request("DELETE", echo.vault_url(path))
|
||||
if status != 404:
|
||||
echo.check(status, body, f"delete {path}")
|
||||
|
||||
|
||||
def move(src: str, dst: str) -> None:
|
||||
text = get_text(src)
|
||||
if text is None:
|
||||
return
|
||||
put_text(dst, text)
|
||||
delete(src)
|
||||
|
||||
|
||||
def do_or_show(apply: bool, desc: str, func=None) -> None:
|
||||
if apply:
|
||||
print(f"migrate: APPLY {desc}")
|
||||
if func:
|
||||
func()
|
||||
else:
|
||||
print(f"migrate: PLAN {desc}")
|
||||
|
||||
|
||||
def marker_schema() -> int:
|
||||
marker = get_text("_agent/echo-vault.md")
|
||||
if marker is None:
|
||||
print("migrate: marker missing — vault not bootstrapped. Run bootstrap.py, not migrate.py.")
|
||||
raise SystemExit(3)
|
||||
for line in marker.splitlines():
|
||||
if line.startswith("schema_version:"):
|
||||
try:
|
||||
return int(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate ECHO vault schema")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
start = marker_schema()
|
||||
print(f"migrate: vault schema_version={start}, plugin schema={CURRENT_SCHEMA} "
|
||||
f"{'(APPLY)' if args.apply else '(dry-run)'}")
|
||||
if start >= CURRENT_SCHEMA:
|
||||
print("migrate: up to date — nothing to do.")
|
||||
return 0
|
||||
|
||||
if start < 1:
|
||||
print("migrate: [0->1] retire in-vault control docs (CLAUDE/BOOTSTRAP/STRUCTURE/index.md)")
|
||||
for filename in ["CLAUDE.md", "BOOTSTRAP.md", "STRUCTURE.md", "index.md"]:
|
||||
if get_text(filename) is not None:
|
||||
do_or_show(args.apply, f"delete vault/{filename} (back it up outside the vault first)",
|
||||
lambda f=filename: delete(f))
|
||||
print("migrate: [0->1] reminder: scrub dangling control-doc [[links]] from Related sections.")
|
||||
|
||||
if start < 2:
|
||||
print("migrate: [1->2] fold reviews/ into journal/ and _agent/health/")
|
||||
for filename in list_files("reviews/weekly"):
|
||||
if filename.endswith(".md"):
|
||||
dst = f"journal/weekly/{filename.replace('-review.md', '.md')}"
|
||||
do_or_show(args.apply, f"move reviews/weekly/{filename} -> {dst}",
|
||||
lambda f=filename, d=dst: move(f"reviews/weekly/{f}", d))
|
||||
for filename in list_files("reviews/monthly"):
|
||||
if filename.endswith(".md"):
|
||||
dst = f"_agent/health/{filename}" if filename.endswith("vault-health.md") else f"journal/monthly/{filename}"
|
||||
do_or_show(args.apply, f"move reviews/monthly/{filename} -> {dst}",
|
||||
lambda f=filename, d=dst: move(f"reviews/monthly/{f}", d))
|
||||
for period in ["quarterly", "annual"]:
|
||||
for filename in list_files(f"reviews/{period}"):
|
||||
if filename.endswith(".md"):
|
||||
dst = f"journal/{period}/{filename}"
|
||||
do_or_show(args.apply, f"move reviews/{period}/{filename} -> {dst}",
|
||||
lambda p=period, f=filename, d=dst: move(f"reviews/{p}/{f}", d))
|
||||
print("migrate: [1->2] reminder: update inbound [[reviews/...]] wikilinks manually.")
|
||||
|
||||
if start < 3:
|
||||
print("migrate: [2->3] add the entity-index folder (cross-link / recall feature)")
|
||||
if get_text("_agent/index/README.md") is None:
|
||||
do_or_show(args.apply, "create _agent/index/README.md",
|
||||
lambda: put_text("_agent/index/README.md",
|
||||
"# index\n\nMachine-maintained entity index. See the echo-memory plugin.\n"))
|
||||
print("migrate: [2->3] then run `sweep.py --apply` to build entities.json and symmetrize links.")
|
||||
|
||||
do_or_show(args.apply, f"set _agent/echo-vault.md schema_version -> {CURRENT_SCHEMA}",
|
||||
lambda: echo.cmd_fm("_agent/echo-vault.md", "schema_version", str(CURRENT_SCHEMA)))
|
||||
if args.apply:
|
||||
print(f"migrate: migration complete -> schema {CURRENT_SCHEMA}. Run sweep.py --apply, then vault_lint.py.")
|
||||
else:
|
||||
print("migrate: dry-run only. Re-run with --apply to perform the moves/deletes above.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# migrate.sh — bring an existing ECHO vault up to the plugin's current schema.
|
||||
#
|
||||
# Reads the marker's schema_version and applies each intervening migration in order.
|
||||
# Migrations are idempotent and additive; every destructive step (DELETE) is gated
|
||||
# behind --apply AND prints what it will do first. Default mode is a DRY-RUN plan.
|
||||
#
|
||||
# Usage:
|
||||
# migrate.sh # print the migration plan (no changes)
|
||||
# migrate.sh --apply # perform the migration (moves/deletes included)
|
||||
#
|
||||
# Env: ECHO_BASE, ECHO_KEY (via echo.sh).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ECHO="$SCRIPT_DIR/echo.sh"
|
||||
CURRENT_SCHEMA=2
|
||||
APPLY=0
|
||||
[ "${1:-}" = "--apply" ] && APPLY=1
|
||||
|
||||
[ -x "$ECHO" ] || chmod +x "$ECHO" 2>/dev/null || true
|
||||
say() { echo "migrate: $*"; }
|
||||
do_or_show() { # do_or_show "<human description>" cmd args...
|
||||
local desc="$1"; shift
|
||||
if [ "$APPLY" = "1" ]; then say "APPLY $desc"; "$@"; else say "PLAN $desc"; fi
|
||||
}
|
||||
|
||||
# ---- Read current schema -----------------------------------------------------
|
||||
if ! marker="$("$ECHO" get _agent/echo-vault.md 2>/dev/null)"; then
|
||||
say "marker missing — vault not bootstrapped. Run bootstrap.sh, not migrate.sh."
|
||||
exit 3
|
||||
fi
|
||||
FROM="$(printf '%s' "$marker" | sed -n 's/^schema_version:[[:space:]]*//p' | head -1)"
|
||||
FROM="${FROM:-0}"
|
||||
say "vault schema_version=$FROM, plugin schema=$CURRENT_SCHEMA $([ "$APPLY" = 1 ] && echo '(APPLY)' || echo '(dry-run)')"
|
||||
|
||||
if [ "$FROM" -ge "$CURRENT_SCHEMA" ] 2>/dev/null; then
|
||||
say "up to date — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ls_files() { "$ECHO" ls "$1" 2>/dev/null | python3 -c 'import sys,json;print("\n".join(json.load(sys.stdin).get("files",[])))' 2>/dev/null || true; }
|
||||
move() { # move SRC DST preserving content (PUT dst <- get src, then delete src)
|
||||
local src="$1" dst="$2"
|
||||
"$ECHO" get "$src" 2>/dev/null | "$ECHO" put "$dst" - >/dev/null
|
||||
"$ECHO" delete "$src" >/dev/null
|
||||
}
|
||||
|
||||
# ---- 0 -> 1 : control docs moved into the plugin -----------------------------
|
||||
mig_0_1() {
|
||||
say "[0->1] retire in-vault control docs (CLAUDE/BOOTSTRAP/STRUCTURE/index.md)"
|
||||
for f in CLAUDE.md BOOTSTRAP.md STRUCTURE.md index.md; do
|
||||
if ECHO_VERIFY=0 "$ECHO" get "$f" >/dev/null 2>&1; then
|
||||
do_or_show "delete vault/$f (back it up outside the vault first)" "$ECHO" delete "$f"
|
||||
fi
|
||||
done
|
||||
say "[0->1] reminder: scrub dangling [[CLAUDE]]/[[BOOTSTRAP]]/[[STRUCTURE]]/[[index]] links from ## Related sections (manual/agent step)."
|
||||
}
|
||||
|
||||
# ---- 1 -> 2 : reviews/ folded into journal/ + _agent/health/ -----------------
|
||||
mig_1_2() {
|
||||
say "[1->2] fold reviews/ into journal/ and _agent/health/"
|
||||
for f in $(ls_files reviews/weekly); do
|
||||
[ "${f%.md}" != "$f" ] || continue
|
||||
dst="journal/weekly/$(printf '%s' "$f" | sed 's/-review\.md$/.md/')"
|
||||
do_or_show "move reviews/weekly/$f -> $dst" move "reviews/weekly/$f" "$dst"
|
||||
done
|
||||
for f in $(ls_files reviews/monthly); do
|
||||
[ "${f%.md}" != "$f" ] || continue
|
||||
case "$f" in
|
||||
*vault-health.md) dst="_agent/health/$f" ;;
|
||||
*) dst="journal/monthly/$f" ;;
|
||||
esac
|
||||
do_or_show "move reviews/monthly/$f -> $dst" move "reviews/monthly/$f" "$dst"
|
||||
done
|
||||
for period in quarterly annual; do
|
||||
for f in $(ls_files "reviews/$period"); do
|
||||
[ "${f%.md}" != "$f" ] || continue
|
||||
do_or_show "move reviews/$period/$f -> journal/$period/$f" move "reviews/$period/$f" "journal/$period/$f"
|
||||
done
|
||||
done
|
||||
say "[1->2] reminder: update inbound [[reviews/...]] wikilinks in ## Related sections (manual/agent step)."
|
||||
}
|
||||
|
||||
[ "$FROM" -lt 1 ] && mig_0_1
|
||||
[ "$FROM" -lt 2 ] && mig_1_2
|
||||
|
||||
# ---- Stamp the marker --------------------------------------------------------
|
||||
do_or_show "set _agent/echo-vault.md schema_version -> $CURRENT_SCHEMA" \
|
||||
"$ECHO" fm _agent/echo-vault.md schema_version "$CURRENT_SCHEMA"
|
||||
|
||||
if [ "$APPLY" = "1" ]; then
|
||||
say "migration complete -> schema $CURRENT_SCHEMA. Run vault-lint.sh to confirm invariants."
|
||||
else
|
||||
say "dry-run only. Re-run with --apply to perform the moves/deletes above."
|
||||
fi
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "CANONICAL machine-readable routing manifest for the ECHO vault. This is the single source of truth for 'what paths may be written to'. The human-readable tables in SKILL.md, references/routing-map.md, and references/api-reference.md are DERIVED views of this file — when they disagree, this file wins. vault-lint.sh consumes it to enforce the core rule: if a path matches no route here (and is not a retired path), nothing should be written to it. Patterns are Python regexes matched against vault-root-relative paths (no leading slash, no /vault/ prefix).",
|
||||
"$comment": "CANONICAL machine-readable routing manifest for the ECHO vault. This is the single source of truth for 'what paths may be written to'. The human-readable tables in SKILL.md, references/routing-map.md, and references/api-reference.md are DERIVED views of this file — when they disagree, this file wins. vault_lint.py consumes it to enforce the core rule (if a vault path matches no route here, and is not retired, nothing should be written to it); check_routing.py consumes it to verify the human routing docs stay in sync with this manifest. Patterns are Python regexes matched against vault-root-relative paths (no leading slash, no /vault/ prefix).",
|
||||
"schema_version": 2,
|
||||
"routes": [
|
||||
{ "id": "inbox-captures", "pattern": "^inbox/captures/inbox\\.md$", "method": "POST", "trigger": "Destination unknown at capture time", "distinct_because": "Only path whose contract is deferred routing" },
|
||||
@@ -42,6 +42,7 @@
|
||||
{ "id": "agent-skills-active", "pattern": "^_agent/skills/active/[^/]+\\.md$", "method": "PUT", "trigger": "A skill/plugin catalogued as a capability", "distinct_because": "Catalogs a capability vs the build effort" },
|
||||
{ "id": "agent-skills-archived","pattern": "^_agent/skills/archived/[^/]+\\.md$", "method": "PUT", "trigger": "A catalogued skill is retired", "distinct_because": "Terminal state of the skill catalog" },
|
||||
{ "id": "agent-locks", "pattern": "^_agent/locks/[^/]+\\.lock$", "method": "PUT", "trigger": "Advisory multi-writer lock acquire/release", "distinct_because": "Concurrency coordination, not memory" },
|
||||
{ "id": "agent-index", "pattern": "^_agent/index/[^/]+\\.json$", "method": "PUT", "trigger": "Entity index rebuilt on write/sweep", "distinct_because": "Machine-maintained routing/recall registry, not a note" },
|
||||
|
||||
{ "id": "leaf-readme", "pattern": "^(.+/)?README\\.md$", "method": "PUT", "trigger": "Bootstrap leaf signpost / vault root README", "distinct_because": "Human signpost, not read for routing" }
|
||||
],
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""sweep.py — bring an existing ECHO vault up to the current feature spec.
|
||||
|
||||
After upgrading the plugin (entity index, cross-linking, recall), run this once to
|
||||
backfill what older vaults don't have yet:
|
||||
|
||||
1. ensure the `_agent/index/` folder exists,
|
||||
2. **(re)build the entity index** (`_agent/index/entities.json`) from the notes
|
||||
already in the vault (people, companies, projects, concepts, references,
|
||||
meetings, areas, decisions, semantic/episodic memory, skills),
|
||||
3. **symmetrize cross-links** — for every `## Related` link A -> B, ensure the
|
||||
reciprocal B -> A exists (it only adds the missing direction; it never invents
|
||||
new links from body text), and
|
||||
4. stamp the marker `schema_version` to the current schema (3).
|
||||
|
||||
Dry-run by default; pass --apply to write. READ-ONLY without --apply.
|
||||
Cross-platform: pure Python via echo.py.
|
||||
|
||||
Usage: sweep.py [--apply]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import echo # noqa: E402
|
||||
import echo_index as idx_mod # noqa: E402
|
||||
import echo_links as links # noqa: E402
|
||||
|
||||
CURRENT_SCHEMA = 3
|
||||
TEMPLATE_RE = re.compile(r"(^|/)(templates/|.*-template\.md$)")
|
||||
SKIP_BASENAMES = {"README.md"}
|
||||
|
||||
|
||||
kind_for = idx_mod.kind_for_path
|
||||
|
||||
|
||||
def get(path: str) -> str | None:
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 404:
|
||||
return None
|
||||
echo.check(status, body, f"get {path}")
|
||||
return body.decode(errors="replace")
|
||||
|
||||
|
||||
def list_dir(path: str):
|
||||
p = "" if path in ("", "/") else (path if path.endswith("/") else path + "/")
|
||||
body = get(p)
|
||||
if body is None:
|
||||
return [], []
|
||||
try:
|
||||
j = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return [], []
|
||||
entries = list(j.get("files", [])) + list(j.get("folders", []))
|
||||
files = [e for e in entries if not e.endswith("/")]
|
||||
folders = [e[:-1] for e in entries if e.endswith("/")]
|
||||
return files, folders
|
||||
|
||||
|
||||
def walk(prefix: str = ""):
|
||||
files, folders = list_dir(prefix)
|
||||
for f in files:
|
||||
yield prefix + f
|
||||
for d in folders:
|
||||
yield from walk(f"{prefix}{d}/")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description="Bring an ECHO vault up to the current feature spec")
|
||||
ap.add_argument("--apply", action="store_true")
|
||||
args = ap.parse_args(argv)
|
||||
apply = args.apply
|
||||
tag = "APPLY" if apply else "PLAN "
|
||||
|
||||
if get("_agent/echo-vault.md") is None:
|
||||
print("sweep: marker missing — vault not bootstrapped. Run bootstrap.py first.", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
print(f"sweep: {'applying changes' if apply else 'dry-run (no writes)'}\n")
|
||||
|
||||
# ---- 1. index folder ------------------------------------------------------
|
||||
if get("_agent/index/README.md") is None:
|
||||
print(f"sweep: {tag} create _agent/index/README.md")
|
||||
if apply:
|
||||
echo.request("PUT", echo.vault_url("_agent/index/README.md"),
|
||||
data=b"# index\n\nMachine-maintained entity index. See the echo-memory plugin.\n",
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
|
||||
all_files = [p for p in walk()
|
||||
if p.endswith(".md") and p.rsplit("/", 1)[-1] not in SKIP_BASENAMES
|
||||
and not TEMPLATE_RE.search(p)]
|
||||
|
||||
# ---- 2. (re)build entity index -------------------------------------------
|
||||
index = idx_mod.load()
|
||||
existing = dict(index.get("entities", {}))
|
||||
rebuilt = idx_mod.empty_index()
|
||||
added = updated = 0
|
||||
for path in all_files:
|
||||
kind = kind_for(path)
|
||||
if kind is None:
|
||||
continue
|
||||
base = path.rsplit("/", 1)[-1][:-3]
|
||||
slug = idx_mod.slugify(base)
|
||||
text = get(path) or ""
|
||||
title = links.first_h1(text) or base
|
||||
prev = existing.get(slug)
|
||||
aliases = prev.get("aliases", []) if prev else []
|
||||
idx_mod.upsert(rebuilt, slug, path, kind, title, aliases)
|
||||
if not prev:
|
||||
added += 1
|
||||
elif prev.get("path") != path or prev.get("title") != title:
|
||||
updated += 1
|
||||
print(f"sweep: {tag} entity index — {len(rebuilt['entities'])} entities "
|
||||
f"({added} new, {updated} changed)")
|
||||
if apply:
|
||||
idx_mod.save(rebuilt)
|
||||
|
||||
# ---- 3. symmetrize existing Related links --------------------------------
|
||||
fileset = set(all_files)
|
||||
basemap: dict[str, str] = {}
|
||||
for p in all_files:
|
||||
basemap.setdefault(idx_mod.slugify(p.rsplit("/", 1)[-1][:-3]), p)
|
||||
|
||||
def resolve_target(t: str) -> str | None:
|
||||
t = t.strip()
|
||||
if "/" in t:
|
||||
cand = t if t.endswith(".md") else t + ".md"
|
||||
return cand if cand in fileset else None
|
||||
return basemap.get(idx_mod.slugify(t))
|
||||
|
||||
missing = set() # (target_path, source_path) — reciprocal link to add on target
|
||||
for path in all_files:
|
||||
text = get(path)
|
||||
if text is None:
|
||||
continue
|
||||
for tgt in links.related_targets(text):
|
||||
tp = resolve_target(tgt)
|
||||
if not tp or tp == path:
|
||||
continue
|
||||
back = {resolve_target(t) for t in links.related_targets(get(tp) or "")}
|
||||
if path not in back:
|
||||
missing.add((tp, path))
|
||||
missing = sorted(missing)
|
||||
print(f"sweep: {tag} reciprocal links — {len(missing)} to add")
|
||||
for tp, sp in missing[:40]:
|
||||
print(f" {tp} += [[{links.link_token(sp)}]]")
|
||||
if len(missing) > 40:
|
||||
print(f" ... and {len(missing) - 40} more")
|
||||
if apply:
|
||||
for tp, sp in missing:
|
||||
links.add_one(tp, sp)
|
||||
|
||||
# ---- 4. stamp schema ------------------------------------------------------
|
||||
marker = get("_agent/echo-vault.md") or ""
|
||||
cur = next((int(m) for m in re.findall(r"(?m)^schema_version:\s*(\d+)", marker)), 0)
|
||||
if cur < CURRENT_SCHEMA:
|
||||
print(f"sweep: {tag} schema_version {cur} -> {CURRENT_SCHEMA}")
|
||||
if apply:
|
||||
echo.cmd_fm("_agent/echo-vault.md", "schema_version", str(CURRENT_SCHEMA))
|
||||
|
||||
print(f"\nsweep: {'done — run vault_lint.py to confirm invariants' if apply else 'dry-run complete. Re-run with --apply to write.'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline regression tests for the ECHO tooling. No network, no vault.
|
||||
|
||||
Run: python3 test_echo_client.py (or: pytest test_echo_client.py)
|
||||
|
||||
Covers echo.py body/scalar normalization, the routing-view consistency checker's
|
||||
helpers, and — as the guard for improvement #4 — asserts the shipped docs are in
|
||||
sync with routing.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import echo # noqa: E402
|
||||
import check_routing # noqa: E402
|
||||
import echo_index # noqa: E402
|
||||
import echo_links # noqa: E402
|
||||
|
||||
|
||||
def test_heading_replace_body_is_newline_guarded() -> None:
|
||||
assert echo.normalize_patch_body(b"- [x] done\n", "replace", "heading") == b"\n- [x] done\n"
|
||||
|
||||
|
||||
def test_heading_append_body_ends_with_newline() -> None:
|
||||
assert echo.normalize_patch_body(b"- item", "append", "heading") == b"- item\n"
|
||||
|
||||
|
||||
def test_frontmatter_plain_string_becomes_json_scalar() -> None:
|
||||
assert json.loads(echo.normalize_json_scalar("Fabrication Lead")) == "Fabrication Lead"
|
||||
|
||||
|
||||
def test_frontmatter_existing_json_is_preserved() -> None:
|
||||
assert echo.normalize_json_scalar('"2026-06-20"') == b'"2026-06-20"'
|
||||
|
||||
|
||||
def test_stem_extracts_literal_directory_prefix() -> None:
|
||||
assert check_routing.stem(r"^projects/active/[^/]+\.md$") == "projects/active/"
|
||||
assert check_routing.stem(r"^areas/(business|personal)/[^/]+\.md$") == "areas/"
|
||||
assert check_routing.stem(r"^journal/daily/\d{4}-\d{2}-\d{2}\.md$") == "journal/daily/"
|
||||
|
||||
|
||||
def test_concretize_substitutes_placeholders() -> None:
|
||||
assert check_routing.concretize("projects/<lifecycle>/<slug>.md") == "projects/active/sample.md"
|
||||
assert check_routing.concretize("_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md") == "_agent/sessions/2026-01-02-0900-sample.md"
|
||||
assert check_routing.concretize("journal/weekly/YYYY-Www.md") == "journal/weekly/2026-W01.md"
|
||||
|
||||
|
||||
def test_looks_like_path_filters_non_paths() -> None:
|
||||
assert check_routing.looks_like_path("inbox/captures/inbox.md")
|
||||
assert check_routing.looks_like_path("_agent/locks/vault.lock")
|
||||
assert not check_routing.looks_like_path("application/vnd.olrapi.document-map+json")
|
||||
assert not check_routing.looks_like_path("Operator Preferences::Fact / Pattern")
|
||||
assert not check_routing.looks_like_path("status: active")
|
||||
|
||||
|
||||
def test_docs_are_in_sync_with_routing_json() -> None:
|
||||
# The guard for improvement #4: SKILL.md / routing-map.md / api-reference.md
|
||||
# must stay consistent with routing.json.
|
||||
assert check_routing.main() == 0
|
||||
|
||||
|
||||
def test_index_slugify_and_derive_path() -> None:
|
||||
assert echo_index.slugify("Bob Smith!") == "bob-smith"
|
||||
assert echo_index.derive_path("person", "bob-smith") == "resources/people/bob-smith.md"
|
||||
assert echo_index.derive_path("project", "echo") == "projects/active/echo.md"
|
||||
assert echo_index.derive_path("area", "ops", domain="business") == "areas/business/ops.md"
|
||||
assert echo_index.derive_path("decision", "x", date="2026-01-02") == "decisions/by-date/2026-01-02-x.md"
|
||||
|
||||
|
||||
def test_index_resolve_matches_alias_and_title() -> None:
|
||||
index = {"entities": {"bob-smith": {"path": "resources/people/bob-smith.md",
|
||||
"kind": "person", "title": "Bob Smith", "aliases": ["bob"]}}}
|
||||
assert echo_index.resolve(index, "Bob Smith")[0] == "bob-smith"
|
||||
assert echo_index.resolve(index, "bob")[0] == "bob-smith"
|
||||
assert echo_index.resolve(index, "nobody")[0] is None
|
||||
|
||||
|
||||
def test_kind_for_path() -> None:
|
||||
assert echo_index.kind_for_path("resources/people/x.md") == "person"
|
||||
assert echo_index.kind_for_path("projects/on-hold/x.md") == "project"
|
||||
assert echo_index.kind_for_path("journal/daily/2026-01-01.md") is None
|
||||
|
||||
|
||||
def test_links_add_related_is_idempotent_and_bidirectional_token() -> None:
|
||||
text = "---\ntype: person\n---\n\n# Bob\n\n## Related\n"
|
||||
new, changed = echo_links.add_related(text, "resources/companies/mpm.md")
|
||||
assert changed and "[[resources/companies/mpm]]" in new
|
||||
again, changed2 = echo_links.add_related(new, "resources/companies/mpm.md")
|
||||
assert not changed2 and again == new
|
||||
|
||||
|
||||
def test_links_create_related_section_when_absent() -> None:
|
||||
text = "---\ntype: concept\n---\n\n# Alpha\n\nbody\n"
|
||||
new, changed = echo_links.add_related(text, "resources/concepts/beta.md")
|
||||
assert changed and "## Related" in new and "[[resources/concepts/beta]]" in new
|
||||
|
||||
|
||||
def _run_all() -> int:
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
print(f"ok {test.__name__}")
|
||||
except AssertionError as exc:
|
||||
failed += 1
|
||||
print(f"FAIL {test.__name__}: {exc}")
|
||||
print(f"\n{len(tests) - failed}/{len(tests)} passed")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_run_all())
|
||||
@@ -1,318 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# vault-lint.sh — mechanically assert ECHO vault invariants.
|
||||
#
|
||||
# Catches the recurring "invariant violation" bugs that prose rules can't enforce:
|
||||
# folder<->status drift, duplicate slugs, wikilinks leaking into frontmatter,
|
||||
# duplicate "## Agent Log" headings, stale active projects, aging inbox captures,
|
||||
# impossible dates, bad status values, missing frontmatter, broken source_notes, and
|
||||
# paths that no route in routing.json permits. Invoked by the monthly Vault Health
|
||||
# pass (see SKILL.md), but safe to run any time — it is READ-ONLY.
|
||||
#
|
||||
# Exit status: 0 = clean, 1 = violations found, 2 = vault unreachable,
|
||||
# 3 = vault not bootstrapped (marker missing).
|
||||
#
|
||||
# Config (env overrides):
|
||||
# ECHO_BASE (default https://echoapi.alwisp.com)
|
||||
# ECHO_KEY (default the plugin's bearer token)
|
||||
# ECHO_TODAY (default the machine date) — pass the conversation's currentDate so
|
||||
# stale/aging math uses the SAME clock the agent writes with (YYYY-MM-DD)
|
||||
# STALE_DAYS (default 30) INBOX_DAYS (default 14)
|
||||
#
|
||||
# routing.json (canonical route manifest) is read from this script's own directory.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
ECHO_BASE="${ECHO_BASE:-https://echoapi.alwisp.com}"
|
||||
ECHO_KEY="${ECHO_KEY:-241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab}"
|
||||
STALE_DAYS="${STALE_DAYS:-30}"
|
||||
INBOX_DAYS="${INBOX_DAYS:-14}"
|
||||
SCOPE_STALE_SESSIONS="${SCOPE_STALE_SESSIONS:-3}"
|
||||
ECHO_TODAY="${ECHO_TODAY:-$(date +%Y-%m-%d)}"
|
||||
|
||||
ECHO_BASE="$ECHO_BASE" ECHO_KEY="$ECHO_KEY" STALE_DAYS="$STALE_DAYS" INBOX_DAYS="$INBOX_DAYS" \
|
||||
SCOPE_STALE_SESSIONS="$SCOPE_STALE_SESSIONS" \
|
||||
ECHO_TODAY="$ECHO_TODAY" ROUTING_JSON="$SCRIPT_DIR/routing.json" \
|
||||
python3 - <<'PY'
|
||||
import os, sys, json, re, datetime, urllib.request, urllib.error
|
||||
|
||||
BASE = os.environ["ECHO_BASE"].rstrip("/")
|
||||
KEY = os.environ["ECHO_KEY"]
|
||||
STALE_DAYS = int(os.environ["STALE_DAYS"])
|
||||
INBOX_DAYS = int(os.environ["INBOX_DAYS"])
|
||||
SCOPE_STALE_SESSIONS = int(os.environ["SCOPE_STALE_SESSIONS"])
|
||||
TODAY = datetime.date.fromisoformat(os.environ["ECHO_TODAY"])
|
||||
ROUTING_JSON = os.environ["ROUTING_JSON"]
|
||||
LIFECYCLES = ["active", "incubating", "on-hold", "archived"]
|
||||
SKIP = {"README.md", "project-template.md", "decision-template.md"}
|
||||
REQUIRED_FM = ("type", "created")
|
||||
# Project status vocabulary IS enforced (status must equal the lifecycle folder) by the
|
||||
# folder/status check below. Other note kinds (decisions/concepts) carry free-form status
|
||||
# vocab (accepted, shipped, reference, ...), so there is no global status allow-list.
|
||||
|
||||
# optional real YAML parser; fall back to a tolerant line parser
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
HAVE_YAML = True
|
||||
except Exception:
|
||||
HAVE_YAML = False
|
||||
|
||||
violations = []
|
||||
def flag(check, msg): violations.append((check, msg))
|
||||
|
||||
def get(path):
|
||||
"""GET /vault/<path>. Returns text, or None on 404. Raises on hard failure."""
|
||||
req = urllib.request.Request(f"{BASE}/vault/{path}",
|
||||
headers={"Authorization": f"Bearer {KEY}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def list_dir(path):
|
||||
"""Return (files, folders) for a vault directory. Directories may arrive either in a
|
||||
'folders' key OR as 'files' entries ending in '/'; handle both. Root is '' -> /vault/.
|
||||
Tolerates non-404 errors (e.g. a 400 on an odd path) by returning empty."""
|
||||
p = "" if path in ("", "/") else (path if path.endswith("/") else path + "/")
|
||||
try:
|
||||
body = get(p)
|
||||
except urllib.error.HTTPError:
|
||||
return [], []
|
||||
if body is None:
|
||||
return [], []
|
||||
try:
|
||||
j = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return [], []
|
||||
entries = list(j.get("files", [])) + list(j.get("folders", []))
|
||||
files = [e for e in entries if not e.endswith("/")]
|
||||
folders = [e[:-1] for e in entries if e.endswith("/")]
|
||||
return files, folders
|
||||
|
||||
def walk(prefix=""):
|
||||
"""Yield every file path under prefix (recursive). prefix is '' or ends with '/'."""
|
||||
files, folders = list_dir(prefix)
|
||||
for f in files:
|
||||
yield prefix + f
|
||||
for d in folders:
|
||||
yield from walk(f"{prefix}{d}/")
|
||||
|
||||
def split_frontmatter(text):
|
||||
"""Return (raw_yaml_str, body) splitting on anchored ^---$ delimiters. ('', text) if none."""
|
||||
if not text:
|
||||
return "", ""
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return "", text
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
return "\n".join(lines[1:i]), "\n".join(lines[i+1:])
|
||||
return "", text # unterminated block -> treat as no frontmatter
|
||||
|
||||
def parse_fm(text):
|
||||
"""Return (raw_yaml_str, dict). Uses PyYAML when available, else a tolerant parser."""
|
||||
raw, _ = split_frontmatter(text)
|
||||
if not raw:
|
||||
return "", {}
|
||||
if HAVE_YAML:
|
||||
try:
|
||||
d = yaml.safe_load(raw)
|
||||
return raw, (d if isinstance(d, dict) else {})
|
||||
except Exception:
|
||||
pass
|
||||
# fallback: scalar + simple inline-list lines (keys may contain digits, _, -)
|
||||
fields = {}
|
||||
for line in raw.splitlines():
|
||||
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
||||
if m:
|
||||
v = m.group(2).strip()
|
||||
if v.startswith("[") and v.endswith("]"):
|
||||
v = [x.strip().strip('"').strip("'") for x in v[1:-1].split(",") if x.strip()]
|
||||
else:
|
||||
v = v.strip('"').strip("'")
|
||||
fields[m.group(1)] = v
|
||||
return raw, fields
|
||||
|
||||
def parse_date(s):
|
||||
m = re.match(r"(\d{4}-\d{2}-\d{2})", str(s or ""))
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return datetime.date.fromisoformat(m.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def as_list(v):
|
||||
if v is None or v == "":
|
||||
return []
|
||||
return v if isinstance(v, list) else [v]
|
||||
|
||||
# ---- Reachability + bootstrap probe (M2: do NOT silently report clean) -------
|
||||
try:
|
||||
if get("_agent/echo-vault.md") is None:
|
||||
print("vault-lint: marker missing — vault not bootstrapped (run bootstrap.sh).", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
except Exception as e:
|
||||
print(f"vault-lint: vault unreachable ({e}).", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# ---- Load canonical routing manifest (S3) ------------------------------------
|
||||
ROUTES, RETIRED = [], []
|
||||
try:
|
||||
with open(ROUTING_JSON) as fh:
|
||||
rj = json.load(fh)
|
||||
ROUTES = [(r["id"], re.compile(r["pattern"])) for r in rj.get("routes", [])]
|
||||
RETIRED = [(re.compile(r["pattern"]), r.get("replacement", "")) for r in rj.get("retired", [])]
|
||||
except Exception as e:
|
||||
flag("routing-manifest", f"could not load routing.json ({e}) — path checks skipped")
|
||||
|
||||
# ---- Single full walk feeds every path-level check ---------------------------
|
||||
all_files = list(walk())
|
||||
|
||||
def route_for(path):
|
||||
for rid, rx in ROUTES:
|
||||
if rx.match(path):
|
||||
return rid
|
||||
return None
|
||||
|
||||
# Path membership + retired-path detection (S3)
|
||||
for path in all_files:
|
||||
if ROUTES and route_for(path) is None:
|
||||
hit = next((repl for rx, repl in RETIRED if rx.match(path)), None)
|
||||
if hit is not None:
|
||||
flag("retired-path", f"{path}: retired location — should be {hit}")
|
||||
else:
|
||||
flag("unknown-path", f"{path}: matches no route in routing.json")
|
||||
|
||||
# ---- Per-note frontmatter checks (M5) ----------------------------------------
|
||||
TEMPLATE_RE = re.compile(r"(^|/)(templates/|.*-template\.md$)")
|
||||
for path in all_files:
|
||||
base = path.rsplit("/", 1)[-1]
|
||||
if base in SKIP or TEMPLATE_RE.search(path) or not path.endswith(".md"):
|
||||
continue
|
||||
text = get(path)
|
||||
if text is None:
|
||||
continue
|
||||
raw, fm = parse_fm(text)
|
||||
|
||||
# wikilinks anywhere in frontmatter (widened sweep — all folders)
|
||||
if "[[" in raw:
|
||||
flag("frontmatter-wikilink", f"{path}: '[[...]]' inside frontmatter")
|
||||
|
||||
# missing required frontmatter
|
||||
missing = [k for k in REQUIRED_FM if not str(fm.get(k, "")).strip()]
|
||||
if fm and missing:
|
||||
flag("missing-frontmatter", f"{path}: missing {', '.join(missing)}")
|
||||
|
||||
# impossible dates: updated < created
|
||||
c, u = parse_date(fm.get("created")), parse_date(fm.get("updated"))
|
||||
if c and u and u < c:
|
||||
flag("date-order", f"{path}: updated {u} is before created {c}")
|
||||
if u and u > TODAY:
|
||||
flag("future-date", f"{path}: updated {u} is in the future (today {TODAY})")
|
||||
|
||||
# source_notes hygiene: plain relative paths, never wikilinks, no self-reference
|
||||
for sn in as_list(fm.get("source_notes")):
|
||||
s = str(sn)
|
||||
if "[[" in s:
|
||||
flag("source-notes-wikilink", f"{path}: source_notes contains a wikilink '{s}'")
|
||||
|
||||
# ---- Projects: folder<->status, stale active, duplicate slugs ----------------
|
||||
slug_homes = {}
|
||||
for lc in LIFECYCLES:
|
||||
files, _ = list_dir(f"projects/{lc}")
|
||||
for fn in files:
|
||||
if fn.endswith("/") or fn in SKIP or not fn.endswith(".md"):
|
||||
continue
|
||||
slug = fn[:-3]
|
||||
slug_homes.setdefault(slug, []).append(lc)
|
||||
text = get(f"projects/{lc}/{fn}")
|
||||
if text is None:
|
||||
continue
|
||||
_, fm = parse_fm(text)
|
||||
status = str(fm.get("status", "")).strip().strip('"').strip("'")
|
||||
if status and status != lc:
|
||||
flag("folder/status", f"projects/{lc}/{fn}: status='{status}' but folder='{lc}'")
|
||||
if lc == "active":
|
||||
d = parse_date(fm.get("updated"))
|
||||
if d and (TODAY - d).days > STALE_DAYS:
|
||||
flag("stale-active", f"projects/active/{fn}: updated {d} ({(TODAY-d).days}d ago) — consider on-hold/")
|
||||
for slug, homes in slug_homes.items():
|
||||
if len(homes) > 1:
|
||||
flag("duplicate-slug", f"'{slug}' exists in {', '.join(homes)}")
|
||||
|
||||
# ---- Daily notes: duplicate "## Agent Log" headings --------------------------
|
||||
for path in all_files:
|
||||
if not re.match(r"^journal/daily/.*\.md$", path):
|
||||
continue
|
||||
text = get(path) or ""
|
||||
n = len(re.findall(r"(?m)^## Agent Log\s*$", text))
|
||||
if n > 1:
|
||||
flag("duplicate-agent-log", f"{path}: {n} '## Agent Log' headings")
|
||||
|
||||
# ---- Inbox: captures aging past INBOX_DAYS -----------------------------------
|
||||
inbox = get("inbox/captures/inbox.md") or ""
|
||||
for line in inbox.splitlines():
|
||||
m = re.match(r"^\s*-\s*(\d{4}-\d{2}-\d{2})\b", line)
|
||||
if m:
|
||||
d = parse_date(m.group(1))
|
||||
if d and (TODAY - d).days > INBOX_DAYS:
|
||||
flag("aging-inbox", f"inbox capture {d} ({(TODAY-d).days}d): {line.strip()[:80]}")
|
||||
|
||||
# ---- Scope freshness (drift detector) ----------------------------------------
|
||||
# Scope is the most churn-prone state (Jason runs several sessions/day across topics).
|
||||
# It has no natural staleness signal, so drift is otherwise invisible. Rule: if N+ session
|
||||
# logs are dated AFTER current-context's scope_updated, the recorded scope may no longer
|
||||
# reflect current work — surface it for a human glance (advisory, like every health finding).
|
||||
cc = get("_agent/context/current-context.md")
|
||||
if cc is not None:
|
||||
_, ccfm = parse_fm(cc)
|
||||
su = parse_date(ccfm.get("scope_updated"))
|
||||
if su is None:
|
||||
flag("scope-no-timestamp",
|
||||
"_agent/context/current-context.md: no scope_updated frontmatter — scope drift cannot be detected; add it (bootstrap.sh repair) and switch scope via `echo.sh scope set`")
|
||||
else:
|
||||
since = [p for p in all_files
|
||||
if (m := re.match(r"^_agent/sessions/(\d{4}-\d{2}-\d{2})", p))
|
||||
and (d := parse_date(m.group(1))) and d > su]
|
||||
if len(since) >= SCOPE_STALE_SESSIONS:
|
||||
flag("scope-stale",
|
||||
f"scope set {su}; {len(since)} session(s) logged since without a switch — confirm it still reflects current work (or run `echo.sh scope set`)")
|
||||
|
||||
# ---- Report ------------------------------------------------------------------
|
||||
if not violations:
|
||||
print("vault-lint: clean — all invariants hold.")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"vault-lint: {len(violations)} violation(s) found\n")
|
||||
by = {}
|
||||
for check, msg in violations:
|
||||
by.setdefault(check, []).append(msg)
|
||||
labels = {
|
||||
"folder/status": "Folder <-> status mismatch",
|
||||
"duplicate-slug": "Duplicate slug across lifecycle folders",
|
||||
"frontmatter-wikilink": "Wikilink in frontmatter (breaks reading view)",
|
||||
"duplicate-agent-log": "Duplicate '## Agent Log' heading",
|
||||
"stale-active": f"Stale active project (updated > {STALE_DAYS}d)",
|
||||
"aging-inbox": f"Inbox capture aging (> {INBOX_DAYS}d)",
|
||||
"unknown-path": "Path matches no route in routing.json",
|
||||
"retired-path": "Write to a retired/dead path",
|
||||
"missing-frontmatter": "Missing required frontmatter field",
|
||||
"date-order": "updated earlier than created",
|
||||
"future-date": "updated date is in the future",
|
||||
"source-notes-wikilink": "Wikilink in source_notes (must be plain paths)",
|
||||
"routing-manifest": "routing.json problem",
|
||||
"scope-no-timestamp": "current-context has no scope_updated (drift undetectable)",
|
||||
"scope-stale": f"Scope may have drifted (>= {SCOPE_STALE_SESSIONS} sessions since last switch)",
|
||||
}
|
||||
for check, msgs in by.items():
|
||||
print(f"## {labels.get(check, check)}")
|
||||
for m in msgs:
|
||||
print(f" - {m}")
|
||||
print()
|
||||
sys.exit(1)
|
||||
PY
|
||||
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""vault_lint.py — mechanically assert ECHO vault invariants. READ-ONLY.
|
||||
|
||||
Catches the recurring drift bugs prose rules can't enforce: folder<->status
|
||||
mismatch, duplicate slugs, wikilinks leaking into frontmatter, duplicate
|
||||
"## Agent Log" headings, stale active projects, aging inbox captures, impossible
|
||||
dates, missing frontmatter, broken source_notes, scope drift, and paths that no
|
||||
route in routing.json permits. Cross-platform: pure Python via echo.py.
|
||||
|
||||
Exit status: 0 = clean, 1 = violations found, 2 = vault unreachable,
|
||||
3 = vault not bootstrapped (marker missing).
|
||||
|
||||
Config (env overrides):
|
||||
ECHO_BASE, ECHO_KEY (via echo.py)
|
||||
ECHO_TODAY (default machine date) — pass the conversation's currentDate so
|
||||
stale/aging math uses the same clock the agent writes with.
|
||||
STALE_DAYS (default 30) INBOX_DAYS (default 14) SCOPE_STALE_SESSIONS (default 3)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import echo # noqa: E402
|
||||
import echo_index as idx_mod # noqa: E402
|
||||
import echo_links as links # noqa: E402
|
||||
|
||||
TODAY = dt.date.fromisoformat(os.environ.get("ECHO_TODAY") or dt.date.today().isoformat())
|
||||
STALE_DAYS = int(os.environ.get("STALE_DAYS", "30"))
|
||||
INBOX_DAYS = int(os.environ.get("INBOX_DAYS", "14"))
|
||||
SCOPE_STALE_SESSIONS = int(os.environ.get("SCOPE_STALE_SESSIONS", "3"))
|
||||
LIFECYCLES = ["active", "incubating", "on-hold", "archived"]
|
||||
SKIP = {"README.md", "project-template.md", "decision-template.md"}
|
||||
REQUIRED_FM = ("type", "created")
|
||||
|
||||
violations: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
def flag(check: str, message: str) -> None:
|
||||
violations.append((check, message))
|
||||
|
||||
|
||||
def get(path: str) -> str | None:
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 404:
|
||||
return None
|
||||
echo.check(status, body, f"get {path}")
|
||||
return body.decode(errors="replace")
|
||||
|
||||
|
||||
def list_dir(path: str) -> tuple[list[str], list[str]]:
|
||||
if path and not path.endswith("/"):
|
||||
path += "/"
|
||||
body = get(path)
|
||||
if body is None:
|
||||
return [], []
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return [], []
|
||||
entries = list(payload.get("files", [])) + list(payload.get("folders", []))
|
||||
files = [entry for entry in entries if not entry.endswith("/")]
|
||||
folders = [entry[:-1] for entry in entries if entry.endswith("/")]
|
||||
return files, folders
|
||||
|
||||
|
||||
def walk(prefix: str = ""):
|
||||
files, folders = list_dir(prefix)
|
||||
for filename in files:
|
||||
yield prefix + filename
|
||||
for folder in folders:
|
||||
yield from walk(f"{prefix}{folder}/")
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[str, str]:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return "", text
|
||||
for index in range(1, len(lines)):
|
||||
if lines[index].strip() == "---":
|
||||
return "\n".join(lines[1:index]), "\n".join(lines[index + 1:])
|
||||
return "", text
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[str, dict[str, object]]:
|
||||
raw, _ = split_frontmatter(text)
|
||||
fields: dict[str, object] = {}
|
||||
for line in raw.splitlines():
|
||||
match = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
||||
if not match:
|
||||
continue
|
||||
value: object = match.group(2).strip().strip('"').strip("'")
|
||||
if isinstance(value, str) and value.startswith("[") and value.endswith("]"):
|
||||
value = [part.strip().strip('"').strip("'") for part in value[1:-1].split(",") if part.strip()]
|
||||
fields[match.group(1)] = value
|
||||
return raw, fields
|
||||
|
||||
|
||||
def parse_date(value: object) -> dt.date | None:
|
||||
match = re.match(r"(\d{4}-\d{2}-\d{2})", str(value or ""))
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return dt.date.fromisoformat(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def as_list(value: object) -> list[object]:
|
||||
if value in (None, ""):
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def route_matchers():
|
||||
routing = json.loads((SCRIPT_DIR / "routing.json").read_text(encoding="utf-8"))
|
||||
routes = [(item["id"], re.compile(item["pattern"])) for item in routing.get("routes", [])]
|
||||
retired = [(re.compile(item["pattern"]), item.get("replacement", "")) for item in routing.get("retired", [])]
|
||||
return routes, retired
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
if get("_agent/echo-vault.md") is None:
|
||||
print("vault-lint: marker missing — vault not bootstrapped (run bootstrap.py).", file=sys.stderr)
|
||||
return 3
|
||||
except Exception as exc:
|
||||
print(f"vault-lint: vault unreachable ({exc}).", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
routes, retired = route_matchers()
|
||||
except Exception as exc:
|
||||
routes, retired = [], []
|
||||
flag("routing-manifest", f"could not load routing.json ({exc}) — path checks skipped")
|
||||
|
||||
all_files = list(walk())
|
||||
|
||||
# Path membership + retired-path detection
|
||||
for path in all_files:
|
||||
if routes and not any(rx.match(path) for _, rx in routes):
|
||||
replacement = next((repl for rx, repl in retired if rx.match(path)), None)
|
||||
if replacement is not None:
|
||||
flag("retired-path", f"{path}: retired location — should be {replacement}")
|
||||
else:
|
||||
flag("unknown-path", f"{path}: matches no route in routing.json")
|
||||
|
||||
# Per-note frontmatter checks
|
||||
template_re = re.compile(r"(^|/)(templates/|.*-template\.md$)")
|
||||
for path in all_files:
|
||||
base = path.rsplit("/", 1)[-1]
|
||||
if base in SKIP or template_re.search(path) or not path.endswith(".md"):
|
||||
continue
|
||||
text = get(path) or ""
|
||||
raw, fm = parse_frontmatter(text)
|
||||
if "[[" in raw:
|
||||
flag("frontmatter-wikilink", f"{path}: '[[...]]' inside frontmatter")
|
||||
missing = [k for k in REQUIRED_FM if not str(fm.get(k, "")).strip()]
|
||||
if fm and missing:
|
||||
flag("missing-frontmatter", f"{path}: missing {', '.join(missing)}")
|
||||
created, updated = parse_date(fm.get("created")), parse_date(fm.get("updated"))
|
||||
if created and updated and updated < created:
|
||||
flag("date-order", f"{path}: updated {updated} is before created {created}")
|
||||
if updated and updated > TODAY:
|
||||
flag("future-date", f"{path}: updated {updated} is in the future (today {TODAY})")
|
||||
for source_note in as_list(fm.get("source_notes")):
|
||||
if "[[" in str(source_note):
|
||||
flag("source-notes-wikilink", f"{path}: source_notes contains a wikilink '{source_note}'")
|
||||
|
||||
# Projects: folder<->status, stale active, duplicate slugs
|
||||
slug_homes: dict[str, list[str]] = {}
|
||||
for lifecycle in LIFECYCLES:
|
||||
files, _ = list_dir(f"projects/{lifecycle}")
|
||||
for filename in files:
|
||||
if filename in SKIP or not filename.endswith(".md"):
|
||||
continue
|
||||
slug = filename[:-3]
|
||||
slug_homes.setdefault(slug, []).append(lifecycle)
|
||||
text = get(f"projects/{lifecycle}/{filename}") or ""
|
||||
_, fm = parse_frontmatter(text)
|
||||
status = str(fm.get("status", "")).strip()
|
||||
if status and status != lifecycle:
|
||||
flag("folder/status", f"projects/{lifecycle}/{filename}: status='{status}' but folder='{lifecycle}'")
|
||||
if lifecycle == "active":
|
||||
updated = parse_date(fm.get("updated"))
|
||||
if updated and (TODAY - updated).days > STALE_DAYS:
|
||||
flag("stale-active", f"projects/active/{filename}: updated {updated} ({(TODAY - updated).days}d ago) — consider on-hold/")
|
||||
for slug, homes in slug_homes.items():
|
||||
if len(homes) > 1:
|
||||
flag("duplicate-slug", f"'{slug}' exists in {', '.join(homes)}")
|
||||
|
||||
# Daily notes: duplicate "## Agent Log" headings
|
||||
for path in all_files:
|
||||
if re.match(r"^journal/daily/.*\.md$", path):
|
||||
text = get(path) or ""
|
||||
count = len(re.findall(r"(?m)^## Agent Log\s*$", text))
|
||||
if count > 1:
|
||||
flag("duplicate-agent-log", f"{path}: {count} '## Agent Log' headings")
|
||||
|
||||
# Inbox: captures aging past INBOX_DAYS
|
||||
inbox = get("inbox/captures/inbox.md") or ""
|
||||
for line in inbox.splitlines():
|
||||
match = re.match(r"^\s*-\s*(\d{4}-\d{2}-\d{2})\b", line)
|
||||
date = parse_date(match.group(1)) if match else None
|
||||
if date and (TODAY - date).days > INBOX_DAYS:
|
||||
flag("aging-inbox", f"inbox capture {date} ({(TODAY - date).days}d): {line.strip()[:80]}")
|
||||
|
||||
# Scope freshness (drift detector)
|
||||
context = get("_agent/context/current-context.md")
|
||||
if context is not None:
|
||||
_, fm = parse_frontmatter(context)
|
||||
scope_updated = parse_date(fm.get("scope_updated"))
|
||||
if scope_updated is None:
|
||||
flag("scope-no-timestamp",
|
||||
"_agent/context/current-context.md: no scope_updated frontmatter — scope drift cannot be detected; add it (bootstrap.py) and switch scope via `echo.py scope set`")
|
||||
else:
|
||||
since = [
|
||||
path for path in all_files
|
||||
if (m := re.match(r"^_agent/sessions/(\d{4}-\d{2}-\d{2})", path))
|
||||
and (d := parse_date(m.group(1))) and d > scope_updated
|
||||
]
|
||||
if len(since) >= SCOPE_STALE_SESSIONS:
|
||||
flag("scope-stale",
|
||||
f"scope set {scope_updated}; {len(since)} session(s) logged since without a switch — confirm it still reflects current work (or run `echo.py scope set`)")
|
||||
|
||||
# ---- Graph health: broken wikilinks, orphans, index drift ----------------
|
||||
md_files = [p for p in all_files if p.endswith(".md")]
|
||||
md_set = set(md_files)
|
||||
basemap: dict[str, str] = {}
|
||||
for p in md_files:
|
||||
basemap.setdefault(idx_mod.slugify(p.rsplit("/", 1)[-1][:-3]), p)
|
||||
skip_link = re.compile(r"(^|/)(templates/|.*-template\.md$)")
|
||||
|
||||
def resolve_link(target: str):
|
||||
target = target.strip()
|
||||
if not target:
|
||||
return None
|
||||
if "/" in target:
|
||||
cand = target if target.endswith(".md") else target + ".md"
|
||||
return cand if cand in md_set else None
|
||||
return basemap.get(idx_mod.slugify(target))
|
||||
|
||||
text_cache: dict[str, str] = {}
|
||||
inbound: set[str] = set()
|
||||
for path in md_files:
|
||||
if path.rsplit("/", 1)[-1] in SKIP or skip_link.search(path):
|
||||
continue
|
||||
text = get(path) or ""
|
||||
text_cache[path] = text
|
||||
_, body = split_frontmatter(text)
|
||||
for t in links.all_wikilinks(body):
|
||||
tp = resolve_link(t)
|
||||
if tp is None:
|
||||
flag("broken-wikilink", f"{path}: [[{t}]] resolves to no note")
|
||||
else:
|
||||
inbound.add(tp)
|
||||
|
||||
def is_indexable(path: str) -> bool:
|
||||
# READMEs and templates are signposts, not entity notes (sweep skips them too).
|
||||
base = path.rsplit("/", 1)[-1]
|
||||
return (idx_mod.kind_for_path(path) is not None
|
||||
and base not in SKIP and not template_re.search(path))
|
||||
|
||||
# orphan: an entity note with no inbound links and an empty ## Related
|
||||
for path in md_files:
|
||||
if not is_indexable(path) or path in inbound:
|
||||
continue
|
||||
text = text_cache.get(path, get(path) or "")
|
||||
if not links.related_targets(text):
|
||||
flag("orphan", f"{path}: no inbound links and empty ## Related ({idx_mod.kind_for_path(path)}) — link it for recall")
|
||||
|
||||
# index drift: entries pointing at gone files, or indexable notes not yet indexed
|
||||
try:
|
||||
index = idx_mod.load()
|
||||
idx_paths = {e.get("path") for e in index.get("entities", {}).values()}
|
||||
for slug, e in index.get("entities", {}).items():
|
||||
if e.get("path") not in md_set:
|
||||
flag("index-stale", f"index entry '{slug}' -> {e.get('path')} no longer exists (run sweep.py)")
|
||||
for path in md_files:
|
||||
if is_indexable(path) and path not in idx_paths:
|
||||
flag("index-missing", f"{path}: indexable note absent from entities.json (run sweep.py)")
|
||||
except Exception as exc:
|
||||
flag("index-error", f"could not check entity index ({exc})")
|
||||
|
||||
if not violations:
|
||||
print("vault-lint: clean — all invariants hold.")
|
||||
return 0
|
||||
|
||||
print(f"vault-lint: {len(violations)} violation(s) found\n")
|
||||
labels = {
|
||||
"folder/status": "Folder <-> status mismatch",
|
||||
"duplicate-slug": "Duplicate slug across lifecycle folders",
|
||||
"frontmatter-wikilink": "Wikilink in frontmatter (breaks reading view)",
|
||||
"duplicate-agent-log": "Duplicate '## Agent Log' heading",
|
||||
"stale-active": f"Stale active project (updated > {STALE_DAYS}d)",
|
||||
"aging-inbox": f"Inbox capture aging (> {INBOX_DAYS}d)",
|
||||
"unknown-path": "Path matches no route in routing.json",
|
||||
"retired-path": "Write to a retired/dead path",
|
||||
"missing-frontmatter": "Missing required frontmatter field",
|
||||
"date-order": "updated earlier than created",
|
||||
"future-date": "updated date is in the future",
|
||||
"source-notes-wikilink": "Wikilink in source_notes (must be plain paths)",
|
||||
"routing-manifest": "routing.json problem",
|
||||
"scope-no-timestamp": "current-context has no scope_updated (drift undetectable)",
|
||||
"scope-stale": f"Scope may have drifted (>= {SCOPE_STALE_SESSIONS} sessions since last switch)",
|
||||
"broken-wikilink": "Wikilink resolves to no note (dead link)",
|
||||
"orphan": "Orphan entity note (no inbound links, empty Related)",
|
||||
"index-stale": "Entity-index entry points at a missing file",
|
||||
"index-missing": "Indexable note absent from the entity index",
|
||||
"index-error": "Entity-index check failed",
|
||||
}
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for check, message in violations:
|
||||
grouped.setdefault(check, []).append(message)
|
||||
for check, messages in grouped.items():
|
||||
print(f"## {labels.get(check, check)}")
|
||||
for message in messages:
|
||||
print(f" - {message}")
|
||||
print()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user