diff --git a/docs/evals.md b/docs/evals.md new file mode 100644 index 0000000..d95fda6 --- /dev/null +++ b/docs/evals.md @@ -0,0 +1,60 @@ +# Skill Evaluation Status + +Continuous evaluation status for Tula skills. This page is regenerated +automatically by `scripts/generate-eval-status.sh` on every CI run that +touches `skills/` or `evals/`. Static analysis (compliance, spec +checks, token budgets) is fresh on every run; live eval results come +from manually-published runs in `results/`. + +Powered by [Microsoft Waza](https://github.com/microsoft/waza). + +| Skill | Compliance | Spec | Tokens | Last live run | +|---|---|---|---|---| +| `epic-note` | Medium-High | 9/9 ✓ | 705 / 500 ⚠ | - | +| `health-records` | Medium-High | 9/9 ✓ | 1318 / 500 ⚠ | - | +| `lookout` | Medium-High | 9/9 ✓ | 1577 / 500 ⚠ | - | +| `med-pdf` | Medium-High | 9/9 ✓ | 842 / 500 ⚠ | - | +| `memory-diff` | Medium-High | 9/9 ✓ | 1183 / 500 ⚠ | - | +| `myhealth-pulse` | Medium-High | 9/9 ✓ | 1176 / 500 ⚠ | - | +| `prep-my-visit` | Medium-High | 9/9 ✓ | 457 / 500 ✓ | - | +| `request-amendment` | Medium-High | 9/9 ✓ | 990 / 500 ⚠ | - | + +--- + +## What this measures + +- **Compliance** - Waza's agentskills.io readiness score + (`High` / `Medium-High` / `Medium` / `Low`). `Medium-High` or better + is the house target. +- **Spec** - count of agentskills.io spec checks the skill passes + (`spec-frontmatter`, `spec-name`, `spec-allowed-fields`, and so on). + 9/9 is full pass. +- **Tokens** - total tokens in `SKILL.md` against Waza's 500-token soft + limit. Tula's house style accepts a higher count when openclaw + fidelity would suffer (per `skills/AGENTS.md`'s "Token Discipline" + section). `⚠` marks "exceeds the soft cap but intentional"; `✓` marks + "within budget." +- **Last live run** - most recent `waza run` output published in + `results/`. Cells show pass rate, run date, and model used (e.g., + `5/5 ✓ (2026-05-17, sonnet-4.6)`). Live eval execution requires + `executor: copilot-sdk` plus model auth, so it is a deliberate + publish today rather than a per-PR CI run. Raw run outputs stay + private; only the pass-rate summary surfaces here. + +## What this does NOT measure + +- The model's actual answer quality. Evals check task-completion + signals (output shape, presence/absence of keywords, routing + behavior, schema validity), not clinical correctness. +- Production behavior under PHI. All evals run against synthetic + personas. See `evals/*/fixtures/` for the test data. +- Anything inside Aria's closed governance layer - multi-tenant + isolation, audit emission, cross-actor coordination - which is + evaluated separately under hospital-scale fixtures. + +## See also + +- [Eval suites](../evals/) - task definitions and fixtures +- [Skill authoring conventions](../skills/AGENTS.md) +- [Tula deployment guide](deployment-guide.md) +- [Microsoft Waza](https://github.com/microsoft/waza) - the eval framework diff --git a/scripts/agent-backup.sh b/scripts/agent-backup.sh index fc0f2bc..b8f5b0d 100755 --- a/scripts/agent-backup.sh +++ b/scripts/agent-backup.sh @@ -50,8 +50,9 @@ # ## Exit codes # 0 Success (whether or not there were changes) # 1 Generic error -# 2 Secret-pattern scan failed - see stderr for offending file(s) +# 2 Secret-pattern scan or large-file guard failed - see stderr # 3 Push failed (commit was made; resolve auth and retry `git push`) +# 4 Privacy guard failed (remote repo is not PRIVATE - refused to push) # # ## Exclusions (mirrors the repo's `.gitignore` - keep both in sync) # credentials/ telegram pairing secrets @@ -153,6 +154,11 @@ PURGE=( 'logs' 'update-check.json' 'plugin-runtime-deps' + 'npm' # ~700MB of plugin npm projects; + # contains coding-agent binaries + # 200MB+ each (> GitHub's 100MB + # file cap). Regenerable via + # `openclaw plugins install ...`. ) # Nested-.git protection. Any `.git` directory under the source - at any @@ -172,6 +178,12 @@ PROTECT=( 'docs' ) +# Hard cap on individual file size in the backup tree. GitHub rejects any +# file >100MB without LFS. We set a tighter 50MB cap to catch problems +# before they hit the remote, and to keep the repo cloneable on slow links. +# Anything over this should be added to PURGE. +MAX_FILE_BYTES=$((50 * 1024 * 1024)) + # Regex patterns that look like real credentials. Tuned to be high-signal; # if a pattern fires, the run aborts unless the file is in ALLOWLIST_GLOBS. SECRET_PATTERNS=( @@ -340,6 +352,63 @@ else log "secret scan: clean" fi +# ---------- step 3b: large-file guard -------------------------------------- +# +# Refuse to stage anything over MAX_FILE_BYTES. GitHub rejects >100MB hard, +# but we want to catch the problem early (cheaper than a failed push) and +# under a tighter budget so clone-from-backup stays fast. + +if [[ $DRY_RUN -eq 0 ]]; then + big_files=$(find "$AGENT_REPO_DIR" -type f -size +"${MAX_FILE_BYTES}c" \ + -not -path "$AGENT_REPO_DIR/.git/*" 2>/dev/null || true) + if [[ -n "$big_files" ]]; then + echo "" >&2 + echo "Large-file guard FAILED. Files over $((MAX_FILE_BYTES/1024/1024))MB:" >&2 + echo "------------------------------------------------------------" >&2 + while IFS= read -r f; do + sz=$(du -h "$f" | cut -f1) + printf ' %s\t%s\n' "$sz" "${f#$AGENT_REPO_DIR/}" >&2 + done <<< "$big_files" + echo "------------------------------------------------------------" >&2 + echo "Add the offending path (or its parent dir) to the PURGE array." >&2 + exit 2 + fi + log "large-file guard: clean (no files > $((MAX_FILE_BYTES/1024/1024))MB)" +fi + +# ---------- step 3c: remote-private guard ---------------------------------- +# +# Defense in depth: refuse to push if the GitHub repo is somehow public. +# Catches a hand-toggle in the GitHub UI that would otherwise expose every +# subsequent backup commit. Only runs for github.com remotes when `gh` is +# available and authenticated; otherwise it is a soft warning. + +verify_repo_private() { + local remote_url="$1" + if ! command -v gh >/dev/null 2>&1; then + log "privacy guard: gh CLI not installed - SKIPPED (soft warning)" + return 0 + fi + if ! [[ "$remote_url" =~ github\.com[:/]([^/]+)/([^/.]+)(\.git)?$ ]]; then + log "privacy guard: non-github remote - SKIPPED" + return 0 + fi + local owner="${BASH_REMATCH[1]}" + local name="${BASH_REMATCH[2]}" + local visibility + visibility=$(gh repo view "$owner/$name" --json visibility -q .visibility 2>/dev/null || echo "") + if [[ -z "$visibility" ]]; then + log "privacy guard: could not query gh - SKIPPED (soft warning)" + return 0 + fi + if [[ "$visibility" != "PRIVATE" ]]; then + log "privacy guard: REFUSING to push - $owner/$name is $visibility (expected PRIVATE)" + return 1 + fi + log "privacy guard: $owner/$name confirmed PRIVATE" + return 0 +} + # ---------- step 4 & 5: commit --------------------------------------------- cd "$AGENT_REPO_DIR" @@ -380,6 +449,8 @@ fi REMOTE_URL=$(git remote get-url "$AGENT_REMOTE" 2>/dev/null || true) [[ -z "$REMOTE_URL" ]] && { log "remote '$AGENT_REMOTE' not configured"; exit 3; } +verify_repo_private "$REMOTE_URL" || exit 4 + log "push: $AGENT_REMOTE $AGENT_BRANCH ($REMOTE_URL)" if [[ -n "${GITHUB_TOKEN:-}" && "$REMOTE_URL" =~ ^https://github\.com/ ]]; then diff --git a/scripts/tenant-template/README.md b/scripts/tenant-template/README.md new file mode 100644 index 0000000..440f5c7 --- /dev/null +++ b/scripts/tenant-template/README.md @@ -0,0 +1,80 @@ +# Tula tenant-template build pipeline + +This directory holds the three artifacts that turn a Tula development VM +into a per-tenant golden image and provision new tenants from it. + +| File | Purpose | Runs on | +|---|---|---| +| `deprovision.sh` | Scrubs a source VM for image capture | The source VM (the one being baked) | +| `tula-provision.sh` | Spawns a new tenant from a captured image | The operator's laptop / control-plane VM | +| `cloud-init-template.yaml` | First-boot configuration for each new tenant | Auto-injected; never run manually | + +Full specification: [`~/.openclaw/workspace/docs/TENANT_TEMPLATE_BUILD.md`](../../../.openclaw/workspace/docs/TENANT_TEMPLATE_BUILD.md) + +## Quick start (operator) + +```bash +# One-time: prepare ops home +mkdir -p ~/tula-ops/{tenants,secrets} +chmod 700 ~/tula-ops ~/tula-ops/secrets +echo -n 'sk-ant-xxxx' > ~/tula-ops/secrets/anthropic-api-key && chmod 600 ~/tula-ops/secrets/anthropic-api-key +echo -n 'ghp_xxxx' > ~/tula-ops/secrets/github-pat-tenant-write && chmod 600 ~/tula-ops/secrets/github-pat-tenant-write + +# Add a few Telegram bot tokens to the pool (one per row) +cat <> ~/tula-ops/bot-token-pool.txt +# pool_name bot_token bot_username status +tula_aux_001 1234567890:AAH... TulaAux001Bot available +tula_aux_002 0987654321:AAH... TulaAux002Bot available +EOF +chmod 600 ~/tula-ops/bot-token-pool.txt + +# Bake the image (one-time, ~30 min) +ssh azureuser@ra-bake-vm 'sudo ~/tula/scripts/tenant-template/deprovision.sh --version 0.1.0 --confirm' +ssh azureuser@ra-bake-vm 'sudo waagent -deprovision+user -force' +az vm deallocate -g ra-healthcareagents-rg -n ra-bake-vm +az vm generalize -g ra-healthcareagents-rg -n ra-bake-vm +az image create -g ra-healthcareagents-rg -n tula-tenant-template-0-1-0 --source ra-bake-vm + +# Provision a tenant (per tenant, ~5 min) +~/tula/scripts/tenant-template/tula-provision.sh new-tenant "Jane Doe" "jane@example.com" +``` + +## Subcommands + +- `tula-provision new-tenant ` - full provision +- `tula-provision list` - list tenants +- `tula-provision show ` - show one tenant's record +- `tula-provision health ` - health check +- `tula-provision rollback ` - clean teardown (idempotent) +- `tula-provision decommission ` - 30-day-grace offboarding + +## Safety + +- `deprovision.sh` refuses to run on hosts named `tula-tenant-*` (prevents + nuking a live tenant) +- `deprovision.sh` requires `--confirm`; supports `--dry-run` +- `tula-provision.sh` rolls back automatically on any failure during + provisioning (deletes Azure RG, deletes GitHub repo, returns bot + token to pool) +- All operator secrets live in `~/tula-ops/secrets/` with 0600 perms +- Tenant secrets live in `/etc/tula-tenant-secrets.env` on the tenant + VM with 0600 perms, owned by `azureuser` +- No tenant content ever crosses any operator boundary (operator can + break-glass via SSH, but the operation is logged) + +## v0.1 known gaps (to harden in v0.2) + +- GitHub PAT per tenant is currently shared across tenants via + `~/tula-ops/secrets/github-pat-tenant-write`. Should be per-tenant + fine-grained PAT or GitHub App installation. Tracked in + [`TENANT_TEMPLATE_BUILD.md`](../../../.openclaw/workspace/docs/TENANT_TEMPLATE_BUILD.md) § 6.5. +- Data disk is currently combined with OS disk. v0.2 separates them so + image updates don't require workspace data migration. +- No control plane yet; tenant heartbeat to a central observability + endpoint is wired but disabled. Enable when control plane lands. +- No automated image-update workflow for existing tenants; updates are + manual per tenant in v0.1. + +## License + +Apache-2.0 (inherited from the Tula repository). diff --git a/scripts/tenant-template/cloud-init-template.yaml b/scripts/tenant-template/cloud-init-template.yaml new file mode 100644 index 0000000..95c30b3 --- /dev/null +++ b/scripts/tenant-template/cloud-init-template.yaml @@ -0,0 +1,259 @@ +#cloud-config +# +# cloud-init userdata for a Tula tenant VM. +# +# Rendered by tula-provision.sh with substitutions for {{TENANT_ID}}, +# {{TENANT_DISPLAY_NAME}}, {{TELEGRAM_BOT_TOKEN}}, {{ANTHROPIC_API_KEY}}, +# {{XAI_API_KEY}}, {{GITHUB_PAT}}, {{BACKUP_REPO_URL}}, {{OPERATOR_SSH_PUBKEY}}, +# {{TIMESTAMP}}. +# +# This file is sent to Azure via `--custom-data`, base64-encoded by az CLI, +# and executed by cloud-init on first boot (after the generalized image is +# unpacked and waagent has regenerated machine-id, SSH host keys, and +# hostname). +# +# Order of operations: +# 1. write_files - write secrets and config to disk with strict perms +# 2. runcmd - chain of post-boot configuration commands +# 3. final_message - operator-readable boot completion signal +# +# Idempotent: re-running this template would be a no-op (each runcmd step +# checks state first), but cloud-init only runs userdata once per instance. +# +# License: Apache-2.0 +# +# See companion spec at ~/.openclaw/workspace/docs/TENANT_TEMPLATE_BUILD.md +# + +# --------------------------------------------------------------------------- +# Hostname (the image was generalized; waagent already set this from the +# Azure VM name, which we passed in as tula-tenant-; we +# re-confirm here for safety.) +# --------------------------------------------------------------------------- +hostname: tula-tenant-{{TENANT_ID}} +fqdn: tula-tenant-{{TENANT_ID}} + +# --------------------------------------------------------------------------- +# Users - the image already has 'azureuser'; we re-inject the operator's +# SSH key (the deprovision step cleared authorized_keys). +# --------------------------------------------------------------------------- +users: + - default + - name: azureuser + ssh_authorized_keys: + - {{OPERATOR_SSH_PUBKEY}} + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + +# --------------------------------------------------------------------------- +# Files written to disk on first boot. All strict-perms. +# --------------------------------------------------------------------------- +write_files: + + # -- /etc/tula-tenant-id --------------------------------------------------- + # Single source of truth for "which tenant am I?", queryable by scripts. + - path: /etc/tula-tenant-id + permissions: '0644' + owner: root:root + content: | + {{TENANT_ID}} + + # -- /etc/tula-tenant-metadata -------------------------------------------- + # Non-secret tenant metadata, queryable by tools and the health endpoint. + - path: /etc/tula-tenant-metadata + permissions: '0644' + owner: root:root + content: | + tenant_id={{TENANT_ID}} + provisioned_at={{TIMESTAMP}} + bot_username={{TELEGRAM_BOT_USERNAME}} + + # -- /etc/tula-tenant-secrets.env ----------------------------------------- + # Secrets file with strict perms - sourced by openclaw-gateway.service. + # NEVER world-readable. + - path: /etc/tula-tenant-secrets.env + permissions: '0600' + owner: azureuser:azureuser + content: | + TELEGRAM_BOT_TOKEN={{TELEGRAM_BOT_TOKEN}} + ANTHROPIC_API_KEY={{ANTHROPIC_API_KEY}} + XAI_API_KEY={{XAI_API_KEY}} + GITHUB_PAT={{GITHUB_PAT}} + BACKUP_REPO_URL={{BACKUP_REPO_URL}} + + # -- /home/azureuser/.openclaw/workspace/USER.md -------------------------- + # Initial USER.md filled with tenant display name. The tenant can amend + # via onboarding; this is only the seed value so the agent has a name + # to greet by at first contact. + - path: /home/azureuser/.openclaw/workspace/USER.md + permissions: '0644' + owner: azureuser:azureuser + content: | + # USER.md - About Your Human + + - **Name:** {{TENANT_DISPLAY_NAME}} + - **What to call them:** {{TENANT_DISPLAY_NAME}} + - **Email:** {{TENANT_EMAIL}} + - **First conversation:** {{TIMESTAMP}} + + ## Context + + _(Build this out over time as they share more - health goals, current + conditions, lifestyle, what matters to them.)_ + + # -- /home/azureuser/tula/apps/my-aria/.env.local -------------------------- + # Tell My Aria to read this tenant's FHIR tree. + - path: /home/azureuser/tula/apps/my-aria/.env.local + permissions: '0600' + owner: azureuser:azureuser + content: | + TULA_DATA_DIR=/home/azureuser/.openclaw/workspace/tula/fhir + + # -- /etc/systemd/system/openclaw-gateway.service ------------------------- + # The gateway is the long-running process that bridges Telegram/web/etc. + # to the OpenClaw runtime. It sources secrets from the env file above. + - path: /etc/systemd/system/openclaw-gateway.service + permissions: '0644' + owner: root:root + content: | + [Unit] + Description=Tula OpenClaw gateway (tenant {{TENANT_ID}}) + After=network-online.target + Wants=network-online.target + + [Service] + Type=simple + User=azureuser + Group=azureuser + EnvironmentFile=/etc/tula-tenant-secrets.env + WorkingDirectory=/home/azureuser + ExecStart=/usr/bin/openclaw gateway start --foreground + Restart=on-failure + RestartSec=10 + StandardOutput=journal + StandardError=journal + + [Install] + WantedBy=multi-user.target + + # -- /etc/cron.d/tula-health-heartbeat ------------------------------------ + # Sends a content-free hourly heartbeat to the control plane. Disabled + # by default; un-comment to enable when the control plane is up. + - path: /etc/cron.d/tula-health-heartbeat + permissions: '0644' + owner: root:root + content: | + # Tula tenant heartbeat - content-free, aggregate-only telemetry. + # Uncomment when the control plane heartbeat endpoint is online. + # 0 * * * * azureuser /usr/local/bin/tula-heartbeat.sh + +# --------------------------------------------------------------------------- +# Runtime commands - executed in order, after write_files. +# --------------------------------------------------------------------------- +runcmd: + + # ---- Inject keys into openclaw config --------------------------------- + # The image ships with openclaw.json containing schema but no secrets. + # We use a small python helper to inject keys without disturbing schema. + - | + set -e + sudo -u azureuser python3 - <<'PYEOF' + import json, os, pathlib + cfg_path = pathlib.Path("/home/azureuser/.openclaw/openclaw.json") + if not cfg_path.exists(): + print("no openclaw.json; skipping key injection") + raise SystemExit(0) + with open(cfg_path) as f: + cfg = json.load(f) + # Read secrets file + secrets = {} + with open("/etc/tula-tenant-secrets.env") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): continue + k, _, v = line.partition("=") + secrets[k] = v + # Anthropic key + cfg.setdefault("providers", {}).setdefault("anthropic", {})["apiKey"] = secrets.get("ANTHROPIC_API_KEY", "") + # xAI key (optional) + if secrets.get("XAI_API_KEY"): + cfg.setdefault("providers", {}).setdefault("xai", {})["apiKey"] = secrets["XAI_API_KEY"] + # Telegram bot token - find the telegram account entry or create it + msgs = cfg.setdefault("messages", {}) + accts = msgs.setdefault("accounts", []) + tg_acct = None + for a in accts: + if isinstance(a, dict) and a.get("provider") == "telegram": + tg_acct = a; break + if tg_acct is None: + tg_acct = {"id": "default", "provider": "telegram", "credentials": {}} + accts.append(tg_acct) + tg_acct.setdefault("credentials", {})["botToken"] = secrets["TELEGRAM_BOT_TOKEN"] + with open(cfg_path, "w") as f: + json.dump(cfg, f, indent=2) + PYEOF + + # ---- Initialize aria-backup with per-tenant remote -------------------- + - | + set -e + sudo -u azureuser bash -c ' + cd /home/azureuser + if [ ! -d aria-repo/.git ]; then + git init aria-repo + cd aria-repo + git config user.name "tula-backup" + git config user.email "backup@tula.local" + fi + cd /home/azureuser/aria-repo + # Inject PAT into remote URL (URL-encode the @ if needed; PAT is alphanumeric) + REMOTE_URL=$(echo "$(grep ^BACKUP_REPO_URL= /etc/tula-tenant-secrets.env | cut -d= -f2-)" \ + | sed "s|https://|https://x-access-token:$(grep ^GITHUB_PAT= /etc/tula-tenant-secrets.env | cut -d= -f2-)@|") + git remote remove origin 2>/dev/null || true + git remote add origin "$REMOTE_URL" + ' + + # ---- Enable & start backup timer -------------------------------------- + - systemctl daemon-reload + - systemctl enable --now aria-backup.timer + + # ---- Enable & start openclaw gateway ---------------------------------- + - systemctl enable --now openclaw-gateway.service + + # ---- Wait briefly for openclaw to be healthy, then ack ---------------- + - | + set -e + # Allow up to 60s for the gateway to be ready + for i in $(seq 1 12); do + if systemctl is-active --quiet openclaw-gateway.service; then + echo ready > /etc/tula-ready + chmod 644 /etc/tula-ready + exit 0 + fi + sleep 5 + done + echo "failed:gateway-not-active-in-60s" > /etc/tula-ready + chmod 644 /etc/tula-ready + exit 1 + +# --------------------------------------------------------------------------- +# Final message - printed to /var/log/cloud-init-output.log and the +# serial console. +# --------------------------------------------------------------------------- +final_message: | + Tula tenant {{TENANT_ID}} provisioned at $UPTIME seconds after boot. + Display name: {{TENANT_DISPLAY_NAME}} + Bot username: @{{TELEGRAM_BOT_USERNAME}} + Backup repo: {{BACKUP_REPO_URL}} + Ready signal: cat /etc/tula-ready + +# --------------------------------------------------------------------------- +# Phone-home / failure capture +# --------------------------------------------------------------------------- +power_state: + # Reboot after first boot to ensure systemd unit ordering is clean + # (commented out for now; cloud-init's runcmd-then-systemd-enable pattern + # works without a reboot. Enable later if any service ordering bug shows up.) + # mode: reboot + # delay: "+1" + # message: "Tula tenant initialization complete; rebooting once." + # condition: True diff --git a/scripts/tenant-template/deprovision.sh b/scripts/tenant-template/deprovision.sh new file mode 100755 index 0000000..148efe9 --- /dev/null +++ b/scripts/tenant-template/deprovision.sh @@ -0,0 +1,601 @@ +#!/usr/bin/env bash +# +# deprovision.sh - scrub a Tula source VM for image capture. +# +# This script transforms a fully-configured, possibly-personalized source +# VM into a generalized tenant-template state, ready for `az vm generalize` +# and `az image create`. It is the operator's last step before image capture. +# +# Idempotent: safe to run twice (the second run is a no-op for already-cleared +# items). +# +# Safety: +# - Refuses to run on a tenant VM (hostname matches `tula-tenant-*`). +# - Refuses to run without `--confirm` to prevent accidental invocation. +# - `--dry-run` prints the plan without making changes. +# - Logs every action to /var/log/tula-deprovision-.log. +# - Final secret-scan exits non-zero if anything looks like a key. +# +# License: Apache-2.0 (inherited from the Tula repository). +# +# See companion spec: ~/.openclaw/workspace/docs/TENANT_TEMPLATE_BUILD.md +# + +set -euo pipefail + +# ------------------------------------------------------------------------- +# Configuration +# ------------------------------------------------------------------------- + +TULA_HOME="${TULA_HOME:-$HOME/tula}" +OPENCLAW_HOME="${OPENCLAW_HOME:-$HOME/.openclaw}" +WORKSPACE="$OPENCLAW_HOME/workspace" +AGENT_REPO="${AGENT_REPO:-$HOME/agent-repo}" +TEMPLATE_VERSION="${TEMPLATE_VERSION:-}" + +TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)" +LOG_FILE="/var/log/tula-deprovision-${TIMESTAMP}.log" + +DRY_RUN=0 +CONFIRM=0 + +# ------------------------------------------------------------------------- +# Argument parsing +# ------------------------------------------------------------------------- + +usage() { + cat <] + +Options: + --dry-run Print the plan; do not modify anything. + --confirm Required to actually perform the scrub. + --version Tag the image with this version (default: from \$TEMPLATE_VERSION env). + -h, --help Print this help. + +This script must be run with sudo. It scrubs the calling VM for image +capture. See ~/.openclaw/workspace/docs/TENANT_TEMPLATE_BUILD.md § 5. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --confirm) CONFIRM=1; shift ;; + --version) TEMPLATE_VERSION="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown arg: $1" >&2; usage; exit 2 ;; + esac +done + +# ------------------------------------------------------------------------- +# Pre-flight checks +# ------------------------------------------------------------------------- + +if [[ "$EUID" -ne 0 ]] && [[ "$DRY_RUN" -eq 0 ]]; then + echo "ERROR: must run with sudo (not dry-run)" >&2 + exit 1 +fi + +CURRENT_HOSTNAME="$(hostname)" +if [[ "$CURRENT_HOSTNAME" =~ ^tula-tenant- ]]; then + cat >&2 <&2 <&2 + TEMPLATE_VERSION="untagged-${TIMESTAMP}" +fi + +# ------------------------------------------------------------------------- +# Logging helpers +# ------------------------------------------------------------------------- + +if [[ "$DRY_RUN" -eq 0 ]]; then + touch "$LOG_FILE" + chmod 644 "$LOG_FILE" + exec > >(tee -a "$LOG_FILE") 2>&1 +fi + +log() { + local prefix="[$(date -u +%H:%M:%S)]" + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "$prefix [DRY-RUN] $*" + else + echo "$prefix $*" + fi +} + +run() { + log "RUN: $*" + if [[ "$DRY_RUN" -eq 0 ]]; then + eval "$@" + fi +} + +# Apparent owner of $HOME - needed because we may be invoked via sudo and +# need to drop privileges for non-root scrubs. +TARGET_USER="azureuser" +TARGET_HOME="/home/$TARGET_USER" + +# ------------------------------------------------------------------------- +# Step 1 - stop running services +# ------------------------------------------------------------------------- + +step_1_stop_services() { + log "==== Step 1: stop services ====" + run "systemctl stop aria-backup.timer 2>/dev/null || true" + run "systemctl stop aria-backup-notify.service 2>/dev/null || true" + run "systemctl stop openclaw-gateway.service 2>/dev/null || true" + run "systemctl stop openclaw-chat.service 2>/dev/null || true" + # Kill any user-space openclaw / claude / codex / next-server processes. + run "pkill -u $TARGET_USER -f 'openclaw|claude --|codex|next-server' 2>/dev/null || true" + sleep 1 +} + +# ------------------------------------------------------------------------- +# Step 2 - disable services that the tenant will re-enable +# ------------------------------------------------------------------------- + +step_2_disable_services() { + log "==== Step 2: disable per-tenant services ====" + run "systemctl disable aria-backup.timer 2>/dev/null || true" + run "systemctl disable openclaw-gateway.service 2>/dev/null || true" + run "systemctl disable openclaw-chat.service 2>/dev/null || true" +} + +# ------------------------------------------------------------------------- +# Step 3 - replace identity files with templates +# ------------------------------------------------------------------------- + +step_3_reset_identity() { + log "==== Step 3: reset tenant identity files ====" + local templates_src="$TULA_HOME/templates" + if [[ ! -d "$templates_src" ]]; then + log "WARNING: templates directory $templates_src does not exist; skipping identity reset" + return + fi + + # MEMORY.md, USER.md - replace with template versions + for f in MEMORY.template.md USER.template.md profile.template.yaml; do + local target="${f%.template.*}.${f##*.}" + # The templates are named MEMORY.template.md, USER.template.md, profile.template.yaml + case "$f" in + MEMORY.template.md) target="MEMORY.md" ;; + USER.template.md) target="USER.md" ;; + profile.template.yaml) target="memory/profile.yaml" ;; + esac + local src="$templates_src/$f" + local dst="$WORKSPACE/$target" + if [[ -f "$src" ]]; then + run "mkdir -p \"$(dirname \"$dst\")\"" + run "cp \"$src\" \"$dst\"" + run "chown $TARGET_USER:$TARGET_USER \"$dst\"" + fi + done + + # IDENTITY.md - replace with the canonical template version + if [[ -f "$templates_src/IDENTITY.template.md" ]]; then + run "cp \"$templates_src/IDENTITY.template.md\" \"$WORKSPACE/IDENTITY.md\"" + fi + + # HEARTBEAT.md - clear, leave the file empty/comment-only + run "echo '# HEARTBEAT.md - empty by default; tenant configures via heartbeat skill or onboarding.' > \"$WORKSPACE/HEARTBEAT.md\"" + + # memory/*.md - clear daily logs + run "rm -rf \"$WORKSPACE/memory\"/*.md 2>/dev/null || true" + run "rm -f \"$WORKSPACE/memory/heartbeat-state.json\" 2>/dev/null || true" +} + +# ------------------------------------------------------------------------- +# Step 4 - clear PHI caches +# ------------------------------------------------------------------------- + +step_4_clear_phi_caches() { + log "==== Step 4: clear PHI caches ====" + for dir in \ + "$WORKSPACE/.health-records-cache" \ + "$WORKSPACE/.med-pdf-cache" \ + "$WORKSPACE/.myhealth-pulse-cache" \ + "$WORKSPACE/tula/fhir" \ + "$WORKSPACE/pmv-review" \ + "$WORKSPACE/pmv-verify" \ + "$WORKSPACE/my-aria-screenshots" \ + "$WORKSPACE/amendment-output" \ + "$WORKSPACE/amendments"; do + if [[ -d "$dir" ]]; then + run "rm -rf \"$dir\"" + fi + done +} + +# ------------------------------------------------------------------------- +# Step 5 - clear loose files in workspace root (preserve curated docs) +# ------------------------------------------------------------------------- + +step_5_clear_workspace_root_loose() { + log "==== Step 5: clear loose files in workspace root ====" + # Files we explicitly keep in the image: + local KEEP_PATTERN='^(SOUL|AGENTS|MEMORY|USER|IDENTITY|TOOLS|HEARTBEAT|TRADEMARK|NOTICE)\.md$|^docs$|^skills$|^memory$|^claude$' + # Anything else at workspace root is fair game + if [[ -d "$WORKSPACE" ]]; then + cd "$WORKSPACE" + for entry in *; do + [[ "$entry" == "*" ]] && break # empty dir + if echo "$entry" | grep -qE "$KEEP_PATTERN"; then + continue + fi + run "rm -rf \"$WORKSPACE/$entry\"" + done + cd - >/dev/null + fi +} + +# ------------------------------------------------------------------------- +# Step 6 - wipe media folders +# ------------------------------------------------------------------------- + +step_6_wipe_media() { + log "==== Step 6: wipe inbound/outbound media ====" + run "rm -rf \"$OPENCLAW_HOME/media/inbound\"/* 2>/dev/null || true" + run "rm -rf \"$OPENCLAW_HOME/media/outbound\"/* 2>/dev/null || true" +} + +# ------------------------------------------------------------------------- +# Step 7 - wipe sessions / runs / state +# ------------------------------------------------------------------------- + +step_7_wipe_sessions() { + log "==== Step 7: wipe openclaw sessions and runs ====" + run "rm -rf \"$OPENCLAW_HOME/sessions\"/* 2>/dev/null || true" + run "rm -rf \"$OPENCLAW_HOME/runs\"/* 2>/dev/null || true" + run "rm -f \"$OPENCLAW_HOME/state.json\" 2>/dev/null || true" +} + +# ------------------------------------------------------------------------- +# Step 8 - wipe cron state +# ------------------------------------------------------------------------- + +step_8_wipe_cron() { + log "==== Step 8: wipe openclaw cron state ====" + run "rm -f \"$OPENCLAW_HOME/cron/state.json\" 2>/dev/null || true" + run "rm -rf \"$OPENCLAW_HOME/cron/runs\" 2>/dev/null || true" +} + +# ------------------------------------------------------------------------- +# Step 9 - sanitize openclaw.json (strip tokens, keep schema) +# ------------------------------------------------------------------------- + +step_9_sanitize_openclaw_config() { + log "==== Step 9: sanitize openclaw config ====" + local cfg="$OPENCLAW_HOME/openclaw.json" + if [[ -f "$cfg" ]]; then + if [[ "$DRY_RUN" -eq 1 ]]; then + log "(would sanitize $cfg via python3)" + else + python3 - "$cfg" <<'PYEOF' +import json, sys, copy +path = sys.argv[1] +with open(path) as f: + cfg = json.load(f) + +SENSITIVE_VALUE_KEYS = { + "apiKey", "token", "botToken", "accessToken", "refreshToken", + "secret", "clientSecret", "webhookSecret", "appPassword", + "password", "credentials" +} + +def scrub(obj, path_str=""): + if isinstance(obj, dict): + for k in list(obj.keys()): + full = f"{path_str}.{k}" if path_str else k + if k in SENSITIVE_VALUE_KEYS: + # Wipe value; keep key for schema clarity + if isinstance(obj[k], dict): + scrub(obj[k], full) + else: + obj[k] = "" + else: + scrub(obj[k], full) + elif isinstance(obj, list): + for item in obj: + scrub(item, path_str) + +scrub(cfg) + +# Also clear accounts.*.credentials by-name +accts = cfg.get("messages", {}).get("accounts", []) +for acct in accts if isinstance(accts, list) else []: + if isinstance(acct, dict) and "credentials" in acct: + if isinstance(acct["credentials"], dict): + for k in list(acct["credentials"].keys()): + acct["credentials"][k] = "" + +with open(path, "w") as f: + json.dump(cfg, f, indent=2) + +print(f"sanitized {path}", file=sys.stderr) +PYEOF + fi + fi +} + +# ------------------------------------------------------------------------- +# Step 10 - clear any .env / .env.local files inside app subtrees +# ------------------------------------------------------------------------- + +step_10_clear_env_files() { + log "==== Step 10: clear app .env files ====" + # Find .env.local / .env files in tula/apps/* and truncate + if [[ -d "$TULA_HOME/apps" ]]; then + while IFS= read -r f; do + run "truncate -s 0 \"$f\"" + done < <(find "$TULA_HOME/apps" -maxdepth 3 -name '.env.local' -o -name '.env' 2>/dev/null) + fi +} + +# ------------------------------------------------------------------------- +# Step 11 - preserved (no-op; sensible openclaw defaults stay in image) +# ------------------------------------------------------------------------- + +# Step 12 - clear aria-backup local state +step_12_clear_aria_backup() { + log "==== Step 12: clear aria-backup local clone ====" + if [[ -d "$AGENT_REPO" ]]; then + run "rm -rf \"$AGENT_REPO\"" + fi +} + +# ------------------------------------------------------------------------- +# Step 13 - clear coding-agent state (preserve global config) +# ------------------------------------------------------------------------- + +step_13_clear_coding_agents() { + log "==== Step 13: clear coding-agent state ====" + # Claude: keep settings.json (the global model config), nuke any per-conversation data + run "rm -rf \"$TARGET_HOME/.claude/projects\" 2>/dev/null || true" + run "rm -rf \"$TARGET_HOME/.claude/conversations\" 2>/dev/null || true" + run "rm -rf \"$TARGET_HOME/.claude/sessions\" 2>/dev/null || true" + run "rm -rf \"$TARGET_HOME/.claude/shell-snapshots\" 2>/dev/null || true" + # Codex + run "rm -rf \"$TARGET_HOME/.codex/sessions\" 2>/dev/null || true" + run "rm -rf \"$TARGET_HOME/.codex/conversations\" 2>/dev/null || true" + # OpenCode (if present) + run "rm -rf \"$TARGET_HOME/.opencode\" 2>/dev/null || true" +} + +# ------------------------------------------------------------------------- +# Step 14 - clear shell history +# ------------------------------------------------------------------------- + +step_14_clear_shell_history() { + log "==== Step 14: clear shell history ====" + for f in .bash_history .zsh_history .python_history .lesshst .wget-hsts .node_repl_history; do + run "rm -f \"$TARGET_HOME/$f\" 2>/dev/null || true" + done + # Also for root, in case any sudo commands left a trail + for f in .bash_history .python_history; do + run "rm -f \"/root/$f\" 2>/dev/null || true" + done +} + +# ------------------------------------------------------------------------- +# Step 15 - rotate and vacuum logs +# ------------------------------------------------------------------------- + +step_15_clear_logs() { + log "==== Step 15: rotate and vacuum journals and logs ====" + run "journalctl --rotate" + run "journalctl --vacuum-time=1s" + # Truncate (not delete - keeps logrotate happy) common log files + for f in /var/log/auth.log /var/log/syslog /var/log/dpkg.log /var/log/apt/history.log /var/log/apt/term.log /var/log/cloud-init.log /var/log/cloud-init-output.log; do + if [[ -f "$f" ]]; then + run "truncate -s 0 \"$f\"" + fi + done + # Specifically nuke any tula-deprovision logs except this one + for f in /var/log/tula-deprovision-*.log; do + [[ "$f" == "$LOG_FILE" ]] && continue + [[ -f "$f" ]] && run "rm -f \"$f\"" + done +} + +# ------------------------------------------------------------------------- +# Step 16 - clear machine-id (waagent will regenerate) +# ------------------------------------------------------------------------- + +step_16_clear_machine_id() { + log "==== Step 16: clear machine-id ====" + run "truncate -s 0 /etc/machine-id" + run "rm -f /var/lib/dbus/machine-id" + run "ln -sf /etc/machine-id /var/lib/dbus/machine-id" +} + +# ------------------------------------------------------------------------- +# Step 17 - remove SSH host keys (waagent regenerates) +# ------------------------------------------------------------------------- + +step_17_remove_ssh_host_keys() { + log "==== Step 17: remove SSH host keys ====" + run "rm -f /etc/ssh/ssh_host_*" +} + +# ------------------------------------------------------------------------- +# Step 18 - clear ops authorized_keys +# ------------------------------------------------------------------------- + +step_18_clear_authorized_keys() { + log "==== Step 18: clear ops authorized_keys (cloud-init re-injects) ====" + run "truncate -s 0 \"$TARGET_HOME/.ssh/authorized_keys\"" +} + +# ------------------------------------------------------------------------- +# Step 19 - apt clean +# ------------------------------------------------------------------------- + +step_19_apt_clean() { + log "==== Step 19: apt clean ====" + run "apt-get clean" +} + +# ------------------------------------------------------------------------- +# Step 20 - clear /tmp and /var/tmp +# ------------------------------------------------------------------------- + +step_20_clear_tmp() { + log "==== Step 20: clear /tmp and /var/tmp ====" + run "find /tmp -mindepth 1 -delete 2>/dev/null || true" + run "find /var/tmp -mindepth 1 -delete 2>/dev/null || true" +} + +# ------------------------------------------------------------------------- +# Step 22 - final secret scan +# ------------------------------------------------------------------------- + +step_22_secret_scan() { + log "==== Step 22: final secret-pattern scan ====" + # Patterns that should never appear in a baked image + # NB: kept reasonably tight to avoid false positives in code samples + local patterns=( + 'sk-ant-[A-Za-z0-9_-]{20,}' # Anthropic + 'sk-proj-[A-Za-z0-9_-]{20,}' # OpenAI project-scoped + 'xai-[A-Za-z0-9]{20,}' # xAI + 'elabs[-_][A-Za-z0-9_-]{20,}' # ElevenLabs (best-effort) + 'ghp_[A-Za-z0-9]{36}' # GitHub classic PAT + 'github_pat_[A-Za-z0-9_]{60,}' # GitHub fine-grained PAT + '[0-9]{9,10}:AAH[A-Za-z0-9_-]{30,}' # Telegram bot token + 'AKIA[0-9A-Z]{16}' # AWS access key + ) + local found=0 + for pat in "${patterns[@]}"; do + # Scan workspace, tula source tree, openclaw config, root home - fast paths + local hits + hits=$(grep -rIE "$pat" "$WORKSPACE" "$TULA_HOME" "$OPENCLAW_HOME" 2>/dev/null | grep -vE '\.git/|node_modules/|\.next/' | head -20 || true) + if [[ -n "$hits" ]]; then + log "POSSIBLE SECRET LEAK matching pattern: $pat" + echo "$hits" | head -5 + found=1 + fi + done + if [[ "$found" -eq 1 ]]; then + log "ERROR: secret-scan FAILED - review above and re-run after cleanup" + return 1 + else + log "secret-scan: clean" + return 0 + fi +} + +# ------------------------------------------------------------------------- +# Step 23 - stamp the image +# ------------------------------------------------------------------------- + +step_23_stamp_image() { + log "==== Step 23: stamp image with provenance ====" + local stamp_file="/etc/tula-template-version" + local tula_git_sha="unknown" + local tula_git_tag="unknown" + if [[ -d "$TULA_HOME/.git" ]]; then + tula_git_sha=$(sudo -u "$TARGET_USER" git -C "$TULA_HOME" rev-parse HEAD 2>/dev/null || echo unknown) + tula_git_tag=$(sudo -u "$TARGET_USER" git -C "$TULA_HOME" describe --tags --always 2>/dev/null || echo unknown) + fi + local openclaw_ver="unknown" + if command -v openclaw >/dev/null 2>&1; then + openclaw_ver=$(openclaw --version 2>/dev/null | head -1 || echo unknown) + fi + local node_ver + node_ver=$(node --version 2>/dev/null || echo unknown) + + if [[ "$DRY_RUN" -eq 1 ]]; then + log "(would write $stamp_file with version=$TEMPLATE_VERSION, tula-git-sha=$tula_git_sha)" + return + fi + + cat > "$stamp_file" < -n $CURRENT_HOSTNAME" + log " az vm generalize -g -n $CURRENT_HOSTNAME" + log " az image create -g -n tula-tenant-template- --source $CURRENT_HOSTNAME" + log "" + log "This host is now in a generalize-ready state. DO NOT reboot it" + log "before capture, or you will need to re-run this script." + log "===================================================================" +} + +main "$@" diff --git a/scripts/tenant-template/tula-provision.sh b/scripts/tenant-template/tula-provision.sh new file mode 100755 index 0000000..920a550 --- /dev/null +++ b/scripts/tenant-template/tula-provision.sh @@ -0,0 +1,689 @@ +#!/usr/bin/env bash +# +# tula-provision.sh - provision a Tula tenant from a baked managed image. +# +# Runs on the operator's laptop (or a control-plane VM). Requires: +# - az CLI authenticated to the subscription +# - gh CLI authenticated to the realactivity org with repo:create permission +# - jq, openssl, xxd +# - a bot-token-pool file at $TULA_OPS_HOME/bot-token-pool.txt +# - a tenants directory at $TULA_OPS_HOME/tenants/ +# - operator SSH pubkey at $OPERATOR_SSH_PUBKEY +# +# Subcommands: +# new-tenant Provision a new tenant end-to-end. +# list List all tenants. +# show Show one tenant's record. +# health Run health checks against a live tenant. +# rollback Tear down a tenant (Azure, GitHub, bot pool). +# decommission Begin tenant offboarding (30-day grace). +# +# License: Apache-2.0 (inherited from the Tula repository). +# +# See companion spec: ~/.openclaw/workspace/docs/TENANT_TEMPLATE_BUILD.md +# + +set -euo pipefail + +# ------------------------------------------------------------------------- +# Configuration +# ------------------------------------------------------------------------- + +TULA_OPS_HOME="${TULA_OPS_HOME:-$HOME/tula-ops}" +TENANTS_DIR="$TULA_OPS_HOME/tenants" +BOT_POOL_FILE="$TULA_OPS_HOME/bot-token-pool.txt" +SECRETS_DIR="$TULA_OPS_HOME/secrets" + +AZURE_LOCATION="${AZURE_LOCATION:-eastus2}" +AZURE_VNET="${AZURE_VNET:-tula-tenants-vnet}" +AZURE_SUBNET="${AZURE_SUBNET:-tula-tenants-subnet}" +AZURE_VM_SIZE="${AZURE_VM_SIZE:-Standard_B2s}" +AZURE_OS_DISK_SIZE_GB="${AZURE_OS_DISK_SIZE_GB:-64}" +AZURE_OS_DISK_SKU="${AZURE_OS_DISK_SKU:-StandardSSD_LRS}" +DEFAULT_IMAGE="${DEFAULT_IMAGE:-tula-tenant-template-0-1-0}" +IMAGE_RESOURCE_GROUP="${IMAGE_RESOURCE_GROUP:-ra-healthcareagents-rg}" + +GH_ORG="${GH_ORG:-realactivity}" + +OPERATOR_SSH_PUBKEY="${OPERATOR_SSH_PUBKEY:-$HOME/.ssh/id_ed25519.pub}" + +TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# ------------------------------------------------------------------------- +# Logging helpers +# ------------------------------------------------------------------------- + +color() { + if [[ -t 1 ]]; then + case "$1" in + red) printf '\033[31m%s\033[0m' "$2" ;; + green) printf '\033[32m%s\033[0m' "$2" ;; + yellow) printf '\033[33m%s\033[0m' "$2" ;; + blue) printf '\033[34m%s\033[0m' "$2" ;; + *) printf '%s' "$2" ;; + esac + else + printf '%s' "$2" + fi +} + +info() { echo "$(color blue '[INFO]') $*"; } +ok() { echo "$(color green '[OK]') $*"; } +warn() { echo "$(color yellow '[WARN]') $*"; } +err() { echo "$(color red '[ERR]') $*" >&2; } + +# ------------------------------------------------------------------------- +# Pre-flight +# ------------------------------------------------------------------------- + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + err "missing required command: $1" + exit 1 + fi +} + +preflight() { + require_cmd az + require_cmd gh + require_cmd jq + require_cmd openssl + require_cmd xxd + + if [[ ! -d "$TULA_OPS_HOME" ]]; then + warn "ops home $TULA_OPS_HOME does not exist; creating" + mkdir -p "$TENANTS_DIR" "$SECRETS_DIR" + chmod 700 "$TULA_OPS_HOME" "$SECRETS_DIR" + fi + + if [[ ! -f "$BOT_POOL_FILE" ]]; then + warn "bot pool file $BOT_POOL_FILE does not exist; creating empty" + touch "$BOT_POOL_FILE" + chmod 600 "$BOT_POOL_FILE" + fi + + if [[ ! -f "$OPERATOR_SSH_PUBKEY" ]]; then + err "operator SSH pubkey not found at $OPERATOR_SSH_PUBKEY" + err "set OPERATOR_SSH_PUBKEY env var to your public key path" + exit 1 + fi + + if ! az account show >/dev/null 2>&1; then + err "not logged in to az CLI. Run: az login" + exit 1 + fi + + if ! gh auth status >/dev/null 2>&1; then + err "not logged in to gh CLI. Run: gh auth login" + exit 1 + fi +} + +# ------------------------------------------------------------------------- +# Tenant-id helpers +# ------------------------------------------------------------------------- + +new_tenant_id() { + # 12-hex random; collision-resistant for >10^6 tenants + xxd -l 6 -p /dev/urandom +} + +is_valid_tenant_id() { + [[ "$1" =~ ^[a-f0-9]{12}$ ]] +} + +tenant_record_path() { + echo "$TENANTS_DIR/$1.json" +} + +# ------------------------------------------------------------------------- +# Bot-token pool helpers (with file lock for concurrent ops) +# ------------------------------------------------------------------------- + +claim_bot_token() { + local tenant_id="$1" + local lock_file="$BOT_POOL_FILE.lock" + exec 200>"$lock_file" + if ! flock -x -w 10 200; then + err "failed to acquire bot pool lock" + exit 1 + fi + # Find the first available bot + local line claimed + while IFS=$' \t' read -r pool_name bot_token bot_username status; do + [[ "$pool_name" =~ ^# ]] && continue + [[ -z "$pool_name" ]] && continue + if [[ "$status" == "available" ]]; then + claimed="$pool_name $bot_token $bot_username" + # Update the line: mark as claimed + sed -i "s|^$pool_name $bot_token $bot_username available|$pool_name $bot_token $bot_username claimed-by-$tenant_id|" "$BOT_POOL_FILE" + break + fi + done < "$BOT_POOL_FILE" + flock -u 200 + rm -f "$lock_file" + if [[ -z "${claimed:-}" ]]; then + err "no available bot tokens in pool. Create more bots via @BotFather and add to $BOT_POOL_FILE" + exit 1 + fi + echo "$claimed" +} + +release_bot_token() { + local tenant_id="$1" + local lock_file="$BOT_POOL_FILE.lock" + exec 200>"$lock_file" + flock -x -w 10 200 || true + sed -i "s|claimed-by-$tenant_id\$|available|" "$BOT_POOL_FILE" + flock -u 200 + rm -f "$lock_file" +} + +# ------------------------------------------------------------------------- +# Secret helpers +# ------------------------------------------------------------------------- + +load_secret() { + local name="$1" + local f="$SECRETS_DIR/$name" + if [[ ! -f "$f" ]]; then + err "missing secret: $f" + err "create with: echo -n 'value' > $f; chmod 600 $f" + exit 1 + fi + cat "$f" +} + +# ------------------------------------------------------------------------- +# Subcommand: new-tenant +# ------------------------------------------------------------------------- + +cmd_new_tenant() { + local tenant_name="${1:-}" + local tenant_email="${2:-}" + local image="${3:-$DEFAULT_IMAGE}" + + if [[ -z "$tenant_name" || -z "$tenant_email" ]]; then + err "usage: tula-provision new-tenant [image-name]" + exit 2 + fi + + preflight + info "=== Provisioning new tenant ===" + info " display name: $tenant_name" + info " email: $tenant_email" + info " image: $image" + echo + + # Step 1 - Generate tenant-id + local tenant_id + tenant_id=$(new_tenant_id) + info "Step 1: tenant-id = $tenant_id" + + # Resource names + local rg="tula-tenant-$tenant_id" + local vm="tula-tenant-$tenant_id" + local backup_repo="$GH_ORG/tula-vm-state-$tenant_id" + + # Pre-create tenant record so we can roll back even on early failure + local tenant_record + tenant_record="$(tenant_record_path "$tenant_id")" + cat > "$tenant_record" < "$tenant_record.tmp" + mv "$tenant_record.tmp" "$tenant_record" + + # Step 3 - Create resource group + info "Step 3: creating Azure resource group $rg" + az group create \ + --name "$rg" \ + --location "$AZURE_LOCATION" \ + --tags purpose=tula-tenant tenant-id="$tenant_id" \ + created-at="$TIMESTAMP" \ + email-hash="$(echo -n "$tenant_email" | sha256sum | awk '{print $1}')" \ + --output none + ok " resource group created" + + # Step 4 - Create GitHub backup repo + info "Step 4: creating GitHub backup repo $backup_repo" + gh repo create "$backup_repo" \ + --private \ + --description "Tula tenant backup ($tenant_id) - encrypted state, do not modify" \ + --confirm 2>/dev/null || gh repo create "$backup_repo" --private \ + --description "Tula tenant backup ($tenant_id) - encrypted state, do not modify" + ok " backup repo created" + + # Generate a fine-grained PAT for the tenant VM to push to its single repo + # NOTE: gh CLI as of 2026 does not yet directly support fine-grained PAT + # creation; we use a classic PAT scoped to that single repo via a token- + # helper script. In an operator deployment, prefer GitHub Apps with a + # per-tenant installation. For v0.1 we use a manually-pre-created PAT + # bundled in $SECRETS_DIR/github-pat-template, then attribute the repo + # access via the bot. This is a known gap to harden in v0.2. + local github_pat + github_pat=$(load_secret github-pat-tenant-write) + ok " github PAT loaded (caller-supplied; rotate per tenant in v0.2)" + + # Step 5 - Load LLM provider keys + info "Step 5: loading LLM provider keys" + local anthropic_key + anthropic_key=$(load_secret anthropic-api-key) + local xai_key="" + if [[ -f "$SECRETS_DIR/xai-api-key" ]]; then + xai_key=$(load_secret xai-api-key) + fi + ok " provider keys loaded" + + # Step 6 - Render cloud-init userdata + info "Step 6: rendering cloud-init userdata" + local cloud_init_template + cloud_init_template="$(dirname "$0")/cloud-init-template.yaml" + if [[ ! -f "$cloud_init_template" ]]; then + err "cloud-init template not found at $cloud_init_template" + exit 1 + fi + local cloud_init_rendered + cloud_init_rendered=$(mktemp) + chmod 600 "$cloud_init_rendered" + local pubkey_content + pubkey_content=$(cat "$OPERATOR_SSH_PUBKEY") + + # Render template with substitutions. Use a Python heredoc for safe YAML + # string injection (avoids sed escaping foot-guns with special chars). + python3 - < "$cloud_init_rendered" +import os, sys +with open("$cloud_init_template") as f: + tmpl = f.read() +subs = { + "TENANT_ID": "$tenant_id", + "TENANT_DISPLAY_NAME": """$tenant_name""", + "TENANT_EMAIL": "$tenant_email", + "TELEGRAM_BOT_TOKEN": "$bot_token", + "TELEGRAM_BOT_USERNAME":"$bot_username", + "ANTHROPIC_API_KEY": """$anthropic_key""", + "XAI_API_KEY": """$xai_key""", + "GITHUB_PAT": """$github_pat""", + "BACKUP_REPO_URL": "https://github.com/$backup_repo.git", + "OPERATOR_SSH_PUBKEY": """$pubkey_content""", + "TIMESTAMP": "$TIMESTAMP", +} +for k, v in subs.items(): + tmpl = tmpl.replace("{{" + k + "}}", v) +sys.stdout.write(tmpl) +PYEOF + ok " cloud-init rendered ($(wc -l < "$cloud_init_rendered") lines)" + + # Step 7 - Create the VM from the image + info "Step 7: creating VM $vm from image $image" + local image_id + image_id=$(az image show \ + --resource-group "$IMAGE_RESOURCE_GROUP" \ + --name "$image" \ + --query id -o tsv 2>/dev/null) || true + if [[ -z "$image_id" ]]; then + err "image $image not found in $IMAGE_RESOURCE_GROUP" + exit 1 + fi + + az vm create \ + --resource-group "$rg" \ + --name "$vm" \ + --image "$image_id" \ + --size "$AZURE_VM_SIZE" \ + --os-disk-size-gb "$AZURE_OS_DISK_SIZE_GB" \ + --storage-sku os="$AZURE_OS_DISK_SKU" \ + --vnet-name "$AZURE_VNET" \ + --subnet "$AZURE_SUBNET" \ + --public-ip-address "" \ + --admin-username azureuser \ + --ssh-key-values "$pubkey_content" \ + --custom-data "$cloud_init_rendered" \ + --assign-identity \ + --tags purpose=tula-tenant tenant-id="$tenant_id" image="$image" \ + --output none + ok " VM created" + + # Step 8 - Wait for cloud-init to report success + info "Step 8: waiting for cloud-init success (timeout 10 min)..." + local start_time deadline + start_time=$(date +%s) + deadline=$((start_time + 600)) + local private_ip + private_ip=$(az vm show -g "$rg" -n "$vm" -d --query privateIps -o tsv) + info " private IP: $private_ip" + info " polling /etc/tula-ready every 15s..." + + while true; do + if [[ $(date +%s) -gt $deadline ]]; then + err "cloud-init timeout" + exit 1 + fi + local result + result=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + -o UserKnownHostsFile=/dev/null \ + "azureuser@$private_ip" \ + 'cat /etc/tula-ready 2>/dev/null || echo not-ready' 2>/dev/null || echo unreachable) + case "$result" in + ready) ok " cloud-init complete"; break ;; + failed:*) err "cloud-init failed: $result"; exit 1 ;; + *) sleep 15 ;; + esac + done + + # Step 9 - Smoke test: ask the bot for status + # (operator runs this via a separate /healthz path on the VM; for v0.1 + # we trust cloud-init's own /etc/tula-ready and do not attempt to + # round-trip Telegram from the operator side, since that requires a + # tenant chat-id we don't have until the patient first messages the bot) + info "Step 9: skipping Telegram round-trip smoke test (no tenant chat-id yet)" + + # Step 10 - Finalize tenant record + info "Step 10: finalizing tenant record" + jq --arg ts "$TIMESTAMP" --arg pip "$private_ip" \ + '.status = "active" | .activated_at = $ts | .azure.private_ip = $pip' \ + "$tenant_record" > "$tenant_record.tmp" + mv "$tenant_record.tmp" "$tenant_record" + + # Step 11 - Onboarding link + local onboarding_token + onboarding_token=$(openssl rand -hex 16) + jq --arg ot "$onboarding_token" '.onboarding_token = $ot' \ + "$tenant_record" > "$tenant_record.tmp" + mv "$tenant_record.tmp" "$tenant_record" + local onboarding_link="https://t.me/$bot_username?start=$onboarding_token" + + # Disable rollback trap - success + rollback_needed=0 + trap - EXIT + + # Step 12 - Operator output + echo + ok "===================================================================" + ok "Tenant provisioned successfully." + ok "" + ok " Tenant ID: $tenant_id" + ok " Display name: $tenant_name" + ok " Email: $tenant_email" + ok " Azure VM: $vm in $rg ($private_ip)" + ok " Backup repo: https://github.com/$backup_repo" + ok " Telegram bot: @$bot_username" + ok " Onboarding link: $onboarding_link" + ok "" + ok "Tenant record: $tenant_record" + ok "===================================================================" + ok "" + ok "Send the onboarding link to $tenant_email and they're live." + + rm -f "$cloud_init_rendered" +} + +# ------------------------------------------------------------------------- +# Subcommand: list +# ------------------------------------------------------------------------- + +cmd_list() { + preflight + if [[ ! -d "$TENANTS_DIR" ]] || [[ -z "$(ls -A "$TENANTS_DIR" 2>/dev/null)" ]]; then + info "(no tenants)" + return + fi + printf "%-14s %-30s %-10s %-25s\n" "TENANT-ID" "DISPLAY-NAME" "STATUS" "PROVISIONED-AT" + printf "%-14s %-30s %-10s %-25s\n" "$(printf '%0.s-' {1..14})" "$(printf '%0.s-' {1..30})" "$(printf '%0.s-' {1..10})" "$(printf '%0.s-' {1..25})" + for f in "$TENANTS_DIR"/*.json; do + [[ -f "$f" ]] || continue + local tid dn status pa + tid=$(jq -r '.tenant_id' "$f") + dn=$(jq -r '.display_name' "$f") + status=$(jq -r '.status' "$f") + pa=$(jq -r '.provisioned_at' "$f") + printf "%-14s %-30s %-10s %-25s\n" "$tid" "$dn" "$status" "$pa" + done +} + +# ------------------------------------------------------------------------- +# Subcommand: show +# ------------------------------------------------------------------------- + +cmd_show() { + local tenant_id="${1:-}" + [[ -z "$tenant_id" ]] && { err "usage: tula-provision show "; exit 2; } + is_valid_tenant_id "$tenant_id" || { err "invalid tenant-id"; exit 2; } + local f + f=$(tenant_record_path "$tenant_id") + [[ ! -f "$f" ]] && { err "no such tenant"; exit 2; } + jq . "$f" +} + +# ------------------------------------------------------------------------- +# Subcommand: rollback (idempotent cleanup) +# ------------------------------------------------------------------------- + +cmd_rollback() { + local tenant_id="${1:-}" + [[ -z "$tenant_id" ]] && { err "usage: tula-provision rollback "; exit 2; } + is_valid_tenant_id "$tenant_id" || { err "invalid tenant-id"; exit 2; } + preflight + info "rolling back tenant $tenant_id (idempotent)" + + local rg="tula-tenant-$tenant_id" + local backup_repo="$GH_ORG/tula-vm-state-$tenant_id" + + # Delete VM + RG + if az group show -n "$rg" >/dev/null 2>&1; then + info " deleting resource group $rg (background)" + az group delete --name "$rg" --yes --no-wait + ok " rg delete initiated" + else + info " resource group $rg not found; skipping" + fi + + # Delete GitHub repo + if gh repo view "$backup_repo" >/dev/null 2>&1; then + info " deleting GitHub repo $backup_repo" + gh repo delete "$backup_repo" --yes 2>/dev/null || \ + warn " could not delete repo $backup_repo (may need --confirm interactively)" + else + info " github repo $backup_repo not found; skipping" + fi + + # Release bot token back to pool + release_bot_token "$tenant_id" + ok " bot token released" + + # Update / remove tenant record + local f + f=$(tenant_record_path "$tenant_id") + if [[ -f "$f" ]]; then + jq --arg ts "$TIMESTAMP" '.status = "rolled-back" | .rolled_back_at = $ts' "$f" > "$f.tmp" + mv "$f.tmp" "$f" + ok " tenant record updated to rolled-back" + fi + + ok "rollback complete" +} + +# ------------------------------------------------------------------------- +# Subcommand: health +# ------------------------------------------------------------------------- + +cmd_health() { + local tenant_id="${1:-}" + [[ -z "$tenant_id" ]] && { err "usage: tula-provision health "; exit 2; } + is_valid_tenant_id "$tenant_id" || { err "invalid tenant-id"; exit 2; } + preflight + local f + f=$(tenant_record_path "$tenant_id") + [[ ! -f "$f" ]] && { err "no such tenant"; exit 2; } + + local rg vm private_ip backup_repo + rg=$(jq -r '.azure.resource_group' "$f") + vm=$(jq -r '.azure.vm_name' "$f") + private_ip=$(jq -r '.azure.private_ip' "$f") + backup_repo=$(jq -r '.github.backup_repo' "$f") + + info "=== Health check: $tenant_id ===" + # VM state + local vm_state + vm_state=$(az vm get-instance-view -g "$rg" -n "$vm" \ + --query 'instanceView.statuses[?starts_with(code, `PowerState/`)].displayStatus' -o tsv 2>/dev/null || echo "unknown") + info " VM state: $vm_state" + + # SSH reachable + if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + -o UserKnownHostsFile=/dev/null \ + "azureuser@$private_ip" 'echo ok' >/dev/null 2>&1; then + ok " SSH reachable" + else + warn " SSH NOT reachable" + fi + + # Last backup + local last_commit + last_commit=$(gh api "repos/$backup_repo/commits?per_page=1" --jq '.[0].commit.committer.date' 2>/dev/null || echo none) + info " last backup: $last_commit" + + # Disk free + local disk_free + disk_free=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + -o UserKnownHostsFile=/dev/null \ + "azureuser@$private_ip" "df -h / | tail -1 | awk '{print \$4 \" (\" \$5 \" used)\"}'" 2>/dev/null || echo unknown) + info " disk free: $disk_free" + + # Openclaw service running + local gw_status + gw_status=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + -o UserKnownHostsFile=/dev/null \ + "azureuser@$private_ip" 'systemctl is-active openclaw-gateway.service' 2>/dev/null || echo inactive) + info " openclaw-gw: $gw_status" + + # Tula version on the VM + local tula_ver + tula_ver=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + -o UserKnownHostsFile=/dev/null \ + "azureuser@$private_ip" 'cat /etc/tula-template-version 2>/dev/null | grep ^version= | cut -d= -f2' 2>/dev/null || echo unknown) + info " template ver: $tula_ver" +} + +# ------------------------------------------------------------------------- +# Subcommand: decommission +# ------------------------------------------------------------------------- + +cmd_decommission() { + local tenant_id="${1:-}" + [[ -z "$tenant_id" ]] && { err "usage: tula-provision decommission "; exit 2; } + is_valid_tenant_id "$tenant_id" || { err "invalid tenant-id"; exit 2; } + preflight + local f + f=$(tenant_record_path "$tenant_id") + [[ ! -f "$f" ]] && { err "no such tenant"; exit 2; } + + info "=== Decommission begin: $tenant_id ===" + local rg vm + rg=$(jq -r '.azure.resource_group' "$f") + vm=$(jq -r '.azure.vm_name' "$f") + + # Phase 1 - stop the VM (deallocate; keeps disk for the grace period) + info " deallocating VM (30-day grace)" + az vm deallocate -g "$rg" -n "$vm" --no-wait + + # Mark in record + jq --arg ts "$TIMESTAMP" '.status = "decommission-requested" | .decommission_requested_at = $ts' \ + "$f" > "$f.tmp" + mv "$f.tmp" "$f" + + ok " tenant deallocated. 30-day grace started." + ok " to complete: tula-provision rollback $tenant_id (after $TIMESTAMP + 30d)" + warn " recommended: export the tenant record before final rollback" +} + +# ------------------------------------------------------------------------- +# Main dispatch +# ------------------------------------------------------------------------- + +usage() { + cat < [image-name] + Provision a new tenant + $0 list List all tenants + $0 show Show one tenant's record + $0 health Health check a live tenant + $0 rollback Tear down a tenant (idempotent) + $0 decommission Begin offboarding (30-day grace) + $0 -h | --help This help + +Environment overrides: + TULA_OPS_HOME Operator state directory (default \$HOME/tula-ops) + AZURE_LOCATION Default eastus2 + AZURE_VNET Default tula-tenants-vnet + AZURE_SUBNET Default tula-tenants-subnet + AZURE_VM_SIZE Default Standard_B2s + DEFAULT_IMAGE Default tula-tenant-template-0-1-0 + IMAGE_RESOURCE_GROUP Default ra-healthcareagents-rg + GH_ORG Default realactivity + OPERATOR_SSH_PUBKEY Default \$HOME/.ssh/id_ed25519.pub + +See companion spec at ~/.openclaw/workspace/docs/TENANT_TEMPLATE_BUILD.md +EOF +} + +main() { + local sub="${1:-}"; shift || true + case "$sub" in + new-tenant) cmd_new_tenant "$@" ;; + list) cmd_list "$@" ;; + show) cmd_show "$@" ;; + rollback) cmd_rollback "$@" ;; + health) cmd_health "$@" ;; + decommission) cmd_decommission "$@" ;; + -h|--help|"") usage ;; + *) err "unknown subcommand: $sub"; usage; exit 2 ;; + esac +} + +main "$@" diff --git a/skills/prep-my-visit/references/examples.md b/skills/prep-my-visit/references/examples.md index e47eeb7..7270d27 100644 --- a/skills/prep-my-visit/references/examples.md +++ b/skills/prep-my-visit/references/examples.md @@ -1,5 +1,19 @@ # Examples +Reference fixtures live alongside this file in the `examples/` subdirectory. +Each scenario ships as two JSON files: the IPS Composition Bundle and the +lab-opportunities payload. They drive the PDF renderer smoke tests and serve +as seed data for the eval suite. + +| Scenario | Bundle | Labs | +|---|---|---| +| Cardiology follow-up | [`examples/cardiology-followup.json`](examples/cardiology-followup.json) | [`examples/cardiology-followup.labs.json`](examples/cardiology-followup.labs.json) | +| PCP annual | [`examples/pcp-annual.json`](examples/pcp-annual.json) | [`examples/pcp-annual.labs.json`](examples/pcp-annual.labs.json) | +| Urgent same-day | [`examples/urgent-same-day.json`](examples/urgent-same-day.json) | [`examples/urgent-same-day.labs.json`](examples/urgent-same-day.labs.json) | + +All patients in these fixtures are synthetic (Robert Johnson, Maria Chen, +Alex Smith); clinicians and tenant IDs are likewise fictional. + ## Example 1: Cardiology follow-up with standing order User prompt: diff --git a/skills/prep-my-visit/references/examples/cardiology-followup.json b/skills/prep-my-visit/references/examples/cardiology-followup.json new file mode 100644 index 0000000..1dc0965 --- /dev/null +++ b/skills/prep-my-visit/references/examples/cardiology-followup.json @@ -0,0 +1,220 @@ +{ + "resourceType": "Bundle", + "id": "brief-cardiology-followup", + "type": "document", + "timestamp": "2026-06-02T13:00:00Z", + "meta": { "source": "prep-my-visit@1.0", "profile": ["http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips"] }, + "entry": [ + { + "fullUrl": "urn:uuid:comp-1", + "resource": { + "resourceType": "Composition", + "id": "comp-1", + "status": "preliminary", + "type": { "text": "Pre-visit summary" }, + "title": "Pre-Visit Brief - Cardiology Follow-Up", + "date": "2026-06-02T13:00:00Z", + "subject": { "reference": "urn:uuid:patient-1" }, + "encounter": { "reference": "urn:uuid:encounter-1" }, + "author": [{ "reference": "urn:uuid:practitioner-1" }], + "section": [ + { + "title": "Patient Story", + "text": { "status": "additional", "div": "

