1
0
forked from jason/echo
Files
chorus/echo-memory.plugin.src/skills/echo-memory/scripts/migrate.py
T

155 lines
5.9 KiB
Python
Raw Normal View History

2026-06-21 11:46:54 -05:00
#!/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())