From ec2272d51c0ac2be902429bab28da9a23e7d001a Mon Sep 17 00:00:00 2001 From: Veronica Chambers Date: Wed, 13 May 2026 21:56:56 -0600 Subject: [PATCH] add moments collection + Mnemosyne sync workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new `_moments` Jekyll collection for short narrative fragments, with a CI workflow that syncs documents from a Mnemosyne folder into the collection on a daily schedule (or via manual dispatch). - `_moments/` collection with dedicated `moment` layout and index page - `.github/scripts/sync_from_mnemosyne.py` calls the platform's POST /generate-markdown/folder endpoint, writes synced files, and removes orphaned ones via provenance-block reconciliation - `.github/workflows/deploy.yml` adds a `sync-mnemosyne-folder` job (workflow_dispatch + daily cron at 10:00 UTC) that runs before deploy - `.tool-versions` pins Ruby 3.2.2 to match CI Hand-written moments are never touched by the sync — only files with a `mnemosyne:` frontmatter block are eligible for reconciliation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/scripts/sync_from_mnemosyne.py | 365 +++++++++++++++++++++++++ .github/workflows/deploy.yml | 72 +++++ .tool-versions | 1 + _config.yml | 6 + _layouts/moment.liquid | 22 ++ _moments/2026-05-13-first-moment.md | 6 + _pages/moments.md | 25 ++ 7 files changed, 497 insertions(+) create mode 100755 .github/scripts/sync_from_mnemosyne.py create mode 100644 .tool-versions create mode 100644 _layouts/moment.liquid create mode 100644 _moments/2026-05-13-first-moment.md create mode 100644 _pages/moments.md diff --git a/.github/scripts/sync_from_mnemosyne.py b/.github/scripts/sync_from_mnemosyne.py new file mode 100755 index 00000000..25845b64 --- /dev/null +++ b/.github/scripts/sync_from_mnemosyne.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Sync a Mnemosyne folder to a Jekyll collection with delete reconciliation. + +Calls the locked Mnemosyne contract: + + POST $MNEMOSYNE_URL/generate-markdown/folder + { "graph_id": ..., "folder_name": ..., "collection": ... } + +Response (200 application/json): + + { + "folder": { "name": ..., "id": ... }, + "items": [ { "document_id": ..., "filename": ..., "content": ... }, ... ], + "document_ids": [ "", ... ], # SUCCESSFUL renders only + "warnings": [ { "document_id": ..., "reason": ... } ] + } + +Writes each item to `_/`. Then scans existing +`_/*.md` files; any file whose YAML frontmatter carries a +`mnemosyne.document_id` not present in the response's `document_ids` is +deleted via `git rm`. Files lacking a `mnemosyne:` provenance block are +NEVER touched — hand-written moments are sacred. + +Modes: + + Live: reads env (MNEMOSYNE_URL, MNEMOSYNE_API_KEY, GRAPH_ID, + FOLDER_NAME, COLLECTION), POSTs to the endpoint, writes + files, runs `git rm` on orphans, prints a summary line. + + Dry-run: --dry-run --response --collection + skips HTTP and `git rm`, prints what *would* change. + Useful for local testing. + +Exits non-zero on HTTP error, malformed JSON, or unexpected I/O failure. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +import yaml + + +# ---------- HTTP ----------------------------------------------------------- # + + +def fetch_folder( + base_url: str, + api_key: str | None, + graph_id: str, + folder_name: str, + collection: str, + dev_user_id: str | None = None, +) -> dict[str, Any]: + """POST to the folder endpoint and return parsed JSON. + + Auth: in normal operation pass ``api_key`` (a ``mnemo_…`` Bearer token). + For local testing against a Mnemosyne in ``dev_no_auth`` mode, pass + ``dev_user_id`` instead — it's sent as the ``X-User-ID`` header that the + platform's dev-mode auth accepts. Exactly one of the two should be set. + """ + url = f"{base_url.rstrip('/')}/generate-markdown/folder" + body = json.dumps( + {"graph_id": graph_id, "folder_name": folder_name, "collection": collection} + ).encode("utf-8") + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + elif dev_user_id: + headers["X-User-ID"] = dev_user_id + req = urllib.request.Request(url, data=body, method="POST", headers=headers) + try: + with urllib.request.urlopen(req) as resp: # noqa: S310 + raw = resp.read() + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8", errors="replace") + sys.stderr.write( + f"Mnemosyne returned HTTP {e.code}\n{err_body}\n" + ) + sys.exit(1) + except urllib.error.URLError as e: + sys.stderr.write(f"Network error reaching {url}: {e}\n") + sys.exit(1) + + try: + return json.loads(raw.decode("utf-8")) + except json.JSONDecodeError as e: + sys.stderr.write( + f"Malformed JSON from Mnemosyne: {e}\n--- body ---\n" + f"{raw.decode('utf-8', errors='replace')}\n" + ) + sys.exit(1) + + +# ---------- Frontmatter --------------------------------------------------- # + + +def extract_mnemosyne_document_id(md_path: Path) -> str | None: + """Return the `mnemosyne.document_id` from a markdown file's frontmatter, + or None if the file has no `mnemosyne:` provenance block. + + Returns None on any parse failure — better to leave a file alone than + risk deleting a hand-written one we couldn't classify. + """ + try: + text = md_path.read_text(encoding="utf-8") + except OSError: + return None + if not text.startswith("---"): + return None + # Split off the YAML frontmatter between the first pair of `---` fences. + parts = text.split("\n", 1) + if len(parts) < 2: + return None + rest = parts[1] + end = rest.find("\n---") + if end == -1: + return None + fm_text = rest[:end] + try: + data = yaml.safe_load(fm_text) + except yaml.YAMLError: + return None + if not isinstance(data, dict): + return None + mn = data.get("mnemosyne") + if not isinstance(mn, dict): + return None + doc_id = mn.get("document_id") + if not isinstance(doc_id, str) or not doc_id: + return None + return doc_id + + +# ---------- Sync ---------------------------------------------------------- # + + +def sync( + response: dict[str, Any], + collection: str, + *, + dry_run: bool = False, + repo_root: Path | None = None, +) -> dict[str, Any]: + """Apply the sync to disk and return a summary dict.""" + root = repo_root or Path.cwd() + coll_dir = root / f"_{collection}" + coll_dir.mkdir(parents=True, exist_ok=True) + + items = response.get("items") or [] + document_ids = set(response.get("document_ids") or []) + warnings = response.get("warnings") or [] + + added: list[str] = [] + modified: list[str] = [] + unchanged: list[str] = [] + removed: list[str] = [] + + # 1. Write each item. + for item in items: + filename = item.get("filename") + content = item.get("content") + if not filename or content is None: + sys.stderr.write( + f"Skipping item with missing filename/content: {item!r}\n" + ) + continue + target = coll_dir / filename + if target.exists(): + try: + existing = target.read_text(encoding="utf-8") + except OSError: + existing = None + if existing == content: + unchanged.append(str(target.relative_to(root))) + continue + if not dry_run: + target.write_text(content, encoding="utf-8") + modified.append(str(target.relative_to(root))) + else: + if not dry_run: + target.write_text(content, encoding="utf-8") + added.append(str(target.relative_to(root))) + + # 2. Delete reconciliation. + for md in sorted(coll_dir.glob("*.md")): + doc_id = extract_mnemosyne_document_id(md) + if doc_id is None: + # No provenance — hand-written. Never delete. + continue + if doc_id in document_ids: + continue + # Provenance present, doc_id missing from response → orphan, delete. + rel = str(md.relative_to(root)) + if not dry_run: + res = subprocess.run( + ["git", "rm", "--quiet", "--", rel], + cwd=root, + capture_output=True, + text=True, + ) + if res.returncode != 0: + # File may not be tracked yet (e.g. just-written in same run); + # fall back to plain unlink so the workflow still reconciles. + try: + md.unlink() + except OSError as e: + sys.stderr.write( + f"Could not remove orphan {rel}: git rm said " + f"{res.stderr.strip()!r}, unlink raised {e}\n" + ) + continue + removed.append(rel) + + return { + "added": added, + "modified": modified, + "unchanged": unchanged, + "removed": removed, + "warnings": warnings, + "collection": collection, + "folder": response.get("folder"), + } + + +# ---------- Reporting ----------------------------------------------------- # + + +def print_summary(summary: dict[str, Any]) -> None: + """Human-readable summary to stderr; machine-readable JSON to stdout. + + The workflow captures stdout to drive the commit message. + """ + n_added = len(summary["added"]) + n_modified = len(summary["modified"]) + n_unchanged = len(summary["unchanged"]) + n_removed = len(summary["removed"]) + n_warnings = len(summary["warnings"]) + + sys.stderr.write( + f"sync: collection={summary['collection']} " + f"added={n_added} modified={n_modified} " + f"unchanged={n_unchanged} removed={n_removed} " + f"warnings={n_warnings}\n" + ) + for path in summary["added"]: + sys.stderr.write(f" + {path}\n") + for path in summary["modified"]: + sys.stderr.write(f" ~ {path}\n") + for path in summary["removed"]: + sys.stderr.write(f" - {path}\n") + for w in summary["warnings"]: + sys.stderr.write( + f" ! warning document_id={w.get('document_id')} " + f"reason={w.get('reason')}\n" + ) + + # Stdout: a single line of JSON the workflow can parse. + out = { + "collection": summary["collection"], + "added": n_added, + "modified": n_modified, + "unchanged": n_unchanged, + "removed": n_removed, + "warnings": n_warnings, + "warning_details": summary["warnings"], + } + sys.stdout.write(json.dumps(out) + "\n") + + +# ---------- Entry --------------------------------------------------------- # + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--dry-run", + action="store_true", + help="Skip HTTP and git operations; just simulate.", + ) + p.add_argument( + "--response", + type=Path, + help="Path to a JSON file containing a pre-fetched response. " + "Required with --dry-run; optional otherwise (skips the HTTP call).", + ) + p.add_argument( + "--collection", + help="Jekyll collection name (e.g. 'moments'). " + "Defaults to $COLLECTION env var.", + ) + p.add_argument( + "--graph-id", + help="Mnemosyne graph_id. Defaults to $GRAPH_ID env var.", + ) + p.add_argument( + "--folder-name", + help="Mnemosyne folder name. Defaults to $FOLDER_NAME env var.", + ) + p.add_argument( + "--repo-root", + type=Path, + default=Path.cwd(), + help="Repository root (default: cwd).", + ) + args = p.parse_args() + + collection = args.collection or os.environ.get("COLLECTION") or "moments" + graph_id = args.graph_id or os.environ.get("GRAPH_ID") or "lucy" + folder_name = args.folder_name or os.environ.get("FOLDER_NAME") or collection + + if args.response is not None: + try: + response = json.loads(args.response.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + sys.stderr.write(f"Could not load response file {args.response}: {e}\n") + return 1 + else: + if args.dry_run: + sys.stderr.write("--dry-run requires --response \n") + return 2 + base_url = os.environ.get("MNEMOSYNE_URL") + api_key = os.environ.get("MNEMOSYNE_API_KEY") + # Dev escape hatch for testing against a local Mnemosyne in + # `dev_no_auth` mode — accepts an X-User-ID header instead of a + # mnemo_ Bearer token. Not used by CI. + dev_user_id = os.environ.get("MNEMOSYNE_DEV_USER_ID") + if not base_url or (not api_key and not dev_user_id): + sys.stderr.write( + "MNEMOSYNE_URL plus one of MNEMOSYNE_API_KEY / " + "MNEMOSYNE_DEV_USER_ID must be set in env.\n" + ) + return 2 + response = fetch_folder( + base_url, + api_key, + graph_id, + folder_name, + collection, + dev_user_id=dev_user_id, + ) + + summary = sync( + response, + collection, + dry_run=args.dry_run, + repo_root=args.repo_root, + ) + print_summary(summary) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3c63c79a..046b1336 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,11 +50,83 @@ on: - "!INSTALL.md" - "!README.md" workflow_dispatch: + inputs: + graph_id: + description: "Mnemosyne graph_id" + required: false + default: "lucy" + folder_name: + description: "Mnemosyne folder name to sync" + required: false + default: "moments" + collection: + description: "Target Jekyll collection" + required: false + default: "moments" + schedule: + # Daily at 10:00 UTC. + - cron: "0 10 * * *" permissions: contents: write jobs: + sync-mnemosyne-folder: + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + runs-on: ubuntu-latest + permissions: + contents: write + env: + GRAPH_ID: ${{ github.event.inputs.graph_id || 'lucy' }} + FOLDER_NAME: ${{ github.event.inputs.folder_name || 'moments' }} + COLLECTION: ${{ github.event.inputs.collection || 'moments' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install Python deps + run: pip install pyyaml + - name: Configure git identity + run: | + git config user.name "mnemosyne-bot" + git config user.email "mnemosyne-bot@users.noreply.github.com" + - name: Sync folder from Mnemosyne + id: sync + env: + MNEMOSYNE_URL: ${{ secrets.MNEMOSYNE_URL }} + MNEMOSYNE_API_KEY: ${{ secrets.MNEMOSYNE_API_KEY }} + run: | + set -euo pipefail + SUMMARY=$(python3 .github/scripts/sync_from_mnemosyne.py \ + --graph-id "$GRAPH_ID" \ + --folder-name "$FOLDER_NAME" \ + --collection "$COLLECTION") + echo "summary<> "$GITHUB_OUTPUT" + echo "$SUMMARY" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + echo "$SUMMARY" + - name: Commit and push + env: + SUMMARY: ${{ steps.sync.outputs.summary }} + run: | + set -euo pipefail + git add -A "_${COLLECTION}" + if git diff --staged --quiet; then + echo "No changes to commit." + exit 0 + fi + ADDED=$(echo "$SUMMARY" | python3 -c "import json,sys;print(json.load(sys.stdin)['added'])") + MODIFIED=$(echo "$SUMMARY" | python3 -c "import json,sys;print(json.load(sys.stdin)['modified'])") + UNCHANGED=$(echo "$SUMMARY" | python3 -c "import json,sys;print(json.load(sys.stdin)['unchanged'])") + REMOVED=$(echo "$SUMMARY" | python3 -c "import json,sys;print(json.load(sys.stdin)['removed'])") + WARNINGS=$(echo "$SUMMARY" | python3 -c "import json,sys;print(json.load(sys.stdin)['warnings'])") + MSG="mnemosyne: sync ${FOLDER_NAME} folder (${ADDED} added, ${MODIFIED} modified, ${REMOVED} removed, ${UNCHANGED} unchanged, ${WARNINGS} warnings)" + git commit -m "$MSG" + git push + deploy: # available images: https://github.com/actions/runner-images#available-images runs-on: ubuntu-latest diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..f2a971aa --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.2.2 diff --git a/_config.yml b/_config.yml index 822415cb..5541ca3e 100644 --- a/_config.yml +++ b/_config.yml @@ -187,6 +187,12 @@ collections: defaults: layout: post sitemap: false + moments: + output: true + permalink: /moments/:path/ + defaults: + layout: moment + sitemap: true announcements: enabled: true diff --git a/_layouts/moment.liquid b/_layouts/moment.liquid new file mode 100644 index 00000000..0a023b19 --- /dev/null +++ b/_layouts/moment.liquid @@ -0,0 +1,22 @@ +--- +layout: default +--- +
+
+ {% if page.title %} +