Robert is a 67-year-old retired teacher with long-standing hypertension and type 2 diabetes who has been working hard on his blood pressure. He walks most mornings but has noticed occasional lightheadedness when he stands up. He wants to leave this visit clear on whether his current medications are still the right ones.

" } + }, + { + "title": "Goals for this visit", + "text": { "status": "additional", "div": "
  • Understand whether my blood pressure is well controlled
  • Find out if I still need to take both blood-pressure pills
  • Ask about the lightheadedness I feel in the mornings
" } + }, + { + "title": "Problem List", + "text": { "status": "generated", "div": "

Hypertension; Type 2 diabetes mellitus; Hyperlipidemia.

" } + }, + { + "title": "Medication Summary", + "text": { "status": "generated", "div": "

Lisinopril, amlodipine, metformin, atorvastatin.

" } + }, + { + "title": "Allergies and Intolerances", + "text": { "status": "generated", "div": "

Sulfamethoxazole - rash (moderate).

" } + }, + { + "title": "Vital Signs", + "text": { "status": "generated", "div": "

BP 138/86 mmHg; HR 72 bpm; Weight 89.8 kg (up from 88.0 kg).

" } + }, + { + "title": "Delta Since Last Visit With This Provider", + "text": { "status": "additional", "div": "
  • Atorvastatin 20 mg daily started 2026-03-10 (new since last visit)
  • LDL cholesterol improved from 142 to 118 mg/dL
  • Home BP readings trended down from the 150s to the high 130s
" } + }, + { + "title": "Top Discussion Items", + "text": { "status": "additional", "div": "
  • Occasional lightheadedness on standing in the morning
  • Whether the evening amlodipine dose can be reduced
" } + } + ] + } + }, + { + "fullUrl": "urn:uuid:patient-1", + "resource": { + "resourceType": "Patient", + "id": "patient-1", + "identifier": [{ "system": "urn:tula:tenant-id", "value": "TULA-100423" }], + "name": [{ "use": "official", "family": "Johnson", "given": ["Robert"], "text": "Robert Johnson" }], + "gender": "male", + "birthDate": "1959-02-11" + } + }, + { + "fullUrl": "urn:uuid:encounter-1", + "resource": { + "resourceType": "Encounter", + "id": "encounter-1", + "status": "planned", + "class": { "code": "AMB", "display": "ambulatory" }, + "type": [{ "text": "Cardiology follow-up" }], + "period": { "start": "2026-06-09T10:30:00" }, + "location": [{ "location": { "display": "Riverside Cardiology, Suite 220" } }] + } + }, + { + "fullUrl": "urn:uuid:practitioner-1", + "resource": { + "resourceType": "Practitioner", + "id": "practitioner-1", + "name": [{ "text": "Dr. Sarah Lin, MD", "family": "Lin", "prefix": ["Dr."] }], + "qualification": [{ "code": { "text": "Cardiology" } }] + } + }, + { + "fullUrl": "urn:uuid:condition-1", + "resource": { + "resourceType": "Condition", + "id": "condition-1", + "clinicalStatus": { "coding": [{ "code": "active" }] }, + "code": { "text": "Essential hypertension" }, + "onsetDateTime": "2014-05-01", + "recordedDate": "2026-03-10" + } + }, + { + "fullUrl": "urn:uuid:condition-2", + "resource": { + "resourceType": "Condition", + "id": "condition-2", + "clinicalStatus": { "coding": [{ "code": "active" }] }, + "code": { "text": "Type 2 diabetes mellitus" }, + "onsetDateTime": "2018-09-01", + "recordedDate": "2026-03-10" + } + }, + { + "fullUrl": "urn:uuid:condition-3", + "resource": { + "resourceType": "Condition", + "id": "condition-3", + "clinicalStatus": { "coding": [{ "code": "active" }] }, + "code": { "text": "Hyperlipidemia" }, + "onsetDateTime": "2020-01-01", + "recordedDate": "2026-03-10" + } + }, + { + "fullUrl": "urn:uuid:medstmt-1", + "resource": { + "resourceType": "MedicationStatement", + "id": "medstmt-1", + "status": "active", + "medicationCodeableConcept": { "text": "Lisinopril 20 mg tablet" }, + "dosage": [{ "text": "20 mg by mouth once daily" }], + "reasonCode": [{ "text": "Hypertension" }], + "effectivePeriod": { "start": "2014-06-01" } + } + }, + { + "fullUrl": "urn:uuid:medstmt-2", + "resource": { + "resourceType": "MedicationStatement", + "id": "medstmt-2", + "status": "active", + "medicationCodeableConcept": { "text": "Amlodipine 10 mg tablet" }, + "dosage": [{ "text": "10 mg by mouth every evening" }], + "reasonCode": [{ "text": "Hypertension" }], + "effectivePeriod": { "start": "2022-02-15" } + } + }, + { + "fullUrl": "urn:uuid:medstmt-3", + "resource": { + "resourceType": "MedicationStatement", + "id": "medstmt-3", + "status": "active", + "medicationCodeableConcept": { "text": "Metformin 1000 mg tablet" }, + "dosage": [{ "text": "1000 mg by mouth twice daily" }], + "reasonCode": [{ "text": "Type 2 diabetes" }], + "effectivePeriod": { "start": "2018-10-01" } + } + }, + { + "fullUrl": "urn:uuid:medstmt-4", + "resource": { + "resourceType": "MedicationStatement", + "id": "medstmt-4", + "status": "active", + "medicationCodeableConcept": { "text": "Atorvastatin 20 mg tablet" }, + "dosage": [{ "text": "20 mg by mouth at bedtime" }], + "reasonCode": [{ "text": "Hyperlipidemia" }], + "effectivePeriod": { "start": "2026-03-10" } + } + }, + { + "fullUrl": "urn:uuid:allergy-1", + "resource": { + "resourceType": "AllergyIntolerance", + "id": "allergy-1", + "clinicalStatus": { "coding": [{ "code": "active" }] }, + "code": { "text": "Sulfamethoxazole" }, + "criticality": "low", + "reaction": [{ "manifestation": [{ "text": "Rash" }], "severity": "moderate" }] + } + }, + { + "fullUrl": "urn:uuid:obs-bp", + "resource": { + "resourceType": "Observation", + "id": "obs-bp", + "status": "final", + "category": [{ "coding": [{ "code": "vital-signs" }] }], + "code": { "text": "Blood pressure" }, + "effectiveDateTime": "2026-05-28", + "component": [ + { "code": { "text": "Systolic" }, "valueQuantity": { "value": 138, "unit": "mmHg" } }, + { "code": { "text": "Diastolic" }, "valueQuantity": { "value": 86, "unit": "mmHg" } } + ] + } + }, + { + "fullUrl": "urn:uuid:obs-hr", + "resource": { + "resourceType": "Observation", + "id": "obs-hr", + "status": "final", + "category": [{ "coding": [{ "code": "vital-signs" }] }], + "code": { "text": "Heart rate" }, + "effectiveDateTime": "2026-05-28", + "valueQuantity": { "value": 72, "unit": "bpm" } + } + }, + { + "fullUrl": "urn:uuid:obs-weight", + "resource": { + "resourceType": "Observation", + "id": "obs-weight", + "status": "final", + "category": [{ "coding": [{ "code": "vital-signs" }] }], + "code": { "text": "Body weight" }, + "effectiveDateTime": "2026-05-28", + "valueQuantity": { "value": 89.8, "unit": "kg" } + } + } + ] +} diff --git a/skills/prep-my-visit/references/examples/cardiology-followup.labs.json b/skills/prep-my-visit/references/examples/cardiology-followup.labs.json new file mode 100644 index 0000000..0428e96 --- /dev/null +++ b/skills/prep-my-visit/references/examples/cardiology-followup.labs.json @@ -0,0 +1,25 @@ +{ + "visitId": "cardiology-followup", + "generatedAt": "2026-06-02T13:00:00Z", + "dtcOptIn": false, + "categoryA": [ + { + "test": "Lipid panel", + "orderingProvider": "Dr. Sarah Lin, MD", + "orderDate": "2026-04-01", + "labContext": "Standing order at Riverside Cardiology lab; no completed result on file", + "action": "complete the standing order before the visit" + } + ], + "categoryB": [ + { + "test": "Hemoglobin A1c", + "rationale": "Last A1c was 7.4% six months ago and several medications affect glucose control.", + "flag": "High", + "guidance": "Ask your doctor whether a repeat A1c before the visit would help guide diabetes management.", + "citation": { "guideline": "American Diabetes Association Standards of Care", "version": "2024" }, + "snippet": "Before my cardiology follow-up, I would like to discuss whether a repeat hemoglobin A1c would be useful. Draft - review and approve before sending." + } + ], + "categoryC": [] +} diff --git a/skills/prep-my-visit/references/examples/pcp-annual.json b/skills/prep-my-visit/references/examples/pcp-annual.json new file mode 100644 index 0000000..61788a7 --- /dev/null +++ b/skills/prep-my-visit/references/examples/pcp-annual.json @@ -0,0 +1,158 @@ +{ + "resourceType": "Bundle", + "id": "brief-pcp-annual", + "type": "document", + "timestamp": "2026-06-02T13:00:00Z", + "meta": { "source": "prep-my-visit@1.0", "profile": ["http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips"] }, + "entry": [ + { + "fullUrl": "urn:uuid:comp-1", + "resource": { + "resourceType": "Composition", + "id": "comp-1", + "status": "preliminary", + "type": { "text": "Pre-visit summary" }, + "title": "Pre-Visit Brief - PCP Annual", + "date": "2026-06-02T13:00:00Z", + "subject": { "reference": "urn:uuid:patient-1" }, + "encounter": { "reference": "urn:uuid:encounter-1" }, + "author": [{ "reference": "urn:uuid:practitioner-1" }], + "section": [ + { + "title": "Patient Story", + "text": { "status": "additional", "div": "