{{ page.title }}

+ {% endif %} +

+ + {{ page.date | date: '%b %d, %Y · %-I:%M %p' }} + {% if page.tags and page.tags != empty %} + {% for tag in page.tags %} +  {{ tag }} + {% endfor %} + {% endif %} +

+
+
+ {{ content }} +
+
diff --git a/_moments/2026-05-13-first-moment.md b/_moments/2026-05-13-first-moment.md new file mode 100644 index 00000000..49c11498 --- /dev/null +++ b/_moments/2026-05-13-first-moment.md @@ -0,0 +1,6 @@ +--- +layout: moment +date: 2026-05-13 14:32:00 +0000 +tags: [example] +--- +This is a hand-written moment — small narrative fragments live in this collection. Moments synced from Mnemosyne are added automatically alongside hand-written ones like this and are distinguished by a `mnemosyne:` block in their frontmatter. The sync only ever touches its own files, so this one is safe. diff --git a/_pages/moments.md b/_pages/moments.md new file mode 100644 index 00000000..472f9ca1 --- /dev/null +++ b/_pages/moments.md @@ -0,0 +1,25 @@ +--- +layout: page +title: moments +permalink: /moments/ +nav: true +nav_order: 6 +--- + +
    +{% assign moments = site.moments | sort: 'date' | reverse %} +{% for m in moments %} +
  • +

    + {{ m.date | date: '%b %d, %Y · %-I:%M %p' }} + {% if m.tags and m.tags != empty %} + {% for tag in m.tags %} · #{{ tag }}{% endfor %} + {% endif %} +

    + {% if m.title %}

    {{ m.title }}

    {% endif %} +
    {{ m.content }}
    + {% unless m.title %}

    permalink

    {% endunless %} +
    +
  • +{% endfor %} +