Maria is a 44-year-old software manager coming in for her annual physical. She feels generally well but has been more tired than usual over the past two months and wants to make sure nothing is being missed. She prefers to keep her medication list short and likes to understand the reasoning behind any test.

" } + }, + { + "title": "Goals for this visit", + "text": { "status": "additional", "div": "
  • Review my overall health for the year
  • Talk about why I have been so tired lately
" } + }, + { + "title": "Problem List", + "text": { "status": "generated", "div": "

Hypothyroidism; history of iron-deficiency anemia.

" } + }, + { + "title": "Medication Summary", + "text": { "status": "generated", "div": "

Levothyroxine.

" } + }, + { + "title": "Allergies and Intolerances", + "text": { "status": "generated", "div": "

No known drug allergies.

" } + }, + { + "title": "Vital Signs", + "text": { "status": "generated", "div": "

BP 118/74 mmHg; HR 68 bpm; Weight 63.5 kg.

" } + }, + { + "title": "Top Discussion Items", + "text": { "status": "additional", "div": "
  • Persistent fatigue over the past two months
  • Whether her thyroid dose still fits her symptoms
" } + } + ] + } + }, + { + "fullUrl": "urn:uuid:patient-1", + "resource": { + "resourceType": "Patient", + "id": "patient-1", + "identifier": [{ "system": "urn:tula:tenant-id", "value": "TULA-204815" }], + "name": [{ "use": "official", "family": "Chen", "given": ["Maria"], "text": "Maria Chen" }], + "gender": "female", + "birthDate": "1981-11-23" + } + }, + { + "fullUrl": "urn:uuid:encounter-1", + "resource": { + "resourceType": "Encounter", + "id": "encounter-1", + "status": "planned", + "class": { "code": "AMB", "display": "ambulatory" }, + "type": [{ "text": "PCP annual physical" }], + "period": { "start": "2026-06-15T09:00:00" }, + "location": [{ "location": { "display": "Lakeshore Family Medicine" } }] + } + }, + { + "fullUrl": "urn:uuid:practitioner-1", + "resource": { + "resourceType": "Practitioner", + "id": "practitioner-1", + "name": [{ "text": "Dr. James Patel, DO", "family": "Patel", "prefix": ["Dr."] }], + "qualification": [{ "code": { "text": "Family Medicine" } }] + } + }, + { + "fullUrl": "urn:uuid:condition-1", + "resource": { + "resourceType": "Condition", + "id": "condition-1", + "clinicalStatus": { "coding": [{ "code": "active" }] }, + "code": { "text": "Hypothyroidism" }, + "onsetDateTime": "2016-04-01", + "recordedDate": "2025-06-12" + } + }, + { + "fullUrl": "urn:uuid:condition-2", + "resource": { + "resourceType": "Condition", + "id": "condition-2", + "clinicalStatus": { "coding": [{ "code": "resolved" }] }, + "code": { "text": "Iron-deficiency anemia (history)" }, + "onsetDateTime": "2022-01-01", + "recordedDate": "2023-02-01" + } + }, + { + "fullUrl": "urn:uuid:medstmt-1", + "resource": { + "resourceType": "MedicationStatement", + "id": "medstmt-1", + "status": "active", + "medicationCodeableConcept": { "text": "Levothyroxine 75 mcg tablet" }, + "dosage": [{ "text": "75 mcg by mouth once daily, morning" }], + "reasonCode": [{ "text": "Hypothyroidism" }], + "effectivePeriod": { "start": "2016-05-01" } + } + }, + { + "fullUrl": "urn:uuid:obs-bp", + "resource": { + "resourceType": "Observation", + "id": "obs-bp", + "status": "final", + "category": [{ "coding": [{ "code": "vital-signs" }] }], + "code": { "text": "Blood pressure" }, + "effectiveDateTime": "2026-05-30", + "component": [ + { "code": { "text": "Systolic" }, "valueQuantity": { "value": 118, "unit": "mmHg" } }, + { "code": { "text": "Diastolic" }, "valueQuantity": { "value": 74, "unit": "mmHg" } } + ] + } + }, + { + "fullUrl": "urn:uuid:obs-hr", + "resource": { + "resourceType": "Observation", + "id": "obs-hr", + "status": "final", + "category": [{ "coding": [{ "code": "vital-signs" }] }], + "code": { "text": "Heart rate" }, + "effectiveDateTime": "2026-05-30", + "valueQuantity": { "value": 68, "unit": "bpm" } + } + }, + { + "fullUrl": "urn:uuid:obs-weight", + "resource": { + "resourceType": "Observation", + "id": "obs-weight", + "status": "final", + "category": [{ "coding": [{ "code": "vital-signs" }] }], + "code": { "text": "Body weight" }, + "effectiveDateTime": "2026-05-30", + "valueQuantity": { "value": 63.5, "unit": "kg" } + } + } + ] +} diff --git a/skills/prep-my-visit/references/examples/pcp-annual.labs.json b/skills/prep-my-visit/references/examples/pcp-annual.labs.json new file mode 100644 index 0000000..8465c91 --- /dev/null +++ b/skills/prep-my-visit/references/examples/pcp-annual.labs.json @@ -0,0 +1,28 @@ +{ + "visitId": "pcp-annual", + "generatedAt": "2026-06-02T13:00:00Z", + "dtcOptIn": false, + "categoryA": [], + "categoryB": [ + { + "test": "Thyroid-stimulating hormone (TSH)", + "rationale": "On levothyroxine with new fatigue; TSH has not been checked in over a year.", + "guidance": "Ask your doctor whether checking your TSH before the visit would help explain the fatigue.", + "citation": { "guideline": "American Thyroid Association Guidelines for Hypothyroidism", "version": "2014" }, + "snippet": "I have been more tired than usual and my last thyroid test was over a year ago. I would like to ask about checking my TSH before my annual visit. Draft - review and approve before sending." + }, + { + "test": "Complete blood count with ferritin", + "rationale": "Prior iron-deficiency anemia plus new fatigue makes a recheck reasonable to discuss.", + "guidance": "Ask your doctor whether a complete blood count and ferritin would be worth repeating given your history.", + "citation": { "guideline": "USPSTF Iron Deficiency Screening Statement", "version": "2015" } + }, + { + "test": "Lipid panel", + "rationale": "Routine cardiovascular risk assessment is due based on age and interval since last panel.", + "guidance": "Ask your doctor whether a fasting lipid panel fits your annual cardiovascular risk review.", + "citation": { "guideline": "ACC/AHA Cholesterol Guideline", "version": "2018" } + } + ], + "categoryC": [] +} diff --git a/skills/prep-my-visit/references/examples/urgent-same-day.json b/skills/prep-my-visit/references/examples/urgent-same-day.json new file mode 100644 index 0000000..df30ac5 --- /dev/null +++ b/skills/prep-my-visit/references/examples/urgent-same-day.json @@ -0,0 +1,95 @@ +{ + "resourceType": "Bundle", + "id": "brief-urgent-same-day", + "type": "document", + "timestamp": "2026-06-02T13:00:00Z", + "meta": { "source": "prep-my-visit@1.0", "profile": ["http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips"] }, + "entry": [ + { + "fullUrl": "urn:uuid:comp-1", + "resource": { + "resourceType": "Composition", + "id": "comp-1", + "status": "preliminary", + "type": { "text": "Pre-visit summary" }, + "title": "Pre-Visit Brief - Urgent Same-Day", + "date": "2026-06-02T13:00:00Z", + "subject": { "reference": "urn:uuid:patient-1" }, + "encounter": { "reference": "urn:uuid:encounter-1" }, + "section": [ + { + "title": "Patient Story", + "text": { "status": "additional", "div": "

Alex has had a sore throat and a fever since yesterday and is being seen at urgent care this afternoon. Little other history is available in the workspace right now.

" } + }, + { + "title": "Goals for this visit", + "text": { "status": "additional", "div": "
  • Get my sore throat and fever checked today
" } + }, + { + "title": "Problem List", + "text": { "status": "generated", "div": "

No chronic problems recorded in the workspace.

" } + }, + { + "title": "Medication Summary", + "text": { "status": "generated", "div": "

No medications recorded in the workspace.

" } + }, + { + "title": "Allergies and Intolerances", + "text": { "status": "generated", "div": "

Penicillin - hives (severe).

" } + }, + { + "title": "Vital Signs", + "text": { "status": "generated", "div": "

Temperature 38.4 C (self-reported).

" } + } + ] + } + }, + { + "fullUrl": "urn:uuid:patient-1", + "resource": { + "resourceType": "Patient", + "id": "patient-1", + "identifier": [{ "system": "urn:tula:tenant-id", "value": "TULA-330914" }], + "name": [{ "use": "official", "family": "Smith", "given": ["Alex"], "text": "Alex Smith" }], + "gender": "other", + "birthDate": "1996-07-30" + } + }, + { + "fullUrl": "urn:uuid:encounter-1", + "resource": { + "resourceType": "Encounter", + "id": "encounter-1", + "status": "planned", + "class": { "code": "AMB", "display": "ambulatory" }, + "type": [{ "text": "Urgent care, same day" }], + "period": { "start": "2026-06-02T16:00:00" }, + "location": [{ "location": { "display": "Eastside Urgent Care" } }] + } + }, + { + "fullUrl": "urn:uuid:allergy-1", + "resource": { + "resourceType": "AllergyIntolerance", + "id": "allergy-1", + "clinicalStatus": { "coding": [{ "code": "active" }] }, + "code": { "text": "Penicillin" }, + "criticality": "high", + "reaction": [{ "manifestation": [{ "text": "Hives" }], "severity": "severe" }] + } + }, + { + "fullUrl": "urn:uuid:obs-temp", + "resource": { + "resourceType": "Observation", + "id": "obs-temp", + "status": "preliminary", + "category": [{ "coding": [{ "code": "vital-signs" }] }], + "code": { "text": "Body temperature" }, + "effectiveDateTime": "2026-06-02", + "valueQuantity": { "value": 38.4, "unit": "C" }, + "note": [{ "text": "self-reported" }] + } + } + ] +} diff --git a/skills/prep-my-visit/references/examples/urgent-same-day.labs.json b/skills/prep-my-visit/references/examples/urgent-same-day.labs.json new file mode 100644 index 0000000..d9cfbcd --- /dev/null +++ b/skills/prep-my-visit/references/examples/urgent-same-day.labs.json @@ -0,0 +1,8 @@ +{ + "visitId": "urgent-same-day", + "generatedAt": "2026-06-02T13:00:00Z", + "dtcOptIn": false, + "categoryA": [], + "categoryB": [], + "categoryC": [] +} diff --git a/skills/prep-my-visit/scripts/.gitignore b/skills/prep-my-visit/scripts/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/skills/prep-my-visit/scripts/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/skills/prep-my-visit/scripts/render_visit_brief.py b/skills/prep-my-visit/scripts/render_visit_brief.py new file mode 100644 index 0000000..eb5532c --- /dev/null +++ b/skills/prep-my-visit/scripts/render_visit_brief.py @@ -0,0 +1,910 @@ +#!/usr/bin/env python3 +""" +prep-my-visit: render an IPS visit-prep package into print-ready PDFs. + +Takes one IPS Bundle JSON (FHIR R4 document Bundle with a Composition first +entry) and one lab-opportunities JSON, and writes two typeset PDFs: + + 1. provider.pdf - a clinician-grade pre-visit summary. Dense, high-signal, + 1-2 pages. Structurally modeled after an Epic After-Visit-Summary / + Visit-Snapshot document: a branded header band, solid section header + bars, striped clinical tables (problems, medications, allergies, + vitals), and a footer band. Burgundy is the single accent color. + 2. patient.pdf - a patient-facing visit-prep companion. Same clinical + document language, looser density, plain-language goals, what-to-bring + checklist, discuss-with-doctor lab questions, and optional portal + snippet drafts marked "review before sending". + +Branding is My Aria, not Epic. The wordmark renders as uniform styled text +("My Aria", single ink color - no two-tone). Burgundy is reserved for the +header accent rule, "High" pills, the patient-story rule, and the review +callout. No Epic logo, no MyChart logo, no Epic blue, no text suggesting an +Epic-affiliated origin. This is defensible structural homage to a familiar +clinical document, not a copy. + +Typesetting is HTML + CSS rendered by WeasyPrint (no headless browser). +Type is a sans-serif clinical stack (the same family the Aria app uses): +ui-sans-serif / system-ui / Segoe UI / Roboto / Helvetica Neue / Arial, +resolved by WeasyPrint to the best available host font. No serif body type. + +Privacy: this script reads PHI from the bundle and writes it into the PDFs +only. On any failure it prints a generic, PHI-free error to stderr and +never echoes bundle or lab contents - PHI must not leak into logs. + +License: Apache-2.0 (inherited from the Tula repository). + +Usage: + python3 render_visit_brief.py --bundle \ + --labs --out + python3 render_visit_brief.py --bundle --out --provider-only + python3 render_visit_brief.py --bundle --labs \ + --out --patient-only + +Stdout: a JSON report {status, files[], notes[]}. Exit 0 on success. +On failure: JSON error on stderr (no PHI), exit 1. +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import html +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +WORDMARK_FIRST = "My" +WORDMARK_SECOND = "Aria" +DOC_VERSION = "v1.0" + +# Aria brand palette, resolved from apps/my-aria/app/globals.css light-mode +# tokens to print-safe hex (WeasyPrint 61 oklch support is uneven; hex is +# exact and portable). Burgundy is the single accent. +BURGUNDY = "#9B1C2C" # --color-accent oklch(0.46 0.16 18). SPARING: + # only the header accent rule, lab High pill, + # patient-story left-rule, and review callout. +INFO_BLUE = "#2F6FB0" # --color-info, appointment metadata only +INK = "#1E2227" # --color-fg oklch(0.20 0.010 264) +MUTED = "#646A72" # --color-fg-muted +SUBTLE = "#8A9099" # --color-fg-subtle +HAIR = "#E3E5E8" # --color-border oklch(0.91 0.005 264) +STRIPE = "#F5F6F7" # table zebra row +BAND_GRAY = "#F4F4F6" # header / section-bar / footer band (achromatic) +RULE_GRAY = "#C9CCD1" # section-bar edge + table header underline +MONO = ('"DejaVu Sans Mono", ui-monospace, SFMono-Regular, Menlo, ' + 'Consolas, "Liberation Mono", monospace') + + +# -------------------------------------------------------------------------- +# small helpers +# -------------------------------------------------------------------------- + +def esc(value) -> str: + if value is None: + return "" + return html.escape(str(value)) + + +def _get(node, *path, default=None): + for part in path: + if isinstance(node, dict): + node = node.get(part) + elif isinstance(node, list) and isinstance(part, int) and 0 <= part < len(node): + node = node[part] + else: + return default + if node is None: + return default + return node + + +def _fmt_date(value) -> str: + """ISO date or datetime -> 'June 9, 2026'. Falls back to raw string.""" + if not value: + return "" + raw = str(value).replace("Z", "") + try: + if "T" in raw: + d = _dt.datetime.fromisoformat(raw).date() + else: + d = _dt.date.fromisoformat(raw[:10]) + return d.strftime("%B %-d, %Y") + except (ValueError, TypeError): + return str(value) + + +def _fmt_datetime(value) -> str: + """ISO datetime -> 'Tuesday, June 9, 2026 at 10:30 AM'.""" + if not value: + return "" + raw = str(value).replace("Z", "") + try: + if "T" in raw: + dt = _dt.datetime.fromisoformat(raw) + return dt.strftime("%A, %B %-d, %Y at %-I:%M %p") + return _fmt_date(value) + except (ValueError, TypeError): + return str(value) + + +def _age_on(birth, ref) -> str: + try: + b = _dt.date.fromisoformat(str(birth)[:10]) + except (ValueError, TypeError): + return "" + try: + r = _dt.date.fromisoformat(str(ref)[:10]) + except (ValueError, TypeError): + r = _dt.date.today() + years = r.year - b.year - ((r.month, r.day) < (b.month, b.day)) + return f"{years}y" if years >= 0 else "" + + +def _strip_ns(tag: str) -> str: + return tag.split("}", 1)[-1] if "}" in tag else tag + + +# -------------------------------------------------------------------------- +# IPS Bundle parsing +# -------------------------------------------------------------------------- + +class Brief: + """Flattened, render-ready view of an IPS Bundle + lab opportunities.""" + + def __init__(self, bundle: dict, labs: dict | None): + entries = [e.get("resource", {}) for e in bundle.get("entry", []) if isinstance(e, dict)] + self.by_type: dict[str, list] = {} + for r in entries: + self.by_type.setdefault(r.get("resourceType"), []).append(r) + + self.composition = self._first("Composition") + self.patient = self._first("Patient") + self.encounter = self._first("Encounter") + self.practitioner = self._first("Practitioner") + self.labs = labs or {} + self.sections = self._index_sections() + + def _first(self, rtype: str) -> dict: + items = self.by_type.get(rtype, []) + return items[0] if items else {} + + def _all(self, rtype: str) -> list: + return self.by_type.get(rtype, []) + + # -- composition narrative ------------------------------------------- + + def _index_sections(self) -> dict: + out = {} + for sec in self.composition.get("section", []) or []: + title = (sec.get("title") or _get(sec, "code", "text") or "").strip() + if title: + out[title.lower()] = sec + return out + + def section_items(self, title: str) -> list[str]: + """Return
  • texts (or

    texts if no list) for a named section.""" + sec = self.sections.get(title.lower()) + if not sec: + return [] + div = _get(sec, "text", "div") + if not div: + return [] + items_li, items_p = [], [] + try: + root = ET.fromstring(div) + except ET.ParseError: + plain = re.sub("<[^>]+>", " ", div) + plain = html.unescape(re.sub(r"\s+", " ", plain)).strip() + return [plain] if plain else [] + for el in root.iter(): + tag = _strip_ns(el.tag) + text = "".join(el.itertext()).strip() + if not text: + continue + if tag == "li": + items_li.append(text) + elif tag == "p": + items_p.append(text) + return items_li if items_li else items_p + + def section_text(self, title: str) -> str: + return " ".join(self.section_items(title)).strip() + + # -- patient / visit identity ---------------------------------------- + + def patient_name(self) -> str: + names = self.patient.get("name", []) + if names: + n = names[0] + if n.get("text"): + return n["text"] + given = " ".join(n.get("given", [])) + return f"{given} {n.get('family', '')}".strip() + return "" + + def patient_dob(self) -> str: + return _fmt_date(self.patient.get("birthDate")) + + def patient_age(self) -> str: + birth = self.patient.get("birthDate") + if not birth: + return "" + return _age_on(birth, _get(self.encounter, "period", "start")) + + def patient_id(self) -> str: + for ident in self.patient.get("identifier", []): + if ident.get("value"): + return ident["value"] + return "" + + def visit_type(self) -> str: + return _get(self.encounter, "type", 0, "text") or "Office visit" + + def visit_when(self) -> str: + return _fmt_datetime(_get(self.encounter, "period", "start")) + + def visit_location(self) -> str: + return _get(self.encounter, "location", 0, "location", "display") or "" + + def clinician(self) -> str: + names = self.practitioner.get("name", []) if self.practitioner else [] + if names: + n = names[0] + if n.get("text"): + return n["text"] + return f"{' '.join(n.get('prefix', []))} {n.get('family', '')}".strip() + return "" + + def clinician_specialty(self) -> str: + return _get(self.practitioner, "qualification", 0, "code", "text") or "" + + # -- structured clinical tables (with narrative fallback) ------------ + + @staticmethod + def _active(resource: dict) -> bool: + codes = _get(resource, "clinicalStatus", "coding", default=[]) or [] + if not codes: + return True + return any(c.get("code") == "active" for c in codes) + + def problems(self) -> list[dict]: + rows = [] + for c in self._all("Condition"): + if not self._active(c): + continue + rows.append({ + "name": _get(c, "code", "text") or "Condition", + "confirmed": c.get("recordedDate") or c.get("onsetDateTime") or "", + }) + rows.sort(key=lambda r: str(r["confirmed"]), reverse=True) + return rows + + def medications(self) -> list[dict]: + rows = [] + for m in self._all("MedicationStatement"): + if m.get("status") and m["status"] not in ("active", "intended", "unknown"): + continue + full = _get(m, "medicationCodeableConcept", "text") or "Medication" + strength = "" + mm = re.search(r"\b\d[\d.]*\s?(?:mg|mcg|g|ml|units?|%)\b", full, re.I) + if mm: + strength = mm.group(0) + name = full[:mm.start()].strip(" ,-") + else: + name = full + rows.append({ + "name": name or full, + "strength": strength, + "sig": _get(m, "dosage", 0, "text") or "", + "indication": _get(m, "reasonCode", 0, "text") or "", + "started": _fmt_date(_get(m, "effectivePeriod", "start")), + }) + return rows + + def medication_names(self) -> list[str]: + out = [] + for r in self.medications(): + label = r["name"] + if r["strength"]: + label = f"{label} {r['strength']}" + out.append(label) + if not out: + # narrative fallback (sparse bundle) + return [t for t in self.section_items("Medication Summary")] + return out + + def allergies(self) -> list[dict]: + rows = [] + for a in self._all("AllergyIntolerance"): + rows.append({ + "substance": _get(a, "code", "text") or "Substance", + "reaction": _get(a, "reaction", 0, "manifestation", 0, "text") or "", + "severity": _get(a, "reaction", 0, "severity") or a.get("criticality") or "", + }) + return rows + + def vitals(self) -> list[dict]: + """Collapse vital-sign Observations into per-date rows.""" + by_date: dict[str, dict] = {} + for o in self._all("Observation"): + cats = _get(o, "category", 0, "coding", default=[]) or [] + if cats and not any(c.get("code") == "vital-signs" for c in cats): + continue + day = str(o.get("effectiveDateTime") or o.get("effectiveInstant") or "")[:10] + row = by_date.setdefault(day, {"date": _fmt_date(day)}) + label = (_get(o, "code", "text") or "").lower() + comps = o.get("component", []) + if comps: + vals = {} + for c in comps: + cl = (_get(c, "code", "text") or "").lower() + v = _get(c, "valueQuantity", "value") + if v is not None: + vals[cl] = v + if "systolic" in vals and "diastolic" in vals: + row["bp"] = f"{vals['systolic']:g}/{vals['diastolic']:g}" + else: + v = _get(o, "valueQuantity", "value") + unit = _get(o, "valueQuantity", "unit") or "" + if v is None: + continue + if "heart" in label or "pulse" in label: + row["hr"] = f"{v:g} {unit}".strip() + elif "weight" in label: + row["weight"] = f"{v:g} {unit}".strip() + rows = [by_date[d] for d in sorted(by_date, reverse=True)] + return [r for r in rows if len(r) > 1][:3] + + # -- lab opportunities ----------------------------------------------- + + def category_a(self) -> list[dict]: + return self.labs.get("categoryA", []) or [] + + def category_b(self) -> list[dict]: + return self.labs.get("categoryB", []) or [] + + def snippets(self) -> list[str]: + out = [] + for item in self.category_b(): + if item.get("snippet"): + out.append(item["snippet"]) + for s in self.labs.get("snippets", []) or []: + if isinstance(s, str): + out.append(s) + elif isinstance(s, dict) and s.get("text"): + out.append(s["text"]) + return out + + +# -------------------------------------------------------------------------- +# shared stylesheet +# -------------------------------------------------------------------------- + +def base_css(band_h: str, side: str, foot_h: str, body_size: str, gap: str) -> str: + return f""" +@page {{ + size: Letter; + margin: {band_h} 0 {foot_h} 0; + @bottom-center {{ + content: "Page " counter(page) " of " counter(pages); + font-family: {MONO}; + font-size: 6.8pt; + letter-spacing: 0.01em; + color: {SUBTLE}; + vertical-align: middle; + }} +}} +* {{ box-sizing: border-box; }} +html {{ -weasy-hyphens: none; }} +body {{ + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + color: {INK}; + font-size: {body_size}; + line-height: 1.4; + margin: 0; + orphans: 3; + widows: 3; +}} + +/* --- repeating header / footer bands (running elements) ------------ */ +.band {{ + background: {BAND_GRAY}; + padding: 0 {side}; + box-sizing: border-box; +}} +.band-top {{ + position: fixed; + top: -{band_h}; /* rise into the reserved top margin */ + left: 0; right: 0; /* L/R page margins are 0 -> full bleed */ + height: {band_h}; + border-bottom: 2.5pt solid {BURGUNDY}; /* the single accent rule */ +}} +.band-top table {{ width: 100%; height: {band_h}; border-collapse: collapse; }} +.band-top td {{ vertical-align: middle; }} +.band-bottom {{ + position: fixed; + bottom: -{foot_h}; /* drop into the reserved bottom margin */ + left: 0; right: 0; + height: {foot_h}; + border-top: 0.75pt solid {HAIR}; + font-family: {MONO}; + font-size: 6.8pt; + letter-spacing: 0.01em; + color: {SUBTLE}; +}} +.band-bottom table {{ width: 100%; height: {foot_h}; border-collapse: collapse; }} +.band-bottom td {{ vertical-align: middle; }} +.wordmark {{ + font-size: 20pt; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1; +}} +.wordmark .one {{ color: {INK}; font-weight: 700; }} +.wordmark .two {{ color: {INK}; font-weight: 700; }} /* uniform wordmark */ +.doc-subtitle {{ + font-family: {MONO}; + font-size: 7.5pt; + letter-spacing: 0.04em; + color: {MUTED}; + margin-top: 5pt; +}} +.idblock {{ text-align: right; font-size: 8.5pt; line-height: 1.5; color: {INK}; }} +.idblock .pname {{ font-weight: 700; font-size: 11pt; }} +.idblock .muted {{ color: {MUTED}; }} +.idblock .appt {{ color: {INFO_BLUE}; font-weight: 600; }} +.foot-right {{ text-align: right; white-space: nowrap; }} + +/* --- content frame -------------------------------------------------- */ +.content {{ + padding: 16pt {side} 14pt {side}; +}} + +/* --- section header bars -------------------------------------------- */ +.sec {{ break-inside: avoid; margin-top: {gap}; }} +.sec.first {{ margin-top: 0; }} +.bar {{ + background: {BAND_GRAY}; + color: {INK}; + border-left: 3pt solid {RULE_GRAY}; + font-size: 8.5pt; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 4pt 9pt; +}} +.sec-body {{ padding: 8pt 1pt 0 1pt; }} + +/* context strip + patient story */ +.strip-label {{ + font-size: 7.5pt; font-weight: 700; letter-spacing: 0.14em; + text-transform: uppercase; color: {MUTED}; margin-bottom: 5pt; +}} +.goals-strip {{ + background: {STRIPE}; + border-left: 3pt solid {RULE_GRAY}; + padding: 9pt 14pt; +}} +.goals-strip ul {{ margin: 0; padding-left: 16pt; }} +.goals-strip li {{ margin: 3pt 0; }} +.story {{ + border-left: 3pt solid {BURGUNDY}; + padding: 3pt 0 3pt 13pt; + margin: {gap} 0 0 0; + line-height: 1.5; +}} + +/* --- clinical tables ------------------------------------------------ */ +table.clin {{ + width: 100%; border-collapse: collapse; + font-size: 9pt; border: 1pt solid {HAIR}; +}} +table.clin th {{ + background: #fff; text-align: left; + font-size: 7.5pt; letter-spacing: 0.07em; text-transform: uppercase; + color: {MUTED}; font-weight: 700; + padding: 5pt 8pt; border-bottom: 1pt solid {RULE_GRAY}; +}} +table.clin td {{ + padding: 5pt 8pt; border-bottom: 0.5pt solid {HAIR}; vertical-align: top; +}} +table.clin tr:nth-child(even) td {{ background: {STRIPE}; }} +table.clin tr:last-child td {{ border-bottom: none; }} +td.med-name {{ font-weight: 600; }} +.nowrap {{ white-space: nowrap; }} + +/* --- lists ---------------------------------------------------------- */ +ul.tight {{ margin: 0; padding-left: 16pt; }} +ul.tight li {{ margin: 4pt 0; line-height: 1.4; }} +.lab-cat {{ + font-size: 7.5pt; font-weight: 700; letter-spacing: 0.10em; + text-transform: uppercase; color: {MUTED}; margin: 0 0 5pt 0; +}} +.pill {{ + display: inline-block; background: {BURGUNDY}; color: #fff; + font-size: 6.5pt; font-weight: 700; letter-spacing: 0.06em; + text-transform: uppercase; padding: 1pt 5pt; border-radius: 7pt; + margin-left: 5pt; vertical-align: middle; +}} +.lab-cat.b {{ margin-top: 9pt; }} +.cite {{ color: {MUTED}; font-size: 8pt; }} +.lab-item {{ margin: 0 0 7pt 0; line-height: 1.4; }} +.lab-item .q {{ font-weight: 600; }} + +/* --- patient-facing extras ----------------------------------------- */ +.snippet {{ + border: 1pt solid {HAIR}; + background: {STRIPE}; padding: 9pt 12pt; margin: 8pt 0; + font-size: 9.5pt; line-height: 1.5; +}} +.review-flag {{ + color: {BURGUNDY}; font-size: 7.5pt; font-weight: 700; + letter-spacing: 0.10em; text-transform: uppercase; margin-bottom: 4pt; +}} +.note {{ color: {MUTED}; font-size: 9pt; margin: 0 0 6pt 0; }} +""" + + +# -------------------------------------------------------------------------- +# HTML fragments +# -------------------------------------------------------------------------- + +def _wordmark(subtitle: str) -> str: + return ( + '

    ' + f'
    {esc(WORDMARK_FIRST)} ' + f'{esc(WORDMARK_SECOND)}
    ' + f'
    {esc(subtitle)}
    ' + '
    ' + ) + + +def _header_band(subtitle: str, id_rows: list[str]) -> str: + return ( + '
    ' + f'' + f'' + '
    {_wordmark(subtitle)}{"".join(id_rows)}
    ' + ) + + +def _footer_band(left: str, right: str) -> str: + return ( + '
    ' + f'' + f'' + '
    {esc(left)}{esc(right)}
    ' + ) + + +def _section(title: str, body_html: str, first: bool = False) -> str: + if not body_html: + return "" + cls = "sec first" if first else "sec" + return ( + f'
    {esc(title)}
    ' + f'
    {body_html}
    ' + ) + + +def _ul(items: list[str], cls: str = "tight") -> str: + if not items: + return "" + lis = "".join(f"
  • {esc(i)}
  • " for i in items) + return f'
      {lis}
    ' + + +def _lab_item(x: dict) -> str: + cite = x.get("citation", {}) + cite_s = "" + if cite.get("guideline"): + cite_s = f' ({esc(cite["guideline"])}' + if cite.get("version"): + cite_s += f", {esc(cite['version'])}" + cite_s += ")" + q = x.get("guidance") or x.get("rationale") or x.get("test", "") + pill = f'{esc(x["flag"])}' if x.get("flag") else "" + return f'
    {esc(q)}{pill}{cite_s}
    ' + + +# -------------------------------------------------------------------------- +# provider PDF +# -------------------------------------------------------------------------- + +def build_provider_html(b: Brief, generated: str) -> str: + css = base_css(band_h="108pt", side="46pt", foot_h="30pt", body_size="10pt", gap="13pt") + + clin = b.clinician() + if clin and b.clinician_specialty(): + clin = f"{clin} · {b.clinician_specialty()}" + + id_rows = [f'
    {esc(b.patient_name())}
    '] + meta = [] + if b.patient_dob(): + dob = b.patient_dob() + if b.patient_age(): + dob += f" ({b.patient_age()})" + meta.append(f"DOB {esc(dob)}") + if b.patient_id(): + meta.append(f"ID {esc(b.patient_id())}") + if meta: + id_rows.append(f'
    {"  ·  ".join(meta)}
    ') + if b.visit_when(): + id_rows.append(f'
    {esc(b.visit_when())}
    ') + line2 = [x for x in [b.visit_type(), b.visit_location()] if x] + if line2: + id_rows.append(f'
    {esc(" · ".join(line2))}
    ') + if clin: + id_rows.append(f'
    {esc(clin)}
    ') + + header = _header_band("Pre-Visit Summary", id_rows) + parts = [] + first = True + + goals = b.section_items("Goals for this visit") + if goals: + parts.append( + f'
    ' + '
    Visit Reason / Goals
    ' + f'
    {_ul(goals)}
    ' + ) + first = False + + story = b.section_text("Patient Story") + if story: + parts.append(f'
    {esc(story)}
    ') + first = False + + probs = b.problems() + if probs: + body = ['' + ''] + for p in probs: + body.append( + f'' + f'' + ) + body.append("
    ProblemLast confirmed
    {esc(p["name"])}{esc(_fmt_date(p["confirmed"]))}
    ") + parts.append(_section("Problem List", "".join(body), first)) + first = False + elif b.section_items("Problem List"): + parts.append(_section("Problem List", _ul(b.section_items("Problem List")), first)) + first = False + + meds = b.medications() + if meds: + body = ['' + '' + ''] + for m in meds: + body.append( + f'' + f'' + f'' + f'' + f'' + ) + body.append("
    MedicationStrengthSigIndicationStarted
    {esc(m["name"])}{esc(m["strength"])}{esc(m["sig"])}{esc(m["indication"])}{esc(m["started"])}
    ") + parts.append(_section("Medications", "".join(body))) + elif b.section_items("Medication Summary"): + parts.append(_section("Medications", _ul(b.section_items("Medication Summary")))) + + allergies = b.allergies() + if allergies: + body = ['' + ''] + for a in allergies: + body.append( + f'' + f'' + f'' + ) + body.append("
    SubstanceReactionSeverity
    {esc(a["substance"])}{esc(a["reaction"])}{esc((a["severity"] or "").title())}
    ") + parts.append(_section("Allergies", "".join(body))) + elif b.section_items("Allergies and Intolerances"): + parts.append(_section("Allergies", _ul(b.section_items("Allergies and Intolerances")))) + + lab_html = [] + cat_a = b.category_a() + if cat_a: + lab_html.append('
    Category A · standing orders pending
    ') + a_items = [] + for x in cat_a: + line = x.get("test", "Lab") + tail = [] + if x.get("orderingProvider"): + tail.append(f"ordered by {x['orderingProvider']}") + if x.get("orderDate"): + tail.append(_fmt_date(x["orderDate"])) + if tail: + line += f" ({'; '.join(tail)})" + if x.get("action"): + line += f" - {x['action']}" + pill = f' {esc(x["flag"])}' if x.get("flag") else "" + a_items.append(esc(line) + pill) + # a_items hold escaped text plus trusted pill markup; emit raw
  • . + lab_html.append( + '
      ' + "".join(f"
    • {i}
    • " for i in a_items) + "
    " + ) + cat_b = b.category_b() + if cat_b: + cls = "lab-cat b" if cat_a else "lab-cat" + lab_html.append(f'
    Category B · discuss with doctor
    ') + for x in cat_b: + lab_html.append(_lab_item(x)) + if lab_html: + parts.append(_section("Pre-Visit Lab Opportunities", "".join(lab_html))) + + delta = b.section_items("Delta Since Last Visit With This Provider") + if delta: + parts.append(_section("Delta Since Last Visit", _ul(delta))) + + vitals = b.vitals() + if vitals: + body = ['' + ''] + for v in vitals: + body.append( + f'' + f'' + f'' + f'' + ) + body.append("
    DateBP (mmHg)HRWeight
    {esc(v.get("date", ""))}{esc(v.get("bp", "-"))}{esc(v.get("hr", "-"))}{esc(v.get("weight", "-"))}
    ") + parts.append(_section("Recent Vitals", "".join(body))) + + disc = b.section_items("Top Discussion Items") + if disc: + parts.append(_section("Top Discussion Items", _ul(disc))) + + footer = _footer_band( + "Generated by Tula - for clinician pre-visit context, not a clinical document of record.", + f"Pre-Visit Summary · {generated} · {DOC_VERSION}", + ) + return _doc(css, header + f'
    {"".join(parts)}
    ' + footer) + + +# -------------------------------------------------------------------------- +# patient PDF +# -------------------------------------------------------------------------- + +def build_patient_html(b: Brief, generated: str) -> str: + css = base_css(band_h="94pt", side="54pt", foot_h="30pt", body_size="10.5pt", gap="16pt") + + id_rows = [f'
    For {esc(b.patient_name())}
    '] + if b.visit_when(): + id_rows.append(f'
    {esc(b.visit_when())}
    ') + if b.clinician(): + id_rows.append(f'
    Seeing {esc(b.clinician())}
    ') + if b.visit_location(): + id_rows.append(f'
    {esc(b.visit_location())}
    ') + + header = _header_band("Your Visit Prep", id_rows) + parts = [] + first = True + + goals = b.section_items("Goals for this visit") + if goals: + parts.append(_section("Your Goals For This Visit", + f'
    {_ul(goals)}
    ', first)) + first = False + + bring = ["Your insurance card and a photo ID", "A list of any questions you want to ask"] + med_names = b.medication_names() + if med_names: + bring.append("Your current medication list: " + ", ".join(med_names)) + disc_items = b.section_items("Top Discussion Items") + if disc_items: + bring.append("Any notes or a symptom log about what you'd like to discuss") + parts.append(_section("What To Bring", _ul(bring), first)) + first = False + + if disc_items: + parts.append(_section("Things To Mention To Your Doctor", _ul(disc_items))) + + cat_b = b.category_b() + if cat_b: + parts.append(_section("Lab Questions To Consider Asking", + "".join(_lab_item(x) for x in cat_b))) + + snips = b.snippets() + if snips: + body = ['
    Review before sending - not auto-sent
    '] + for s in snips: + body.append(f'
    {esc(s)}
    ') + parts.append(_section("Optional Portal Snippets", "".join(body))) + + footer = _footer_band( + "Tula prep for your visit - this is not medical advice, just a way to walk in prepared.", + f"Your Visit Prep · {generated}", + ) + return _doc(css, header + f'
    {"".join(parts)}
    ' + footer) + + +def _doc(css: str, body: str) -> str: + return ( + '' + f'{body}' + ) + + +# -------------------------------------------------------------------------- +# main +# -------------------------------------------------------------------------- + +def main() -> int: + parser = argparse.ArgumentParser( + description="Render an IPS visit-prep package into provider/patient PDFs." + ) + parser.add_argument("--bundle", required=True, help="Path to IPS Bundle JSON") + parser.add_argument("--labs", default=None, help="Path to lab-opportunities JSON") + parser.add_argument("--out", required=True, help="Output directory for PDFs") + parser.add_argument("--provider-only", action="store_true", help="Render only provider.pdf") + parser.add_argument("--patient-only", action="store_true", help="Render only patient.pdf") + args = parser.parse_args() + + try: + from weasyprint import HTML + except Exception: + print(json.dumps({ + "status": "error", + "error": "weasyprint is not installed. Install with: pip install --user weasyprint", + }), file=sys.stderr) + return 1 + + try: + try: + bundle = json.loads(Path(args.bundle).read_text(encoding="utf-8")) + except json.JSONDecodeError: + raise ValueError("bundle is not valid JSON") + if not isinstance(bundle, dict) or bundle.get("resourceType") != "Bundle": + raise ValueError("bundle must be a FHIR Bundle resource") + + labs = None + if args.labs: + try: + labs = json.loads(Path(args.labs).read_text(encoding="utf-8")) + except json.JSONDecodeError: + raise ValueError("labs is not valid JSON") + if not isinstance(labs, dict): + raise ValueError("labs file must be a JSON object") + + brief = Brief(bundle, labs) + if not brief.composition: + raise ValueError("bundle has no Composition entry") + if not brief.patient_name(): + raise ValueError("bundle is missing a Patient name") + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + generated = _dt.datetime.now().strftime("%b %-d, %Y %-I:%M %p") + + files = [] + if not args.patient_only: + p = out_dir / "provider.pdf" + HTML(string=build_provider_html(brief, generated), base_url=".").write_pdf(str(p)) + files.append(str(p)) + if not args.provider_only: + p = out_dir / "patient.pdf" + HTML(string=build_patient_html(brief, generated), base_url=".").write_pdf(str(p)) + files.append(str(p)) + + except ValueError as exc: + print(json.dumps({"status": "error", "error": str(exc)}), file=sys.stderr) + return 1 + except Exception as exc: + print(json.dumps({ + "status": "error", + "error": f"rendering failed ({type(exc).__name__})", + }), file=sys.stderr) + return 1 + + print(json.dumps({"status": "ok", "files": files, "notes": []}, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/templates/AGENTS.template.md b/templates/AGENTS.template.md new file mode 100644 index 0000000..feb0452 --- /dev/null +++ b/templates/AGENTS.template.md @@ -0,0 +1,79 @@ +# AGENTS.md - Your Workspace + + + +This folder is home. Treat it that way. + +## First Run + +If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again. + +## Session Startup + +Before doing anything else: + +1. Read `SOUL.md` - this is who you are +2. Read `USER.md` - this is who you're helping ({{USER_SHORT_NAME}}) +3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context +4. **If in MAIN SESSION** (direct chat with your human): also read `MEMORY.md` +5. For anything topical (health detail, infra, skills, bugs), use `memory/_index.md` as your map + +Don't ask permission. Just do it. + +## Memory - the tier discipline + +You wake up fresh each session. These files are your continuity. They are organized in TIERS so the always-loaded layer stays small and detail loads on demand: + +- **L1 (always loaded):** `MEMORY.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `AGENTS.md`, `TOOLS.md`, `HEARTBEAT.md` - durable and SMALL. `MEMORY.md` targets <80 lines: every-turn facts plus pointers, NOT detail. +- **L2 (load on demand):** `memory/*.md` and `memory/infra/*.md` - topical files (health-snapshot, stewardship, skills, backups, coding-agents, search-tools, voice-tts, parked-bugs, known-issues). This is where the substance lives. Pull them in with `memory_search` / `memory_get`. +- **L3 (daily journal):** `memory/YYYY-MM-DD.md` - raw, same-day logs of what happened. +- **L4 (archive):** `memory/archive/` - historically true, no longer actionable. Never delete, just move here. +- **Index:** `memory/_index.md` is the catalog and the front door. Update it whenever you add, demote, or archive a memory file. + +**The rule that keeps this healthy:** raw detail goes to L3 the same day it happens. Durable signal gets PROMOTED into the relevant L2 file. Only the distilled, every-turn-relevant essence reaches L1 (`MEMORY.md`). If `MEMORY.md` is bloating, you're skipping the daily-note step and shoveling tactical detail into the always-loaded layer - stop, and demote. + +### MEMORY.md - main session only +- **ONLY load in main session** (direct chats with your human). +- **DO NOT load in shared contexts** (group chats, sessions with other people). It holds personal context that shouldn't leak. +- You can read, edit, and update it freely in main sessions. + +### Write it down - no "mental notes" +- If you want to remember something, WRITE IT TO A FILE. Mental notes don't survive a session restart; files do. +- "Remember this" -> `memory/YYYY-MM-DD.md` or the relevant L2 file. +- A lesson learned -> update `AGENTS.md`, `TOOLS.md`, or the relevant skill. +- A mistake -> document it so future-you doesn't repeat it. + +## Red Lines + +- Don't exfiltrate private data. Ever. +- Don't run destructive commands without asking. +- `trash` > `rm` (recoverable beats gone forever). +- When in doubt, ask. + +## External vs Internal + +**Safe to do freely:** read files, explore, organize, learn, search the web, work within this workspace. + +**Ask first:** sending emails / messages / public posts, anything that leaves the machine, anything you're uncertain about. + +## Group Chats + +You have access to your human's stuff. That doesn't mean you *share* it. In groups you're a participant - not their voice, not their proxy. Respond when directly addressed, when you add genuine value, or to correct important misinformation. Stay quiet during casual banter, when someone already answered, or when a reaction would do. Don't dominate. + +## Tools + +Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (device names, SSH details, voice preferences) in `TOOLS.md`. See `memory/skills.md` for the catalog of skills authored for this tenant. + +## Heartbeats - be proactive, not noisy + +On a heartbeat poll, don't reflexively reply `HEARTBEAT_OK` - but don't spam either. Batch periodic checks (email, calendar, mentions, weather) into `HEARTBEAT.md`. Reach out for something important; stay quiet late at night, when the human is busy, or when nothing's changed. Use heartbeats for background memory maintenance: review recent daily notes and promote durable signal into L2 / `MEMORY.md` per the tier discipline above. + +## Make It Yours + +This is a starting point. Add your own conventions, style, and rules as you figure out what works for {{USER_SHORT_NAME}}. diff --git a/templates/MEMORY.template.md b/templates/MEMORY.template.md new file mode 100644 index 0000000..131ca33 --- /dev/null +++ b/templates/MEMORY.template.md @@ -0,0 +1,71 @@ +# MEMORY.md - Long-term, always-loaded + + + +_This file is sent to me every turn. Keep it small. Detail lives in `memory/` files - see `memory/_index.md` for the catalog._ + +## About me +- **Name:** {{AGENT_NAME}} {{AGENT_EMOJI}} +- **Role:** {{AGENT_ROLE}} +- **First came online:** {{TENANT_BOOTED_AT}} + +## About {{USER_SHORT_NAME}} +- **Full name:** {{USER_FULL_NAME}} +- **DOB:** {{USER_DOB}} +- **Pronouns:** {{USER_PRONOUNS}} +- **Location:** {{USER_LOCATION_LABEL}} (~{{USER_LAT}}, {{USER_LNG}}) +- **Timezone:** {{USER_TZ}} +- **Units:** {{USER_UNITS}} + + +## Clinically active right now + + +## Operating rules +- **Stewardship frame:** {{USER_DATA_FRAME}}. Read `memory/stewardship.md` before talking security or compliance. +- **Stay in frame:** +- **Daily notes are not optional.** When something incident- or learning-grade happens, write it to `memory/YYYY-MM-DD.md` the same day. MEMORY.md only gets the distilled signal. + +## Backups + + +## Where to look for details +| Looking for | Read | +|---|---| +| Full clinical picture | `memory/health-snapshot.md` | +| Stewardship / data-handling frame | `memory/stewardship.md` | +| Workspace skills | `memory/skills.md` | +| Backup architecture & runbook | `memory/infra/backups.md` | +| Coding-agent wiring | `memory/infra/coding-agents.md` | +| Web / social search config | `memory/infra/search-tools.md` | +| Voice (TTS) config | `memory/infra/voice-tts.md` | +| Known bugs to fix later | `memory/infra/parked-bugs.md` | +| Recurring infra problems & fixes | `memory/infra/known-issues.md` | +| What happened on a specific day | `memory/YYYY-MM-DD.md` | +| Catalog of everything above | `memory/_index.md` | + +When in doubt, use `memory_search` - it indexes the whole tree. diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..aea13ac --- /dev/null +++ b/templates/README.md @@ -0,0 +1,124 @@ +# Tula tenant templates + +This directory is the **golden-image memory layer** every new Tula tenant +inherits. When a fresh agent comes online for a new user, the onboarding skill +copies these templates into the agent's workspace, fills in the `{{PLACEHOLDER}}` +tokens from the welcome conversation, and lets the rest accrete over time. + +Everything here is **plain markdown** (and one YAML). No proprietary formats, no +embedded binary, no opaque index. A human in five years should be able to +navigate a deployed tenant's memory with nothing but `cat`. That portability is +a hard design constraint, not a preference. + +## The tier model + +Memory is organized in tiers so the always-loaded layer (sent to the model on +every turn) stays small, and detail loads on demand. + +| Tier | What | Files | Discipline | +|---|---|---|---| +| **L1** | Always loaded, every turn | `MEMORY.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `AGENTS.md`, `TOOLS.md`, `HEARTBEAT.md` | Durable + SMALL. `MEMORY.md` targets **<80 lines**: every-turn facts plus pointers, never detail. | +| **L2** | Load on demand | `memory/*.md`, `memory/infra/*.md`, `memory/profile.yaml` | Where the substance lives. Curated, topical. Pulled in via `memory_search` / `memory_get`. | +| **L3** | Raw daily journal | `memory/YYYY-MM-DD.md` | Same-day, full detail, narrative. Written when something incident- or learning-grade happens. | +| **L4** | Archive | `memory/archive/` | Historically true, no longer actionable. Never deleted, just moved. | + +**The flow that keeps it healthy:** raw detail lands in L3 the same day -> +durable signal is promoted into the relevant L2 file -> only the distilled, +every-turn essence reaches L1. If `MEMORY.md` is bloating, the daily-note step +is being skipped. The whole design fails if it's easy to dump detail into L1. + +**The index is the front door.** A fresh agent reads `MEMORY.md`, which points +to `memory/_index.md`, which catalogs everything else. Keep that chain intact in +every deployment. + +## File tree + +``` +templates/ + README.md <- you are here + AGENTS.template.md <- workspace operating manual (teaches the tier discipline) + MEMORY.template.md <- L1, slim, always-loaded + USER.template.md <- L1, who the human is + profile.template.yaml <- L2, stable structured facts (consumed by myhealth-pulse) + memory/ + _index.md.template <- the catalog / discovery layer + health-snapshot.template.md <- L2: detailed clinical picture + stewardship.template.md <- L2: data-stewardship frame (NOT optional) + skills.template.md <- L2: catalog of skills authored for this tenant + YYYY-MM-DD.template.md <- L3: example daily-note shape + infra/ + backups.template.md + coding-agents.template.md + search-tools.template.md + voice-tts.template.md + parked-bugs.template.md + known-issues.template.md +``` + +> **Naming note.** Templates use the `.template.md` / `.template.yaml` suffix so +> the onboarding step can strip it to produce the live filename +> (`health-snapshot.template.md` -> `health-snapshot.md`). The index is named +> `_index.md.template` per the original brief; the onboarding step should map it +> to `memory/_index.md`. (If standardizing later, `_index.template.md` would +> match the rest - flagged for a future cleanup.) + +## Placeholder vocabulary + +Tokens use `{{UPPER_SNAKE}}`. The onboarding skill fills them; anything still +unknown stays as the token (an unfilled placeholder honestly says "we don't +know this yet" - see the honesty principle below). + +**Agent identity** +- `{{AGENT_NAME}}`, `{{AGENT_EMOJI}}`, `{{AGENT_ROLE}}` + +**User identity** +- `{{USER_FULL_NAME}}`, `{{USER_SHORT_NAME}}`, `{{USER_DOB}}`, `{{USER_PRONOUNS}}` +- `{{USER_LOCATION_LABEL}}`, `{{USER_LAT}}`, `{{USER_LNG}}`, `{{USER_TZ}}`, `{{USER_UNITS}}` + +**Tenant / deployment** +- `{{TENANT_BOOTED_AT}}` - ISO datetime the agent first came online for this user +- `{{TENANT_ID}}` +- `{{TENANT_BACKUP_REPO}}` - `owner/repo` of the private backup repo +- `{{TENANT_BACKUP_BRANCH}}` + +**Health / care** +- `{{PCP_NAME}}`, `{{HEALTH_SYSTEM}}`, `{{FHIR_BASE_URL}}`, `{{MRN}}` + +**Stewardship** +- `{{USER_DATA_FRAME}}` - how the user relates to their own data. One of + `self-stewardship`, `caregiver-managed`, `covered-entity-operated`, or a + tenant-specific phrasing. Drives how the agent talks about security and + compliance. **Declaring this is mandatory** - see `memory/stewardship.template.md`. + +Free-form fill-ins that aren't stable identity (example dates, provider +filenames, version numbers) are written as `` inside HTML +comments rather than minted as new tokens, to keep the vocabulary small. + +## How onboarding should consume these + +1. Copy the tree into the agent's workspace, stripping the `.template` suffix + (and mapping `_index.md.template` -> `memory/_index.md`). +2. Replace `{{PLACEHOLDER}}` tokens from the welcome conversation. **Leave any + token you can't fill** - do not invent a value. +3. **Force a stewardship declaration.** `{{USER_DATA_FRAME}}` must be set, and + `stewardship.md`'s implications/examples rewritten to match the chosen frame. + This is the one field the deployment process must not skip. +4. Keep `MEMORY.md` and `memory/_index.md` consistent: every L2 file should have + a row in the index and a pointer reachable from `MEMORY.md`. +5. Delete the `` guidance comments only once a section holds real + content; until then they document what belongs there. + +## Design principles (do not violate) + +1. **Lifetime memory + portability.** Plain markdown only. Navigable with `cat` + in five years. No binary, no proprietary index. +2. **Honesty about gaps.** An empty section or an unfilled placeholder means "we + don't know this yet." Never fabricate content to fill a template. +3. **Tier discipline is the whole point.** L1 stays small; detail lives in L2; + L3 is raw and chronological; L4 is stale-but-true. If a template makes it easy + to bloat L1, the design has failed. +4. **The index is the front door.** `MEMORY.md` -> `memory/_index.md` -> the rest. + Preserve that chain after templating. +5. **The stewardship frame is mandatory.** Every tenant has *some* frame for how + they relate to their data. The template forces the deployment to declare it and + the agent to respect it. diff --git a/templates/USER.template.md b/templates/USER.template.md new file mode 100644 index 0000000..1d00f23 --- /dev/null +++ b/templates/USER.template.md @@ -0,0 +1,25 @@ +# USER.md - About Your Human + + + +- **Name:** {{USER_FULL_NAME}} +- **What to call them:** {{USER_SHORT_NAME}} +- **Pronouns:** {{USER_PRONOUNS}} +- **Timezone:** {{USER_TZ}} +- **First contact:** {{TENANT_BOOTED_AT}} + +## Context + + diff --git a/templates/memory/YYYY-MM-DD.template.md b/templates/memory/YYYY-MM-DD.template.md new file mode 100644 index 0000000..81ddfb7 --- /dev/null +++ b/templates/memory/YYYY-MM-DD.template.md @@ -0,0 +1,46 @@ +# YYYY-MM-DD + + + +## + + + +### What happened + + +### Root cause / what we found + + +### Fix sequence + + +### What {{USER_SHORT_NAME}} corrected me on + + +## Lessons logged + +- -> promote into `memory/infra/.md` diff --git a/templates/memory/_index.md.template b/templates/memory/_index.md.template new file mode 100644 index 0000000..3fec4e4 --- /dev/null +++ b/templates/memory/_index.md.template @@ -0,0 +1,76 @@ +# memory/_index.md + +_Catalog of {{AGENT_NAME}}'s tiered memory. Keep this in sync by hand whenever you add, demote, or archive a file._ + + + +## How this works + +{{AGENT_NAME}}'s memory is organized in TIERS so the always-loaded layer (sent +on every turn) stays small, and tactical detail lives in files that are pulled +in on demand via `memory_search` and `memory_get`. + +``` +L1 (always loaded) -> MEMORY.md, SOUL.md, IDENTITY.md, USER.md, AGENTS.md, TOOLS.md, HEARTBEAT.md +L2 (load on demand) -> memory/*.md, memory/infra/*.md, memory/profile.yaml +L3 (raw journal) -> memory/YYYY-MM-DD.md +L4 (archive) -> memory/archive/ (kept for the lifetime of the record, almost never read) +``` + +- **L1 - always loaded.** Durable, every-turn-relevant facts plus pointers. Stays SMALL (target <80 lines for MEMORY.md). If it grows, demote detail to L2. +- **L2 - load on demand.** Topical files. This is where the substance lives. Curated, not raw. +- **L3 - daily journal.** Raw, same-day, narrative. Written when something incident- or learning-grade happens. +- **L4 - archive.** Historically true but no longer actionable. Never deleted, just moved here. Lifetime memory matters. + +**Discovery rule for fresh agents:** read MEMORY.md -> it points here -> use this index to find what you need. + +## L2 - topical files + + + +| File | What's in it | +|---|---| +| `profile.yaml` | Stable structured facts about {{USER_SHORT_NAME}} (location, timezone, identifiers). | +| `health-snapshot.md` | Detailed clinical picture - meds, flagged conditions, latest labs, hypothesis, care team. Re-read for any health-deep question. | +| `stewardship.md` | Data-stewardship frame ({{USER_DATA_FRAME}}). Re-read before talking about security or compliance. | +| `skills.md` | Workspace skills authored for this tenant + the skill-authoring stack. | +| `infra/backups.md` | Backup architecture, guards, runbook pointer. | +| `infra/coding-agents.md` | Which coding agents are wired up, how to spawn each. | +| `infra/search-tools.md` | Web / social search provider config. | +| `infra/voice-tts.md` | Text-to-speech provider and voice config. | +| `infra/parked-bugs.md` | Known bugs to fix later. | +| `infra/known-issues.md` | Recurring infra problems with fix recipes. | + +## L3 - dated daily notes + +Format: `memory/YYYY-MM-DD.md`. Raw chronological journal. **Write here the same day** when something incident-grade or learning-grade happens. See `memory/YYYY-MM-DD.template.md` for the shape. + + + +Currently present (newest first): +- _(none yet - the first daily note will land here)_ + +## L4 - archive + +`memory/archive/` - historically true content that's no longer actionable. Empty until the first thing goes stale. + +## Rules of promotion / demotion + +1. **Daily notes are raw.** Write same-day, full detail, narrative voice. +2. **L2 files are curated.** When a daily note contains durable info (a fix recipe, an architecture decision, a calibration like a corrected assumption), promote it into the relevant L2 topical file. +3. **MEMORY.md is durable + small.** Only durable facts that must be available *every turn* go there - and pointers to L2, not the content itself. +4. **Archive when stale, never delete.** If something is no longer true but was once important, `mv` it to `archive/`. + +## Weekly review + + + +A weekly review job can read the last 7 days of daily notes and propose: items to promote into L2, L2 items that are stale and should move to archive, and MEMORY.md lines that have been superseded. Configure cadence and delivery channel to taste. diff --git a/templates/memory/health-snapshot.template.md b/templates/memory/health-snapshot.template.md new file mode 100644 index 0000000..da30a1e --- /dev/null +++ b/templates/memory/health-snapshot.template.md @@ -0,0 +1,77 @@ +# Health snapshot - {{USER_FULL_NAME}} + +_Detailed clinical picture. MEMORY.md keeps a short summary; this file holds the longitudinal substance._ + + + + +## Frame + + +## Current active medications + + +| Med | Dose / route | Started | For | +|---|---|---|---| +| | | | | + +## Still flagged + + +## Latest labs + + +| Marker | Value | +|---|---| +| | | + +## Encouraging signals + + +## Activity baseline + + +## Clinical hypothesis + + +## Still unanswered + + +## Care team +- **PCP:** {{PCP_NAME}} +- **Health system:** {{HEALTH_SYSTEM}} +- **FHIR base:** {{FHIR_BASE_URL}} +- **MRN:** {{MRN}} + + +## Source-of-truth records + + +**Always read from the source export before making clinical statements - don't rely on this summary file alone.** diff --git a/templates/memory/infra/backups.template.md b/templates/memory/infra/backups.template.md new file mode 100644 index 0000000..a38994e --- /dev/null +++ b/templates/memory/infra/backups.template.md @@ -0,0 +1,64 @@ +# Backups + + + +## Architecture (one-liner) +`~/.openclaw/` -> `rsync --delete` (with PURGE list) -> local git repo -> `git push` -> `{{TENANT_BACKUP_REPO}}` (private). + +## Schedule + + +## Script +`scripts/aria-backup.sh` (source of truth lives in the tula source repo; deployed copy runs on the VM). + +## Pre-commit guards (all must pass before push) +1. **Secret scan** - regex for tokens/keys, with an allowlist for false positives. Aborts on hit. +2. **Large-file guard** - refuses to stage any file over the configured cap (default 50 MB, under GitHub's 100 MB hard cap). Aborts on hit. +3. **Privacy guard** - checks remote visibility and refuses to push if the repo is not PRIVATE. Aborts on hit. + +## What's backed up + + +## What's NOT backed up (and why) +| Path | Reason | +|---|---| +| `credentials/`, `auth-profiles*.json`, `identity/device.json`, `identity/device-auth.json`, `devices/paired.json` | Live secrets / device private keys | +| `openclaw.json*` | Holds every provider API key | +| `agents/main/sessions/` | Chat trajectories - large + privacy-sensitive | +| `logs/`, `update-check.json`, `exec-approvals.json` | Regenerable noise | +| `plugin-runtime-deps/`, `npm/` | Third-party code, hundreds of MB, reinstallable via `openclaw plugins install` | +| `**/.git/` (except the backup repo's own) | Nested checkouts | + +## Repo posture + +- **Repo:** `{{TENANT_BACKUP_REPO}}` +- **Branch:** `{{TENANT_BACKUP_BRANCH}}` +- **Visibility:** PRIVATE (enforced by the privacy guard) + +## Runbook +**Full operator runbook:** `scripts/BACKUP-RUNBOOK.md` - full DR clone on a fresh VM, partial file recovery, time-travel restore, monthly integrity check, common-failure table, threat model. + +## Active-state checks +```bash +# Is the timer healthy? +systemctl status aria-backup.timer aria-backup.service --no-pager -l + +# Last successful push? +gh repo view {{TENANT_BACKUP_REPO}} --json pushedAt -q .pushedAt + +# Local vs remote drift? +cd ~/aria-repo && git fetch origin && git status -sb +``` + +## When NOT to re-recommend +Once backups are running, don't propose setting them up. If asked "is the backup working?" - actually run the checks above; don't extrapolate. diff --git a/templates/memory/infra/coding-agents.template.md b/templates/memory/infra/coding-agents.template.md new file mode 100644 index 0000000..555bfdb --- /dev/null +++ b/templates/memory/infra/coding-agents.template.md @@ -0,0 +1,46 @@ +# Coding agents available + + + +## (status: ) +- **Harness:** +- **Binary:** - version +- **Spawn:** `sessions_spawn` with `runtime: "acp"` and the correct `agentId`. + +- **When to use:** real agentic coding tasks (multi-file builds, refactors, + longer autonomous work). Don't delegate trivial edits - the agent's own + read/write/edit/exec tools handle those. +- **Last verified:** + +### Permission config (platform note) + + +### This agent's own workspace + + +## Agents that are NOT available + + +## Host agent's own tools (always available) +- `read`, `write`, `edit`, `exec` - for quick code changes, file ops, shell commands. No need to delegate trivial edits. diff --git a/templates/memory/infra/known-issues.template.md b/templates/memory/infra/known-issues.template.md new file mode 100644 index 0000000..08be0f5 --- /dev/null +++ b/templates/memory/infra/known-issues.template.md @@ -0,0 +1,41 @@ +# Known infra issues (with fixes) + +_Things that go wrong on this VM. If symptoms match, apply the fix and move on._ + + + + + +## Gateway restart kills the session (platform note) +- **Symptom:** Running `openclaw gateway restart` mid-conversation SIGTERMs the agent's exec context; the tool result vanishes. +- **Cause:** Expected - the restart tears down the runtime hosting the agent. +- **Fix:** Just continue after the restart. Gateway comes back in <10s. Verify with `openclaw status` next turn. + +## Plugin install footgun (platform note) +- **Symptom:** `openclaw plugins install ` fails with a config-validator error referencing `tools.web.search.provider`. +- **Cause:** The installer rewrites `openclaw.json`; the validator blocks the install if the search provider references a plugin not yet installed. +- **Fix:** Keep `tools.web.search.provider` on a working value during install, then flip after. The installer auto-creates a `.bak`. + +## Config writes trigger "deferred reload" (platform note) +- **Symptom:** Gateway logs `config change detected; evaluating reload (...)` then `restart still deferred after Nms with M operation(s)...`. +- **Cause:** The runtime defers config reloads while an agent run is in flight; if it never gets a quiet moment, the change doesn't apply. +- **Fix:** Restart the gateway during an idle moment, or wait for natural quiet. + + diff --git a/templates/memory/infra/parked-bugs.template.md b/templates/memory/infra/parked-bugs.template.md new file mode 100644 index 0000000..bf44e78 --- /dev/null +++ b/templates/memory/infra/parked-bugs.template.md @@ -0,0 +1,24 @@ +# Parked bugs + +_Things known broken or working-but-not-right. Fix with a fresh session when there's time._ + + + + + +_No parked bugs yet._ diff --git a/templates/memory/infra/search-tools.template.md b/templates/memory/infra/search-tools.template.md new file mode 100644 index 0000000..58948e1 --- /dev/null +++ b/templates/memory/infra/search-tools.template.md @@ -0,0 +1,29 @@ +# Web & social search tools + + + +## General web - `web_search` +- **Provider:** +- **Configured:** +- **Key location:** `plugins.entries..config.webSearch.apiKey` (in openclaw.json - not in this file, not in backup) +- **Provider selector:** `tools.web.search.provider = ""` +- **Notes:** + +## Social / X - `x_search` +- **Provider:** +- **Configured:** +- **Key location:** `plugins.entries..config.webSearch.apiKey` +- **Tool config:** `plugins.entries..config.xSearch.enabled = true` +- **Parameters:** `query`, `allowed_x_handles`, `excluded_x_handles`, `from_date`, `to_date`, and media-understanding flags. +- **Notes:** + +## Installer footgun (platform note) +`openclaw plugins install` rewrites `openclaw.json`, and the config validator blocks the install if `tools.web.search.provider` references a plugin that isn't installed yet. **Workaround:** keep the provider on a working value during install, then flip it after. The installer auto-creates a `.bak` of the pre-install config. + +## Gateway-restart gotcha (platform note) +Running `openclaw gateway restart` mid-conversation SIGTERMs the agent's own exec context. The restart still succeeds (verify via status next turn). Don't panic when the tool result vanishes - the gateway is back in <10s. diff --git a/templates/memory/infra/voice-tts.template.md b/templates/memory/infra/voice-tts.template.md new file mode 100644 index 0000000..91fc643 --- /dev/null +++ b/templates/memory/infra/voice-tts.template.md @@ -0,0 +1,22 @@ +# Voice (TTS) + + + +- **Configured:** +- **Provider:** +- **Voice:** +- **Mode:** +- **Output format:** +- **Config location:** `messages.tts.*` in `~/.openclaw/openclaw.json` +- **Slash commands:** `/tts status`, `/tts audio `, `/tts off` + +## Upgrade path (if quality matters) + + +## Gotcha (platform note) +Editing the `messages` block in `openclaw.json`: add to the EXISTING `messages` block, don't create a second one - two `messages` blocks collapse to invalid config. Config writes may log `config change detected; evaluating reload (messages.tts)` and defer the actual reload until the session quiets (same deferred-reload pattern noted in known-issues.md). diff --git a/templates/memory/skills.template.md b/templates/memory/skills.template.md new file mode 100644 index 0000000..845ce97 --- /dev/null +++ b/templates/memory/skills.template.md @@ -0,0 +1,39 @@ +# Workspace skills (mine) + + + +## How they fit together + + + + +## Skill-authoring stack + diff --git a/templates/memory/stewardship.template.md b/templates/memory/stewardship.template.md new file mode 100644 index 0000000..225810f --- /dev/null +++ b/templates/memory/stewardship.template.md @@ -0,0 +1,61 @@ +# Data stewardship frame + + + + + +## The frame +**{{USER_FULL_NAME}} relates to their data as: {{USER_DATA_FRAME}}.** + + +## Implications + + +- **Match the security posture to the frame, not to the worst case.** For self-stewardship, a private repo + TLS + 2FA + a fine-grained token is the right answer - not a baseline that needs hardening. For a covered-entity frame, the opposite: the regulatory floor is the requirement. +- **Don't pitch controls the frame doesn't call for.** (Self-stewardship example: don't pitch at-rest encryption of the data cache, HSMs, audit logging, BAAs, or "defense in depth" framed around regulatory exposure - unless asked.) +- **Guards in the tooling exist to catch the user's own mistakes**, not to satisfy auditors - unless the frame is regulated, in which case they may need to satisfy both. +- **Answer in-frame.** When the user asks about backups, security, or data handling, respond for *their* frame - don't default to enterprise compliance. + +## What that sounds like in practice + + +- (x) *"For defense in depth, consider encrypting the cache before commit so only you hold the key."* +- (check) *"Private repo + 2FA is the right posture for personal infra. Want me to watch anything specific, or are we good?"* + +- (x) *"This isn't compliance-grade audit logging."* +- (check) *"The hourly service journal is enough for you to debug a failure later - that's what you actually need."* + +- (x) *"You should rotate the token every 90 days per best practice."* +- (check) *"Token's fine until it expires or you suspect it leaked."* diff --git a/templates/profile.template.yaml b/templates/profile.template.yaml new file mode 100644 index 0000000..1fc7ae9 --- /dev/null +++ b/templates/profile.template.yaml @@ -0,0 +1,44 @@ +# Personal pulse profile for myhealth-pulse. +# Read by the skill at runtime from ~/.openclaw/workspace/memory/profile.yaml. +# Schema: see skills/myhealth-pulse/references/profile-schema.md in +# https://github.com/realactivity/tula (Apache-2.0). +# +# This file is personal. It belongs in the tenant's private backup repo, +# never in the public tula repo. The skill itself is generic; this file is +# what makes the skill *theirs*. +# +# Onboarding populates the identity + social blocks from conversation. +# Topics + sources start as the sensible defaults below; the user can +# refine over time. + +version: 1 + +identity: + display_name: "{{USER_FULL_NAME}}" + short_name: "{{USER_SHORT_NAME}}" + +social: + # Onboarding asks "do you want to mention any social handles you follow for + # health content?" - populates this block from the answer. Leave empty if + # the user prefers not to share. + # - platform: x + # handle: "@example" + +topics: + # Default seed topics. The user can edit these any time. + primary: + - "health AI" + - "patient-facing AI" + - "wearables" + - "health-data privacy" + secondary: + - "digital therapeutics" + - "health-data exchange" + - "healthcare cybersecurity" + excluded: + # Things the user explicitly does NOT want surfaced. + - "billing" + - "payor news" + - "EOB" + - "prior authorization" + - "medical coding" diff --git a/templates/scripts/BACKUP-RUNBOOK.template.md b/templates/scripts/BACKUP-RUNBOOK.template.md new file mode 100644 index 0000000..8aeb930 --- /dev/null +++ b/templates/scripts/BACKUP-RUNBOOK.template.md @@ -0,0 +1,192 @@ +# Tula Backup - Operator Runbook ({{TENANT_ID}}) + +_Last verified end-to-end: {{BACKUP_LAST_VERIFIED_AT}}_ + + + +## What this protects + +Everything the tenant's agent needs to wake up in the same state on a fresh VM: + +- `~/.openclaw/workspace/` - the agent's brain (MEMORY.md, daily notes, skills, caches, USER.md, IDENTITY.md) +- `~/.openclaw/agents/*/agent/` - model and plugin catalogs (sans auth tokens) +- `~/.openclaw/cron/`, `flows/`, `canvas/`, etc. - runtime state +- `~/.openclaw/identity/` - node identity (sans private device key) + +**Excluded by design** (regenerable or sensitive): +- `credentials/`, `auth-profiles*.json`, `identity/device.json`, `identity/device-auth.json`, `devices/paired.json`, `openclaw.json*` - all hold live API keys or device private keys. Rotate after restore. +- `logs/`, `update-check.json`, `exec-approvals.json` - local noise. +- `plugin-runtime-deps/`, `npm/` - third-party code; reinstall via `openclaw plugins install`. +- `agents/main/sessions/` - chat trajectories, large + privacy-sensitive. +- `**/.git/` (except the backup repo's own) - nested checkouts. + +## Architecture (one-liner) + +`~/.openclaw/` -> `rsync --delete` (with PURGE list) -> `~/aria-repo/` -> `git push` -> `{{TENANT_BACKUP_REPO}}` (PRIVATE). + +Runs hourly via `aria-backup.timer` systemd unit. Logs go to `journalctl -u aria-backup.service`. + +## Pre-flight checks + +```bash +# Is the repo still private? +gh repo view {{TENANT_BACKUP_REPO}} --json visibility -q .visibility +# expected: PRIVATE + +# Is the timer healthy? +systemctl status aria-backup.timer aria-backup.service --no-pager -l + +# When did the last successful backup land? +gh repo view {{TENANT_BACKUP_REPO}} --json pushedAt -q .pushedAt + +# How many commits behind/ahead is local? +cd ~/aria-repo && git fetch origin && git status -sb +``` + +## Restore: full disaster recovery on a fresh VM + +```bash +# 0. On the new VM, install prerequisites +sudo apt update && sudo apt install -y git curl rsync python3 +curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list +sudo apt update && sudo apt install -y gh + +# 1. Authenticate to GitHub as the operator (account that owns {{TENANT_BACKUP_REPO}}) +gh auth login # choose: {{TENANT_BACKUP_REMOTE_HOST}}, HTTPS, login with browser + +# 2. Clone the backup +mkdir -p ~/restore && cd ~/restore +git clone https://{{TENANT_BACKUP_REMOTE_HOST}}/{{TENANT_BACKUP_REPO}}.git +cd $(basename {{TENANT_BACKUP_REPO}}) + +# 3. Install OpenClaw (gives us ~/.openclaw scaffold) +# Follow https://docs.openclaw.ai for the current install command. + +# 4. Restore the workspace (overlay onto the empty scaffold) +rsync -a --exclude=.git --exclude=README.md --exclude=scripts --exclude=aria-backup.sh \ + ./ ~/.openclaw/ + +# 5. Re-pair this node (regenerates identity/device.json + device-auth.json) +openclaw # follow the setup-code/QR prompt + +# 6. Re-add provider API keys (they were NOT backed up) +# Restore from your password manager. + +# 7. Reinstall plugins +openclaw plugins install # repeat for each from prior config + +# 8. Re-enable the backup itself +sudo cp scripts/systemd/aria-backup.service /etc/systemd/system/ +sudo cp scripts/systemd/aria-backup.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now aria-backup.timer + +# 9. Sanity-check +openclaw status +ls -la ~/.openclaw/workspace/MEMORY.md +``` + +## Restore: partial - grab one file + +```bash +git clone --depth 1 https://{{TENANT_BACKUP_REMOTE_HOST}}/{{TENANT_BACKUP_REPO}}.git /tmp/restore +cp /tmp/restore/workspace/memory/.md ~/.openclaw/workspace/memory/ +rm -rf /tmp/restore +``` + +## Restore: time-travel - file as of a specific date + +```bash +cd ~/aria-repo +git fetch origin +git log --before="" --oneline | head -1 +git show :workspace/MEMORY.md > /tmp/MEMORY-snapshot.md +``` + +## Verify backup integrity (monthly) + +```bash +TEST_DIR=$(mktemp -d) +cd "$TEST_DIR" +git clone --depth 1 https://{{TENANT_BACKUP_REMOTE_HOST}}/{{TENANT_BACKUP_REPO}}.git restored +cd $(basename {{TENANT_BACKUP_REPO}}) + +# Critical files present? +for f in workspace/MEMORY.md {{TENANT_RECORDS_PATH}}; do + test -f "$f" && echo "OK: $f ($(du -h "$f" | cut -f1))" || echo "MISSING: $f" +done + +# Cache JSON intact? (adapt this check to the tenant's actual cache schema) +python3 -c " +import json, sys +with open('{{TENANT_RECORDS_PATH}}') as f: d = json.load(f) +# tenant-specific assertions go here +print('OK: integrity check passed') +" + +# Secrets NOT present? (these MUST always be absent) +for bad in credentials openclaw.json identity/device.json agents/main/agent/auth-profiles.json; do + test -e "$bad" && echo "LEAK: $bad SHOULD NOT BE HERE" || echo "OK: $bad correctly excluded" +done + +cd / && rm -rf "$TEST_DIR" +``` + +## Common failures + +| Symptom | Cause | Fix | +|---|---|---| +| `push: FAILED - check git auth` | Token expired, or repo divergence from manual commit | `cd ~/aria-repo && git pull --rebase origin {{TENANT_BACKUP_BRANCH}} && git push` | +| `large-file guard FAILED` | A new openclaw plugin dropped a big binary somewhere | Add the parent dir to `PURGE` in `aria-backup.sh` and to `.gitignore` | +| `Secret-pattern scan FAILED` | New file in workspace contains a real-looking secret | If real: add to `PURGE`. If false positive: add glob to `ALLOWLIST_GLOBS` with a comment | +| `privacy guard: REFUSING to push ... is PUBLIC` | Repo visibility was hand-toggled in the GitHub UI | Flip it back: `gh repo edit {{TENANT_BACKUP_REPO}} --visibility private --accept-visibility-change-consequences` | +| Push rejected `pre-receive hook declined`, "File X is N MB" | Old large file still in history | `git-filter-repo --invert-paths --path ` then `git push --force origin {{TENANT_BACKUP_BRANCH}}` | +| Hourly timer healthy but `pushedAt` is stale | Backup running but push silently failing | `journalctl -u aria-backup.service -n 100 --no-pager` | +| `not a git repo: $ARIA_REPO_DIR` | `~/aria-repo` got nuked | Re-clone: `git clone https://{{TENANT_BACKUP_REMOTE_HOST}}/{{TENANT_BACKUP_REPO}}.git ~/aria-repo` | + +## Things that should NOT be backed up here + +If you find any of these in the repo, treat it as a security incident: +- Anything matching `*-private-key*`, `*.pem`, `*.token` +- Live API keys (model providers, search providers, voice providers, messaging bot tokens) +- Filebrowser admin password +- Messaging-platform pairing secrets +- Device Ed25519 private key (`identity/device.json`) +- **Anyone else's health data** - this backup is for the single tenant identified by `{{TENANT_ID}}` only + +If found: add to `PURGE` array, run `git-filter-repo --invert-paths --path `, force-push, and **rotate the leaked credential**. + +## Threat model & known risks + +**Protects against:** +- VM disk failure / accidental `rm -rf` of `~/.openclaw/` +- Cloud-region outage (GitHub is separate from the VM's hosting provider) +- Single-bad-edit regret (any prior commit recoverable) + +**Does NOT protect against:** +- GitHub account takeover - whoever holds `{{TENANT_GITHUB_LOGIN}}`'s credentials reads everything. Mitigation: 2FA + passkey on the account, fine-grained tokens for the gh CLI. +- GitHub itself being breached - tenant data is encrypted in transit (TLS) and at rest (GitHub server-side), but not under a key only the operator holds. Optional hardening: `age`-encrypt sensitive directories before commit, with the key in a separate password manager. +- Compromise of `~/.openclaw/openclaw.json` (not in backup, but on the VM) - that file holds every provider API key. VM compromise -> key leak. + +## Operator contacts (filled by onboarding skill) + +- **Tenant private backup repo:** https://{{TENANT_BACKUP_REMOTE_HOST}}/{{TENANT_BACKUP_REPO}} (PRIVATE) +- **Backup script:** `~/tula/scripts/aria-backup.sh` +- **Systemd units:** `/etc/systemd/system/aria-backup.{timer,service}` +- **Filled instance of this runbook:** lives in the tenant's PRIVATE backup repo at `workspace/operator/BACKUP-RUNBOOK.md`, NOT in the public `realactivity/tula` repo.