Skill: clawboard (version 1.0.1)

--- name: clawboard description: "Install and operate Clawboard with OpenClaw. Use for scripted/manual/agentic install, token setup, Docker startup, logger plugin wiring, and validation." --- # Clawboard ## Status - ClawHub: **not available yet**. - Install path today: scripted bootstrap or manual repo install. - If OpenClaw is missing and user wants Chutes provider, start with [`add_chutes.sh`](../../inference-providers/add_chutes.sh) after creating a `https://chutes.ai` account. ## Current Architecture Snapshot - `web` (Next.js): `http://localhost:3010` - `api` (FastAPI + SQLite): `http://localhost:8010` - `classifier` (async stage-2 worker): topic/task classification loop - `qdrant` (vector index): dense retrieval backend on internal Docker network - `clawboard-logger` plugin: stage-1 firehose logging + response-time context extension Retrieval/search stack: - Dense vectors + BM25 + lexical matching - Reciprocal rank fusion + reranking - Qdrant primary, SQLite embeddings mirror/fallback ## Goal Get a user to a working Clawboard install where: 1. Clawboard web/api/classifier are running. 2. `clawboard-logger` plugin is installed and enabled. 3. Token flow is configured correctly (required for writes + non-localhost reads). 4. OpenClaw gateway is restarted and logging into Clawboard. ## Hard Rules (Repo vs Installed Skill) - Repo copy (version controlled): `$CLAWBOARD_DIR/skills/clawboard` - Installed skill path (what OpenClaw reads): `$HOME/.openclaw/skills/clawboard` - **Default install mode is symlink** (`~/.openclaw/skills/clawboard -> $CLAWBOARD_DIR/skills/clawboard`). - Detect mode deterministically at runtime: ```bash if [ -L "$HOME/.openclaw/skills/clawboard" ]; then echo "symlink" else echo "copy" fi ``` - In **symlink mode** (default), repo edits are immediately visible to OpenClaw. - In **copy mode**, repo edits do not update OpenClaw until you sync/copy again. After skill file changes (SKILL.md, `agents/`, `references/`, `scripts/`), sync into OpenClaw: ```bash cd "$CLAWBOARD_DIR" bash scripts/sync_openclaw_skill.sh --to-openclaw --apply --force ``` - If you changed files under `~/.openclaw/skills/clawboard` while in copy mode, sync them back before committing: ```bash cd "$CLAWBOARD_DIR" bash scripts/sync_openclaw_skill.sh --to-repo --apply ``` ## Workspace + Runtime Assumptions (for coding tasks) - Typical repo locations for Clawboard code: - `~/[agent_name]/clawboard` - `~/[agent_name]/projects/clawboard` - For scripted installs, bootstrap auto-detects OpenClaw workspace conventions and usually lands in one of those layouts. - When asked to work on Clawboard frontend/backend, prefer the active git repo copy under those locations, not `~/.openclaw/skills/*`. - After typical bootstrap, assume Docker services are running: - `CLAWBOARD_WEB_HOT_RELOAD=1`: `web-dev` is used for Next.js hot reload (`web` is stopped). - `CLAWBOARD_WEB_HOT_RELOAD=0`: production-style `web` service is used. - Fast runtime check commands: - `docker compose ps` - `echo "$CLAWBOARD_WEB_HOT_RELOAD"` (or read from `$CLAWBOARD_DIR/.env`) - `curl -s http://localhost:8010/api/health` - Parity rules while editing: - Skill path mode should be checked first (`test -L ~/.openclaw/skills/clawboard`). - If symlink mode: edit repo files directly (`$CLAWBOARD_DIR/skills/clawboard/**`). - If copy mode: sync to OpenClaw after edits (`bash scripts/sync_openclaw_skill.sh --to-openclaw --apply --force`). - Logger plugin edits (`extensions/clawboard-logger/**`) must be reinstalled/enabled in OpenClaw: - `openclaw plugins install -l "$CLAWBOARD_DIR/extensions/clawboard-logger"` - `openclaw plugins enable clawboard-logger` - `~/.openclaw/skills/clawboard-logger` is optional and may not exist by default. If your environment has it, keep it synced with its repo copy explicitly. ## Install Modes ### 1) Quick Scripted Install (recommended) Use: ```bash curl -fsSL https://raw.githubusercontent.com/sirouk/clawboard/main/scripts/bootstrap_openclaw.sh | bash ``` What the script does: - Clones/updates repo by auto-detecting your OpenClaw workspace. If it finds a `projects/` (or `project/`) convention, it installs there; otherwise it falls back to `~/clawboard`. - Override with `--dir <path>`, `CLAWBOARD_DIR=<path>`, or `CLAWBOARD_PARENT_DIR=<path>` (installs to `<parent>/clawboard`). - Generates a token if missing and writes `.env` with `CLAWBOARD_TOKEN`. - Detects browser access URLs (Tailscale if available, else localhost) and writes `.env` `CLAWBOARD_PUBLIC_API_BASE` and `CLAWBOARD_PUBLIC_WEB_URL`. - Builds and starts Docker services. - Ensures `web` + `api` + `classifier` + `qdrant` are running. - Installs skill at `$HOME/.openclaw/skills/clawboard` (default: symlink to repo skill; optional copy mode). - Installs/enables `clawboard-logger` plugin. - Writes plugin config (`baseUrl`, `token`, `enabled`) via `openclaw config set`. - Ensures OpenClaw OpenResponses endpoint is enabled (`POST /v1/responses`) for attachments. - Restarts OpenClaw gateway. - Sets `/api/config` title + integration level. If `openclaw` CLI is not installed yet, the script still deploys Clawboard and prints follow-up instructions. It now also offers to run the Chutes fast path automatically when `openclaw` is missing. Useful flags: - `--integration-level full|write|manual` (default `write`) - `--no-backfill` (same as `manual`) - `--api-url http://localhost:8010` - `--web-url http://localhost:3010` - `--web-hot-reload` / `--no-web-hot-reload` - `--public-api-base https://api.example.com` - `--public-web-url https://clawboard.example.com` - `--token <token>` - `--title "<name>"` - `--skill-symlink` (default) - `--skill-copy` (fallback if you do not want symlink mode) - `--update` ### 2) Human Manual Install Prereqs: - `git`, `docker` (+ compose), `openclaw` CLI - Docker Desktop on macOS Steps: 1. Clone repo: ```bash CLAWBOARD_PARENT_DIR="${CLAWBOARD_PARENT_DIR:-}" CLAWBOARD_DIR="${CLAWBOARD_DIR:-${CLAWBOARD_PARENT_DIR:+$CLAWBOARD_PARENT_DIR/clawboard}}" CLAWBOARD_DIR="${CLAWBOARD_DIR:-$HOME/clawboard}" git clone https://github.com/sirouk/clawboard "$CLAWBOARD_DIR" cd "$CLAWBOARD_DIR" ``` 2. Create token and env: ```bash cp .env.example .env openssl rand -hex 32 ``` Set `CLAWBOARD_TOKEN=<value>` in `$CLAWBOARD_DIR/.env`. Set `CLAWBOARD_PUBLIC_API_BASE=<browser-reachable-api-url>` in `$CLAWBOARD_DIR/.env`. Optional: set `CLAWBOARD_PUBLIC_WEB_URL=<browser-reachable-ui-url>` in `$CLAWBOARD_DIR/.env`. Examples: - local: `http://localhost:8010` - tailscale: `http://100.x.y.z:8010` - custom domain: `https://api.example.com` 3. Start Clawboard: ```bash docker compose up -d --build ``` 4. Install skill: ```bash mkdir -p "$HOME/.openclaw/skills" rm -rf "$HOME/.openclaw/skills/clawboard" ln -s "$CLAWBOARD_DIR/skills/clawboard" "$HOME/.openclaw/skills/clawboard" ``` If you explicitly want copy mode instead of symlink: ```bash mkdir -p "$HOME/.openclaw/skills" rm -rf "$HOME/.openclaw/skills/clawboard" cp -R "$CLAWBOARD_DIR/skills/clawboard" "$HOME/.openclaw/skills/clawboard" ``` If user keeps skills elsewhere, use that path instead of `$HOME/.openclaw/skills`. 5. Install + enable logger plugin: ```bash openclaw plugins install -l "$CLAWBOARD_DIR/extensions/clawboard-logger" openclaw plugins enable clawboard-logger ``` 6. Configure plugin (writes into OpenClaw config): ```bash # contextMode options: auto | cheap | full | patient openclaw config set plugins.entries.clawboard-logger.config --json '{ "baseUrl":"http://localhost:8010", "token":"YOUR_TOKEN", "enabled":true, "contextMode":"auto", "contextFallbackMode":"cheap", "contextFetchTimeoutMs":1200, "contextTotalBudgetMs":2200, "contextMaxChars":2200 }' openclaw config set plugins.entries.clawboard-logger.enabled --json true ``` 7. Enable OpenResponses (recommended for attachments): ```bash openclaw config set gateway.http.endpoints.responses.enabled --json true ``` 8. Restart gateway: ```bash openclaw gateway restart ``` Notes: - OpenClaw config is stored at `$HOME/.openclaw/openclaw.json`. - Keep plugin `token` aligned with API server `CLAWBOARD_TOKEN`. ### 3) Agentic Install (copy/paste into OpenClaw) Use this exact prompt: ```md Install Clawboard for me end-to-end. ClawHub is not available yet, so choose one of these: 1) Scripted install (preferred): - Run: curl -fsSL https://raw.githubusercontent.com/sirouk/clawboard/main/scripts/bootstrap_openclaw.sh | bash 2) Manual install: - Clone repo to `$CLAWBOARD_DIR` - Create `CLAWBOARD_TOKEN` and write `$CLAWBOARD_DIR/.env` - Set `CLAWBOARD_PUBLIC_API_BASE` (local/Tailscale/custom domain) in `$CLAWBOARD_DIR/.env` - Set `CLAWBOARD_PUBLIC_WEB_URL` (local/Tailscale/custom domain) in `$CLAWBOARD_DIR/.env` - Start docker compose - Symlink skill to $HOME/.openclaw/skills/clawboard (default; copy only if explicitly requested) - Install/enable clawboard-logger plugin - Set plugin config (baseUrl + token) in OpenClaw config - Restart gateway After install, validate: - http://localhost:8010/api/health - http://localhost:8010/api/config - plugin enabled + gateway restarted - send one test message and confirm it appears in Clawboard logs Ask me before choosing local vs Tailscale API base URL. ``` Security reminder for all methods: - `CLAWBOARD_TOKEN` is required for all writes and all non-localhost reads. - Localhost reads can run tokenless (read-only default posture). - Keep network boundaries strict (localhost/firewall/Tailscale ACLs; avoid Funnel/public exposure unless explicitly intended). - Compose defaults keep vector/db/cache services off host ports; use the API as the supported read/write/delete interface. ## Validation Checklist Run: ```bash curl -s http://localhost:8010/api/health curl -s http://localhost:8010/api/config openclaw plugins list | rg clawboard-logger curl -s "http://localhost:8010/api/search?q=continuity" ``` Expect: - API health is `ok`. - `tokenRequired` is `true`. - `tokenConfigured` is `true`. - Logger plugin is enabled. - Search endpoint returns mode/details (and will include qdrant mode when vectors are available). - New OpenClaw message appears in Clawboard Logs. ## Optional Helpers - Local memory setup script: - `$CLAWBOARD_DIR/skills/clawboard/scripts/setup-openclaw-local-memory.sh` - **Curated memory cloud backup (GitHub private repo):** - Setup (interactive): - `$CLAWBOARD_DIR/skills/clawboard/scripts/setup-openclaw-memory-backup.sh` - Backup run (safe for automation; commits/pushes only when changed): - `$CLAWBOARD_DIR/skills/clawboard/scripts/backup_openclaw_curated_memories.sh` - Includes optional full Clawboard state export (config/topics/tasks/logs + optional attachments). - Stores config at: - `$HOME/.openclaw/credentials/clawboard-memory-backup.json` (chmod 600) - Chutes provider helper: - `curl -fsSL https://raw.githubusercontent.com/sirouk/clawboard/main/inference-providers/add_chutes.sh | bash` ## References - `references/clawboard-api.md` - `references/openclaw-hooks.md` - `references/openclaw-memory-local.md`

path: scripts/setup-openclaw-memory-backup.sh
content: #!/usr/bin/env bash
set -euo pipefail

# setup-openclaw-memory-backup.sh
#
# Interactive setup:
# - Detect OpenClaw workspace path from ~/.openclaw/openclaw.json
# - (Optional) Create a new private GitHub repo via `gh` (if available/auth'd)
# - Prefer GitHub Deploy Key (SSH) for auth (recommended)
#   - Generates a dedicated SSH keypair
#   - Prints instructions to add it as a Deploy Key (WRITE access reminder)
#   - Saves key path in ~/.openclaw/credentials/clawboard-memory-backup.env
# - Writes config to ~/.openclaw/credentials/clawboard-memory-backup.json (0600)
# - Optionally includes full Clawboard state export + attachment files in each backup run
# - Optionally installs an OpenClaw cron job (agentTurn) to run backup every 15m

say() { printf "%s\n" "$*"; }
die() { say "ERROR: $*" >&2; exit 2; }

OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/.openclaw}"
OPENCLAW_JSON="$OPENCLAW_DIR/openclaw.json"
CRED_DIR="$OPENCLAW_DIR/credentials"
CRED_JSON="$CRED_DIR/clawboard-memory-backup.json"
CRED_ENV="$CRED_DIR/clawboard-memory-backup.env"

need_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; }
need_cmd python3
need_cmd mkdir
need_cmd chmod
need_cmd cat
need_cmd ssh-keygen

has_cmd() { command -v "$1" >/dev/null 2>&1; }

# bash 3.2 compatibility (macOS default /bin/bash): no ${var,,}
lc() { tr '[:upper:]' '[:lower:]' <<<"${1:-}"; }

workspace_from_config() {
  [[ -f "$OPENCLAW_JSON" ]] || return 1
  python3 - <<'PY' "$OPENCLAW_JSON"
import json,sys
p=sys.argv[1]
try:
  d=json.load(open(p))
  print(d.get('agents',{}).get('defaults',{}).get('workspace','') or '')
except Exception:
  print('')
PY
}

read_env_value() {
  local file_path="$1"
  local key="$2"
  local line
  [[ -f "$file_path" ]] || return 1
  line="$(awk -v key="$key" '
    $0 ~ "^[[:space:]]*" key "=" {
      print substr($0, index($0, "=") + 1)
    }
  ' "$file_path" | tail -n1)"
  line="${line//$'\r'/}"
  line="${line#"${line%%[![:space:]]*}"}"
  line="${line%"${line##*[![:space:]]}"}"
  line="${line%\"}"
  line="${line#\"}"
  line="${line%\'}"
  line="${line#\'}"
  [[ -n "$line" ]] || return 1
  printf "%s" "$line"
}

detect_clawboard_dir() {
  local script_dir candidate
  script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  candidate="$(cd "$script_dir/../../.." >/dev/null 2>&1 && pwd || true)"
  if [[ -n "$candidate" && -f "$candidate/deploy.sh" && -f "$candidate/docker-compose.yaml" ]]; then
    printf "%s" "$candidate"
    return 0
  fi

  for candidate in \
    "${CLAWBOARD_DIR:-}" \
    "$WORKSPACE_PATH/projects/clawboard" \
    "$WORKSPACE_PATH/project/clawboard" \
    "$HOME/clawboard"
  do
    [[ -n "$candidate" ]] || continue
    if [[ -f "$candidate/deploy.sh" && -f "$candidate/docker-compose.yaml" ]]; then
      printf "%s" "$candidate"
      return 0
    fi
  done
  return 1
}

prompt() {
  local var="$1"; shift
  local msg="$1"; shift
  local silent="${1:-0}"
  local val=""
  if [[ "$silent" == "1" ]]; then
    read -r -s -p "$msg" val; echo ""
  else
    read -r -p "$msg" val
  fi
  printf -v "$var" "%s" "$val"
}

say "== Clawboard: OpenClaw curated memory backup setup =="

WORKSPACE_PATH="$(workspace_from_config || true)"
if [[ -z "$WORKSPACE_PATH" ]]; then
  say "Could not find workspace in: $OPENCLAW_JSON"
  prompt WORKSPACE_PATH "Enter your OpenClaw workspace path (e.g. /Users/<you>/clawd): "
fi
[[ -d "$WORKSPACE_PATH" ]] || die "Workspace path does not exist: $WORKSPACE_PATH"

mkdir -p "$CRED_DIR"

say ""
say "Step 1: GitHub repo"
say "We can create a *private* repo automatically if you have the GitHub CLI (gh) installed and authenticated."

CREATE_REPO=""
if has_cmd gh; then
  # Check auth status quickly
  if gh auth status -h github.com >/dev/null 2>&1; then
    prompt CREATE_REPO "Create a new private repo now via gh? [y/N]: "
  else
    say "(gh found, but not authenticated. Run: gh auth login)"
    CREATE_REPO="n"
  fi
else
  say "(gh not found. We'll print browser instructions instead.)"
  CREATE_REPO="n"
fi

REPO_URL=""
REPO_SSH_URL=""

case "$(lc "${CREATE_REPO:-}")" in
  y|yes)
    prompt REPO_OWNER "Repo owner (user or org): "
    prompt REPO_NAME "Repo name [default: openclaw-memories-backup]: "
    REPO_NAME="${REPO_NAME:-openclaw-memories-backup}"

    say "Creating private repo: $REPO_OWNER/$REPO_NAME"
    gh repo create "$REPO_OWNER/$REPO_NAME" --private --confirm >/dev/null

    REPO_URL="https://github.com/$REPO_OWNER/$REPO_NAME.git"
    REPO_SSH_URL="[REDACTED_SECRET]:$REPO_OWNER/$REPO_NAME.git"

    say "Created: $REPO_URL"
    ;;
  *)
    say "Create a *private* GitHub repo for backups (do this in your browser)."
    say "  - Repo name suggestion: openclaw-memories-backup"
    say "  - Keep it private"
    say "  - You can leave it empty (no README needed)"
    say ""
    prompt REPO_URL "Paste the repo HTTPS URL (like https://github.com/OWNER/REPO.git): "
    [[ "$REPO_URL" == https://github.com/*/*.git ]] || die "Repo URL must look like: https://github.com/OWNER/REPO.git"
    REPO_SSH_URL="${REPO_URL/https:\/\/github.com\//[REDACTED_SECRET]:}"
    ;;
esac

say ""
say "Step 2: Auth method"
say "Recommended: GitHub Deploy Key (SSH)."
say " - You must enable *Allow write access* when adding the key (reminder: otherwise pushes will fail)."

prompt AUTH_METHOD "Use Deploy Key (SSH) instead of PAT? [Y/n]: "
case "$(lc "${AUTH_METHOD:-}")" in n|no) AUTH_METHOD="pat" ;; *) AUTH_METHOD="ssh" ;; esac

GITHUB_USER=""
GITHUB_PAT=""
DEPLOY_KEY_PATH=""
DEPLOY_PUB_PATH=""

if [[ "$AUTH_METHOD" == "pat" ]]; then
  say ""
  say "PAT mode (legacy)"
  say "Create a fine-grained PAT (GitHub Settings → Developer settings → Fine-grained tokens)."
  say "  - Resource owner: your account/org"
  say "  - Repository access: ONLY the backup repo"
  say "  - Permissions: Contents = Read and write"
  say ""
  prompt GITHUB_USER "GitHub username (used for HTTPS auth): "
  prompt GITHUB_PAT "Paste the fine-grained PAT (input hidden): " 1
else
  say ""
  say "Deploy key (SSH) mode"
  say "We'll generate a dedicated SSH keypair and then you add the *public* key as a Deploy Key on the repo."

  DEFAULT_KEY_PATH="$OPENCLAW_DIR/credentials/clawboard-memory-backup-deploy-key"
  prompt DEPLOY_KEY_PATH "Deploy key path [default: $DEFAULT_KEY_PATH]: "
  DEPLOY_KEY_PATH="${DEPLOY_KEY_PATH:-$DEFAULT_KEY_PATH}"
  DEPLOY_PUB_PATH="$DEPLOY_KEY_PATH.pub"

  mkdir -p "$(dirname "$DEPLOY_KEY_PATH")"

  if [[ -f "$DEPLOY_KEY_PATH" && -f "$DEPLOY_PUB_PATH" ]]; then
    prompt REUSE_KEY "Key already exists at $DEPLOY_KEY_PATH. Reuse it? [Y/n]: "
    REUSE_KEY="${REUSE_KEY:-Y}"
    case "$(lc "${REUSE_KEY:-}")" in
      y|yes)
        say "Reusing existing deploy key."
        ;;
      *)
        prompt OVERWRITE "Overwrite existing keypair at $DEPLOY_KEY_PATH? [y/N]: "
        case "$(lc "${OVERWRITE:-}")" in
          y|yes)
            rm -f "$DEPLOY_KEY_PATH" "$DEPLOY_PUB_PATH"
            ssh-keygen -t ed25519 -C "clawboard-memory-backup" -f "$DEPLOY_KEY_PATH" -N "" >/dev/null
            chmod 600 "$DEPLOY_KEY_PATH"
            chmod 644 "$DEPLOY_PUB_PATH"
            ;;
          *)
            say "Keeping existing keypair (no changes)."
            ;;
        esac
        ;;
    esac
  else
    ssh-keygen -t ed25519 -C "clawboard-memory-backup" -f "$DEPLOY_KEY_PATH" -N "" >/dev/null
    chmod 600 "$DEPLOY_KEY_PATH"
    chmod 644 "$DEPLOY_PUB_PATH"
  fi

  say ""
  say "Deploy Key reminder (GitHub):"
  say "  Repo → Settings → Deploy keys → Add deploy key"
  say "  Title: clawboard-memory-backup"
  say "  Key: (paste the PUBLIC key below)"
  say "  IMPORTANT: check 'Allow write access' so backups can push."
  say ""
  cat "$DEPLOY_PUB_PATH"
  say ""
  prompt CONFIRM_DEPLOY_KEY "Press Enter once you've added the key (or Ctrl+C to abort): "
fi

say ""
say "Step 3: Choose what to back up (Option B buckets)."
prompt INCLUDE_OPENCLAW_CONFIG "Include ~/.openclaw/openclaw.json* ? [Y/n]: "
prompt INCLUDE_OPENCLAW_SKILLS "Include ~/.openclaw/skills/ ? [Y/n]: "

# defaults
case "$(lc "${INCLUDE_OPENCLAW_CONFIG:-}")" in n|no) INCLUDE_OPENCLAW_CONFIG=false ;; *) INCLUDE_OPENCLAW_CONFIG=true ;; esac
case "$(lc "${INCLUDE_OPENCLAW_SKILLS:-}")" in n|no) INCLUDE_OPENCLAW_SKILLS=false ;; *) INCLUDE_OPENCLAW_SKILLS=true ;; esac

say ""
say "Step 4: Clawboard state backup"
say "Recommended: include full Clawboard state export (config/topics/tasks/logs)."
prompt INCLUDE_CLAWBOARD_STATE "Include Clawboard state export? [Y/n]: "
case "$(lc "${INCLUDE_CLAWBOARD_STATE:-}")" in
  n|no)
    INCLUDE_CLAWBOARD_STATE=false
    CLAWBOARD_DIR=""
    CLAWBOARD_API_URL=""
    INCLUDE_CLAWBOARD_ATTACHMENTS=false
    INCLUDE_CLAWBOARD_ENV=false
    CLAWBOARD_BACKUP_TOKEN=""
    ;;
  *)
    INCLUDE_CLAWBOARD_STATE=true
    DETECTED_CLAWBOARD_DIR="$(detect_clawboard_dir || true)"
    if [[ -n "$DETECTED_CLAWBOARD_DIR" ]]; then
      prompt CLAWBOARD_DIR "Clawboard install path [default: $DETECTED_CLAWBOARD_DIR]: "
      CLAWBOARD_DIR="${CLAWBOARD_DIR:-$DETECTED_CLAWBOARD_DIR}"
    else
      prompt CLAWBOARD_DIR "Clawboard install path (contains deploy.sh): "
    fi
    [[ -d "$CLAWBOARD_DIR" ]] || die "Clawboard path does not exist: $CLAWBOARD_DIR"
    [[ -f "$CLAWBOARD_DIR/deploy.sh" ]] || die "Clawboard path does not look valid (missing deploy.sh): $CLAWBOARD_DIR"

    DEFAULT_CLAWBOARD_API_URL="http://localhost:8010"
    if [[ -f "$CLAWBOARD_DIR/.env" ]]; then
      DEFAULT_CLAWBOARD_API_URL="$(read_env_value "$CLAWBOARD_DIR/.env" "CLAWBOARD_PUBLIC_API_BASE" || true)"
      if [[ -z "$DEFAULT_CLAWBOARD_API_URL" ]]; then
        DEFAULT_CLAWBOARD_API_URL="http://localhost:8010"
      fi
    fi
    prompt CLAWBOARD_API_URL "Clawboard API base URL [default: $DEFAULT_CLAWBOARD_API_URL]: "
    CLAWBOARD_API_URL="${CLAWBOARD_API_URL:-$DEFAULT_CLAWBOARD_API_URL}"

    prompt INCLUDE_CLAWBOARD_ATTACHMENTS "Include Clawboard attachment files? [Y/n]: "
    prompt INCLUDE_CLAWBOARD_ENV "Include Clawboard .env (contains secrets)? [y/N]: "
    prompt CLAWBOARD_BACKUP_TOKEN "Optional Clawboard token override (hidden, blank=read CLAWBOARD_TOKEN from .env): " 1

    case "$(lc "${INCLUDE_CLAWBOARD_ATTACHMENTS:-}")" in n|no) INCLUDE_CLAWBOARD_ATTACHMENTS=false ;; *) INCLUDE_CLAWBOARD_ATTACHMENTS=true ;; esac
    case "$(lc "${INCLUDE_CLAWBOARD_ENV:-}")" in y|yes) INCLUDE_CLAWBOARD_ENV=true ;; *) INCLUDE_CLAWBOARD_ENV=false ;; esac
    ;;
esac

say ""
prompt BACKUP_DIR "Local backup repo directory [default: $OPENCLAW_DIR/memory-backup-repo]: "
BACKUP_DIR="${BACKUP_DIR:-$OPENCLAW_DIR/memory-backup-repo}"

REMOTE_NAME="origin"
BRANCH="main"

# Write env file for future use (esp. SSH key path)
# NOTE: This stores only the *path* to the private key, not the key material.
cat >"$CRED_ENV" <<ENV
# Clawboard memory backup auth/env
# This file is sourced by backup_openclaw_curated_memories.sh (set -a)

AUTH_METHOD="$AUTH_METHOD"
REPO_URL="$REPO_URL"
REPO_SSH_URL="$REPO_SSH_URL"
DEPLOY_KEY_PATH="${DEPLOY_KEY_PATH:-}"
GITHUB_USER="$GITHUB_USER"
GITHUB_PAT="$GITHUB_PAT"
CLAWBOARD_BACKUP_DIR="${CLAWBOARD_DIR}"
CLAWBOARD_BACKUP_API_URL="${CLAWBOARD_API_URL}"
CLAWBOARD_BACKUP_TOKEN="${CLAWBOARD_BACKUP_TOKEN:-}"
ENV
chmod 600 "$CRED_ENV"

# Write JSON config (0600)
cat >"$CRED_JSON" <<JSON
{
  "workspacePath": "${WORKSPACE_PATH}",
  "backupDir": "${BACKUP_DIR}",
  "repoUrl": "${REPO_URL}",
  "repoSshUrl": "${REPO_SSH_URL}",
  "authMethod": "${AUTH_METHOD}",
  "deployKeyPath": "${DEPLOY_KEY_PATH}",
  "githubUser": "${GITHUB_USER}",
  "githubPat": "${GITHUB_PAT}",
  "remoteName": "${REMOTE_NAME}",
  "branch": "${BRANCH}",
  "includeOpenclawConfig": ${INCLUDE_OPENCLAW_CONFIG},
  "includeOpenclawSkills": ${INCLUDE_OPENCLAW_SKILLS},
  "includeClawboardState": ${INCLUDE_CLAWBOARD_STATE},
  "clawboardDir": "${CLAWBOARD_DIR}",
  "clawboardApiUrl": "${CLAWBOARD_API_URL}",
  "includeClawboardAttachments": ${INCLUDE_CLAWBOARD_ATTACHMENTS},
  "includeClawboardEnv": ${INCLUDE_CLAWBOARD_ENV}
}
JSON

chmod 600 "$CRED_JSON"

say "Wrote config: $CRED_JSON"
say "Wrote env:    $CRED_ENV"

say ""
say "Running one backup now to validate..."
"$(cd "$(dirname "$0")" && pwd)/backup_openclaw_curated_memories.sh"

say ""
say "Optional: install an OpenClaw cron job to run every 15 minutes."
say "If you say yes, we will create a Gateway cron job (isolated agent turn) that runs the backup script."

prompt INSTALL_CRON "Install OpenClaw cron (every 15m)? [Y/n]: "
# default: yes
INSTALL_CRON="${INSTALL_CRON:-Y}"
case "$(lc "${INSTALL_CRON:-}")" in
  y|yes)
    need_cmd openclaw
    say ""
    say "Creating cron job via OpenClaw CLI..."

    JOB_NAME="Clawboard: backup continuity + state"
    JOB_EVERY="15m"
    JOB_SESSION="isolated"
    JOB_MESSAGE="Run the continuity + Clawboard state backup now (automated 15-minute backup). Execute: $HOME/.openclaw/skills/clawboard/scripts/backup_openclaw_curated_memories.sh . IMPORTANT: Only notify me if (a) there were changes pushed, or (b) the backup failed. If there were no changes and the script exited 0 without output, respond with NO_REPLY."

    # Idempotent cron install: update existing job by name (or backup script match); otherwise add.
    existing_id=""
    if openclaw cron list --json >/dev/null 2>&1; then
      existing_id="$(
        openclaw cron list --json | python3 -c '
import json
import sys

data = json.load(sys.stdin)
jobs = data.get("jobs") if isinstance(data, dict) else []
jobs = jobs or []

name = "Clawboard: backup continuity + state"
needle = "backup_openclaw_curated_memories.sh"

cands = [
  j
  for j in jobs
  if isinstance(j, dict)
  and str(j.get("sessionTarget") or "").strip() == "isolated"
  and (
    str(j.get("name") or "") == name
    or needle in str(((j.get("payload") if isinstance(j.get("payload"), dict) else {}) or {}).get("message") or "")
  )
]

print((cands[0].get("id") or cands[0].get("jobId") or "") if cands else "", end="")
' || true
      )"
    fi

    cron_ok=0
    if [[ -n "$existing_id" ]]; then
      say "Found existing cron job ($existing_id). Updating it..."
      if openclaw cron edit "$existing_id" \
        --name "$JOB_NAME" \
        --every "$JOB_EVERY" \
        --session "$JOB_SESSION" \
        --no-deliver \
        --message "$JOB_MESSAGE" \
        --enable; then
        cron_ok=1
      fi
    else
      say "No existing cron job found. Creating it..."
      if openclaw cron add \
        --name "$JOB_NAME" \
        --every "$JOB_EVERY" \
        --session "$JOB_SESSION" \
        --no-deliver \
        --message "$JOB_MESSAGE"; then
        cron_ok=1
      fi
    fi

    if [[ "$cron_ok" -ne 1 ]]; then
      say "If cron setup failed, create it manually with these settings:"
      say "  name: $JOB_NAME"
      say "  schedule: every $JOB_EVERY"
      say "  session: $JOB_SESSION"
      say "  delivery: none"
      say "  message: $JOB_MESSAGE"
    else
      say "Cron job installed/updated successfully."
    fi
    ;;
  *)
    say "Skipped cron install. You can run this script anytime to push updates."
    ;;
esac

say "Done."


path: scripts/backup_openclaw_curated_memories.sh
content: #!/usr/bin/env bash
set -euo pipefail

# backup_openclaw_curated_memories.sh
#
# Sync a *curated* allowlist of continuity files into a dedicated backup git repo,
# then commit+push *only if changed*.
#
# Option B support:
# - Always backs up curated workspace text (MEMORY.md, memory/*.md, SOUL/USER/etc.)
# - Optionally backs up selected OpenClaw files (openclaw.json*, skills/) based on config flags.
# - Optionally exports full Clawboard state (config/topics/tasks/logs + optional attachments).
#
# Config is read from:
#   $HOME/.openclaw/credentials/clawboard-memory-backup.json
# Fallback (optional):
#   $HOME/.openclaw/credentials/clawboard-memory-backup.env
#
# Auth methods:
# - authMethod=ssh (recommended): uses a GitHub Deploy Key (write access must be enabled in repo settings)
# - authMethod=pat (legacy): uses HTTPS + fine-grained PAT via GIT_ASKPASS

say() { printf "%s\n" "$*"; }
die() { say "ERROR: $*" >&2; exit 2; }

OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/.openclaw}"
CRED_JSON="$OPENCLAW_DIR/credentials/clawboard-memory-backup.json"
CRED_ENV="$OPENCLAW_DIR/credentials/clawboard-memory-backup.env"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXPORT_CLAWBOARD_HELPER="$SCRIPT_DIR/export_clawboard_backup.py"

need_cmd() {
  command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}

json_get() {
  # Usage: json_get <file> <jq-filter>
  local f="$1"; shift
  local filter="$1"; shift || true
  python3 - <<'PY' "$f" "$filter"
import json,sys
p=sys.argv[1]
filt=sys.argv[2]
with open(p,'r') as fh:
  d=json.load(fh)
# extremely tiny "jq-like" getter for dot paths: .a.b.c
if not filt.startswith('.'):
  print('')
  sys.exit(0)
keys=[k for k in filt.split('.') if k]
cur=d
try:
  for k in keys:
    cur=cur[k]
except Exception:
  cur=""
if cur is None:
  cur=""
print(cur if isinstance(cur,str) else json.dumps(cur))
PY
}

load_env_file() {
  local f="$1"
  [[ -f "$f" ]] || return 0
  # shellcheck disable=SC1090
  set -a; source "$f"; set +a
}

read_env_value() {
  local file_path="$1"
  local key="$2"
  local line
  [[ -f "$file_path" ]] || return 1
  line="$(awk -v key="$key" '
    $0 ~ "^[[:space:]]*" key "=" {
      print substr($0, index($0, "=") + 1)
    }
  ' "$file_path" | tail -n1)"
  line="${line//$'\r'/}"
  line="${line#"${line%%[![:space:]]*}"}"
  line="${line%"${line##*[![:space:]]}"}"
  line="${line%\"}"
  line="${line#\"}"
  line="${line%\'}"
  line="${line#\'}"
  [[ -n "$line" ]] || return 1
  printf "%s" "$line"
}

# --- Load config ---
if [[ -f "$CRED_JSON" ]]; then
  WORKSPACE_PATH="$(json_get "$CRED_JSON" '.workspacePath')"
  BACKUP_DIR="$(json_get "$CRED_JSON" '.backupDir')"
  REPO_URL="$(json_get "$CRED_JSON" '.repoUrl')"
  REPO_SSH_URL="$(json_get "$CRED_JSON" '.repoSshUrl')"
  AUTH_METHOD="$(json_get "$CRED_JSON" '.authMethod')"
  DEPLOY_KEY_PATH="$(json_get "$CRED_JSON" '.deployKeyPath')"
  GITHUB_USER="$(json_get "$CRED_JSON" '.githubUser')"
  GITHUB_PAT="$(json_get "$CRED_JSON" '.githubPat')"
  REMOTE_NAME="$(json_get "$CRED_JSON" '.remoteName')"
  BRANCH="$(json_get "$CRED_JSON" '.branch')"
  INCLUDE_OPENCLAW_CONFIG="$(json_get "$CRED_JSON" '.includeOpenclawConfig')"
  INCLUDE_OPENCLAW_SKILLS="$(json_get "$CRED_JSON" '.includeOpenclawSkills')"
  INCLUDE_CLAWBOARD_STATE="$(json_get "$CRED_JSON" '.includeClawboardState')"
  CLAWBOARD_DIR="$(json_get "$CRED_JSON" '.clawboardDir')"
  CLAWBOARD_API_URL="$(json_get "$CRED_JSON" '.clawboardApiUrl')"
  INCLUDE_CLAWBOARD_ATTACHMENTS="$(json_get "$CRED_JSON" '.includeClawboardAttachments')"
  INCLUDE_CLAWBOARD_ENV="$(json_get "$CRED_JSON" '.includeClawboardEnv')"
  CLAWBOARD_TOKEN="$(json_get "$CRED_JSON" '.clawboardToken')"
else
  load_env_file "$CRED_ENV"
  WORKSPACE_PATH="${WORKSPACE_PATH:-}"
  BACKUP_DIR="${BACKUP_DIR:-}"
  REPO_URL="${REPO_URL:-}"
  REPO_SSH_URL="${REPO_SSH_URL:-}"
  AUTH_METHOD="${AUTH_METHOD:-}"
  DEPLOY_KEY_PATH="${DEPLOY_KEY_PATH:-}"
  GITHUB_USER="${GITHUB_USER:-}"
  GITHUB_PAT="${GITHUB_PAT:-}"
  REMOTE_NAME="${REMOTE_NAME:-}"
  BRANCH="${BRANCH:-}"
  INCLUDE_OPENCLAW_CONFIG="${INCLUDE_OPENCLAW_CONFIG:-}"
  INCLUDE_OPENCLAW_SKILLS="${INCLUDE_OPENCLAW_SKILLS:-}"
  INCLUDE_CLAWBOARD_STATE="${INCLUDE_CLAWBOARD_STATE:-}"
  CLAWBOARD_DIR="${CLAWBOARD_DIR:-}"
  CLAWBOARD_API_URL="${CLAWBOARD_API_URL:-}"
  INCLUDE_CLAWBOARD_ATTACHMENTS="${INCLUDE_CLAWBOARD_ATTACHMENTS:-}"
  INCLUDE_CLAWBOARD_ENV="${INCLUDE_CLAWBOARD_ENV:-}"
  CLAWBOARD_TOKEN="${CLAWBOARD_BACKUP_TOKEN:-${CLAWBOARD_TOKEN:-}}"
fi

# Env file values override JSON for secrets/runtime configuration.
load_env_file "$CRED_ENV"
CLAWBOARD_TOKEN="${CLAWBOARD_BACKUP_TOKEN:-${CLAWBOARD_TOKEN:-}}"
CLAWBOARD_API_URL="${CLAWBOARD_BACKUP_API_URL:-${CLAWBOARD_API_URL:-}}"
CLAWBOARD_DIR="${CLAWBOARD_BACKUP_DIR:-${CLAWBOARD_DIR:-}}"

REMOTE_NAME="${REMOTE_NAME:-origin}"
BRANCH="${BRANCH:-main}"
AUTH_METHOD="${AUTH_METHOD:-ssh}"
CLAWBOARD_API_URL="${CLAWBOARD_API_URL:-http://localhost:8010}"

# bash 3.2 compatibility (macOS default /bin/bash): no ${var,,}
lc() { tr '[:upper:]' '[:lower:]' <<<"${1:-}"; }

# Booleans are stored as JSON true/false; our json_get returns a JSON string for non-strings.
# Accept: true/false/"true"/"false"/1/0/yes/no
as_bool() {
  local v="${1:-}"
  v="${v//\"/}"
  case "$(lc "$v")" in
    true|1|yes|y) return 0 ;;
    *) return 1 ;;
  esac
}

[[ -n "${WORKSPACE_PATH:-}" ]] || die "workspacePath not configured. Run setup-openclaw-memory-backup.sh first."
[[ -d "$WORKSPACE_PATH" ]] || die "workspacePath does not exist: $WORKSPACE_PATH"

[[ -n "${BACKUP_DIR:-}" ]] || die "backupDir not configured. Run setup-openclaw-memory-backup.sh first."

case "$(lc "${AUTH_METHOD:-}")" in
  ssh)
    [[ -n "${REPO_SSH_URL:-}" ]] || die "repoSshUrl not configured (needed for authMethod=ssh). Re-run setup."
    [[ -n "${DEPLOY_KEY_PATH:-}" ]] || die "deployKeyPath not configured (needed for authMethod=ssh). Re-run setup."
    [[ -f "$DEPLOY_KEY_PATH" ]] || die "deploy key not found: $DEPLOY_KEY_PATH"
    ;;
  pat)
    [[ -n "${REPO_URL:-}" ]] || die "repoUrl not configured (needed for authMethod=pat). Re-run setup."
    [[ -n "${GITHUB_USER:-}" ]] || die "githubUser not configured. Re-run setup."
    [[ -n "${GITHUB_PAT:-}" ]] || die "githubPat not configured. Re-run setup."
    ;;
  *)
    die "Unknown authMethod: ${AUTH_METHOD}. Expected 'ssh' or 'pat'."
    ;;
esac

need_cmd git
need_cmd rsync
need_cmd mktemp
need_cmd date
need_cmd python3

mkdir -p "$BACKUP_DIR"

# Avoid overlapping cron runs. If another backup is active, exit quietly.
LOCK_DIR="$BACKUP_DIR/.backup-lock"
if ! mkdir "$LOCK_DIR" >/dev/null 2>&1; then
  lock_pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
  if [[ -n "${lock_pid:-}" ]] && kill -0 "$lock_pid" >/dev/null 2>&1; then
    exit 0
  fi
  rm -rf "$LOCK_DIR" >/dev/null 2>&1 || true
  mkdir "$LOCK_DIR" >/dev/null 2>&1 || exit 0
fi
printf "%s\n" "$$" > "$LOCK_DIR/pid"
trap 'rm -rf "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT

# Ensure backup repo exists
if [[ ! -d "$BACKUP_DIR/.git" ]]; then
  (cd "$BACKUP_DIR" && git init -b "$BRANCH" >/dev/null)
fi

# Ensure remote is set
REMOTE_URL=""
if [[ "$(lc "${AUTH_METHOD:-}")" == "ssh" ]]; then
  REMOTE_URL="$REPO_SSH_URL"
else
  REMOTE_URL="$REPO_URL"
fi

if ! (cd "$BACKUP_DIR" && git remote get-url "$REMOTE_NAME" >/dev/null 2>&1); then
  (cd "$BACKUP_DIR" && git remote add "$REMOTE_NAME" "$REMOTE_URL")
else
  (cd "$BACKUP_DIR" && git remote set-url "$REMOTE_NAME" "$REMOTE_URL")
fi

# Ensure commits can be created in clean environments without global git identity.
if [[ -z "$(git -C "$BACKUP_DIR" config --get user.name || true)" ]]; then
  git -C "$BACKUP_DIR" config user.name "Clawboard Backup Bot"
fi
if [[ -z "$(git -C "$BACKUP_DIR" config --get user.email || true)" ]]; then
  git -C "$BACKUP_DIR" config user.email "clawboard-backup@localhost"
fi

# Make a staging subdir so we can delete removed files cleanly without nuking .git
STAGE_DIR="$BACKUP_DIR/.stage"
rm -rf "$STAGE_DIR"
mkdir -p "$STAGE_DIR"

# --- A) Curated workspace text (always) ---
# Note: do NOT use --delete when rsync'ing individual files into the same dest,
# or each call will delete what the previous call copied.
RSYNC_WORKSPACE=(
  "MEMORY.md"
  "USER.md"
  "SOUL.md"
  "AGENTS.md"
  "TOOLS.md"
  "IDENTITY.md"
  "HEARTBEAT.md"
  "memory"
)

for p in "${RSYNC_WORKSPACE[@]}"; do
  if [[ -e "$WORKSPACE_PATH/$p" ]]; then
    if [[ -d "$WORKSPACE_PATH/$p" ]]; then
      # directory
      rsync -a --prune-empty-dirs \
        --exclude ".DS_Store" \
        "$WORKSPACE_PATH/$p" \
        "$STAGE_DIR/" \
        >/dev/null
    else
      # file
      rsync -a --prune-empty-dirs \
        --exclude ".DS_Store" \
        "$WORKSPACE_PATH/$p" \
        "$STAGE_DIR/" \
        >/dev/null
    fi
  fi
done

# --- B) Selected OpenClaw files (optional) ---
if as_bool "${INCLUDE_OPENCLAW_CONFIG:-}"; then
  mkdir -p "$STAGE_DIR/openclaw"
  if [[ -f "$OPENCLAW_DIR/openclaw.json" ]]; then
    rsync -a "$OPENCLAW_DIR/openclaw.json" "$STAGE_DIR/openclaw/" >/dev/null
  fi
  # include backups if present (small, useful)
  shopt -s nullglob
  for f in "$OPENCLAW_DIR"/openclaw.json.bak*; do
    rsync -a "$f" "$STAGE_DIR/openclaw/" >/dev/null
  done
  shopt -u nullglob
fi

if as_bool "${INCLUDE_OPENCLAW_SKILLS:-}"; then
  if [[ -d "$OPENCLAW_DIR/skills" ]]; then
    mkdir -p "$STAGE_DIR/openclaw"
    rsync -a --delete --prune-empty-dirs \
      --exclude ".DS_Store" \
      "$OPENCLAW_DIR/skills/" \
      "$STAGE_DIR/openclaw/skills/" \
      >/dev/null
  fi
fi

# --- C) Full Clawboard state backup (optional) ---
if as_bool "${INCLUDE_CLAWBOARD_STATE:-}"; then
  [[ -f "$EXPORT_CLAWBOARD_HELPER" ]] || die "Missing exporter helper: $EXPORT_CLAWBOARD_HELPER"

  if [[ -n "${CLAWBOARD_DIR:-}" ]]; then
    # Expand "~" in a POSIX-safe way.
    case "$CLAWBOARD_DIR" in
      "~/"*) CLAWBOARD_DIR="$HOME/${CLAWBOARD_DIR#~/}" ;;
    esac
    [[ -d "$CLAWBOARD_DIR" ]] || die "clawboardDir does not exist: $CLAWBOARD_DIR"
  fi

  if [[ -z "${CLAWBOARD_TOKEN:-}" && -n "${CLAWBOARD_DIR:-}" && -f "$CLAWBOARD_DIR/.env" ]]; then
    CLAWBOARD_TOKEN="$(read_env_value "$CLAWBOARD_DIR/.env" "CLAWBOARD_TOKEN" || true)"
  fi
  [[ -n "${CLAWBOARD_TOKEN:-}" ]] || die "Clawboard token missing. Set CLAWBOARD_TOKEN in $CLAWBOARD_DIR/.env or CLAWBOARD_BACKUP_TOKEN in $CRED_ENV."

  CLAWBOARD_EXPORT_DIR="$STAGE_DIR/clawboard/export"
  mkdir -p "$CLAWBOARD_EXPORT_DIR"
  python3 "$EXPORT_CLAWBOARD_HELPER" \
    --api-base "$CLAWBOARD_API_URL" \
    --token "$CLAWBOARD_TOKEN" \
    --out-dir "$CLAWBOARD_EXPORT_DIR" \
    --include-raw \
    >/dev/null

  if as_bool "${INCLUDE_CLAWBOARD_ATTACHMENTS:-}"; then
    attachments_path=""
    if [[ -n "${CLAWBOARD_ATTACHMENTS_DIR:-}" ]]; then
      attachments_path="$CLAWBOARD_ATTACHMENTS_DIR"
    elif [[ -n "${CLAWBOARD_DIR:-}" && -f "$CLAWBOARD_DIR/.env" ]]; then
      attachments_path="$(read_env_value "$CLAWBOARD_DIR/.env" "CLAWBOARD_ATTACHMENTS_DIR" || true)"
    fi
    if [[ -z "$attachments_path" && -n "${CLAWBOARD_DIR:-}" ]]; then
      attachments_path="$CLAWBOARD_DIR/data/attachments"
    fi
    if [[ -n "$attachments_path" ]]; then
      case "$attachments_path" in
        "~/"*) attachments_path="$HOME/${attachments_path#~/}" ;;
      esac
      if [[ "${attachments_path#/}" == "$attachments_path" && -n "${CLAWBOARD_DIR:-}" ]]; then
        attachments_path="$CLAWBOARD_DIR/$attachments_path"
      fi
      if [[ -d "$attachments_path" ]]; then
        mkdir -p "$STAGE_DIR/clawboard/attachments"
        rsync -a --delete --prune-empty-dirs \
          --exclude ".DS_Store" \
          "$attachments_path/" \
          "$STAGE_DIR/clawboard/attachments/" \
          >/dev/null
      fi
    fi
  fi

  if as_bool "${INCLUDE_CLAWBOARD_ENV:-}" && [[ -n "${CLAWBOARD_DIR:-}" ]] && [[ -f "$CLAWBOARD_DIR/.env" ]]; then
    mkdir -p "$STAGE_DIR/clawboard"
    rsync -a "$CLAWBOARD_DIR/.env" "$STAGE_DIR/clawboard/" >/dev/null
  fi
fi

# Replace tracked content (keep .git)
rsync -a --delete --prune-empty-dirs \
  --exclude ".git" \
  --exclude ".stage" \
  "$STAGE_DIR/" \
  "$BACKUP_DIR/" \
  >/dev/null

rm -rf "$STAGE_DIR"

cd "$BACKUP_DIR"

if [[ -z "$(git status --porcelain)" ]]; then
  # Silent success: cron should not notify when there is nothing new.
  exit 0
fi

git add -A

ts="$(date -u +"%Y-%m-%d %H:%M:%SZ")"
scope="OpenClaw continuity (curated + selected)"
if as_bool "${INCLUDE_CLAWBOARD_STATE:-}"; then
  scope="$scope + Clawboard state"
fi
msg="Clawboard: auto backup $scope ($ts)"
if ! git commit -m "$msg" >/dev/null 2>&1; then
  if [[ -z "$(git status --porcelain)" ]]; then
    exit 0
  fi
  die "git commit failed"
fi

# Ensure branch exists locally
if ! git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then
  git checkout -b "$BRANCH" >/dev/null
else
  git checkout "$BRANCH" >/dev/null
fi

# Push
if [[ "$(lc "${AUTH_METHOD:-}")" == "ssh" ]]; then
  # Use explicit key for this push so we don't depend on user-level ssh config.
  # Default: port 22 to github.com. If that fails (common on restrictive networks),
  # retry via ssh.github.com:443.
  export GIT_SSH_COMMAND="ssh -i $DEPLOY_KEY_PATH -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"

  set +e
  out="$(git push -u "$REMOTE_NAME" "$BRANCH" 2>&1)"
  code=$?

  if [[ $code -ne 0 ]]; then
    # Retry over port 443
    export GIT_SSH_COMMAND="ssh -i $DEPLOY_KEY_PATH -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 443 -o HostName=ssh.github.com"
    out2="$(git push -u "$REMOTE_NAME" "$BRANCH" 2>&1)"
    code2=$?
    if [[ $code2 -ne 0 ]]; then
      set -e
      say "$out" >&2
      say "--- retry over ssh.github.com:443 ---" >&2
      say "$out2" >&2
      die "git push failed (ssh). Confirm the Deploy Key was added AND 'Allow write access' is checked. If you're on a network blocking SSH, port 443 fallback may still be blocked."
    else
      set -e
      say "Backed up and pushed changes to $REPO_SSH_URL (via ssh.github.com:443)"
      exit 0
    fi
  fi
  set -e
  say "Backed up and pushed changes to $REPO_SSH_URL"
else
  # HTTPS + PAT via ephemeral askpass helper so we don't depend on OS keychains.
  ASKPASS="$(mktemp -t clawboard-git-askpass.XXXXXX)"
  chmod 700 "$ASKPASS"
  cat >"$ASKPASS" <<'SH'
#!/usr/bin/env bash
case "$1" in
  *Username*)
    printf "%s" "${GITHUB_USER}"
    ;;
  *Password*)
    printf "%s" "${GITHUB_PAT}"
    ;;
  *)
    printf "%s" "${GITHUB_PAT}"
    ;;
esac
SH

  export GIT_ASKPASS="$ASKPASS"
  export GIT_TERMINAL_PROMPT=0
  export GITHUB_USER
  export GITHUB_PAT

  set +e
  out="$(git push -u "$REMOTE_NAME" "$BRANCH" 2>&1)"
  code=$?
  set -e
  rm -f "$ASKPASS"

  if [[ $code -ne 0 ]]; then
    say "$out" >&2
    die "git push failed (pat)"
  fi
  say "Backed up and pushed changes to $REPO_URL"
fi


path: scripts/setup-openclaw-local-memory.sh
content: #!/usr/bin/env bash
set -euo pipefail

# setup-openclaw-local-memory.sh
# Configure OpenClaw memory search for local embeddings (privacy-first).
#
# Usage:
#   ./setup-openclaw-local-memory.sh
#
# Optional env vars:
#   OPENCLAW_MEMORY_MODEL_URL (override default download URL)
#   OPENCLAW_MODEL_DIR (default: ~/.openclaw/models)

need_cmd() {
  command -v "$1" >/dev/null 2>&1 || { echo "Missing required command: $1" >&2; exit 1; }
}

need_cmd curl
need_cmd openclaw

MODEL_DIR="${OPENCLAW_MODEL_DIR:-$HOME/.openclaw/models}"
MODEL_FILE="$MODEL_DIR/embeddinggemma-300M-Q8_0.gguf"
MODEL_URL_DEFAULT="https://huggingface.co/ggml-org/embeddinggemma-300M-GGUF/resolve/main/embeddinggemma-300M-Q8_0.gguf"
MODEL_URL="${OPENCLAW_MEMORY_MODEL_URL:-$MODEL_URL_DEFAULT}"

mkdir -p "$MODEL_DIR"

if [[ ! -f "$MODEL_FILE" ]]; then
  echo "Downloading embedding model -> $MODEL_FILE" >&2
  tmp="$MODEL_FILE.tmp"
  curl -fL --retry 3 --retry-delay 1 -o "$tmp" "$MODEL_URL"
  mv "$tmp" "$MODEL_FILE"
fi

# Set config keys via openclaw CLI (creates config if needed)
openclaw config set agents.defaults.compaction.memoryFlush.enabled true --json
openclaw config set agents.defaults.memorySearch.enabled true --json
openclaw config set agents.defaults.memorySearch.provider "local"
openclaw config set agents.defaults.memorySearch.fallback "none"
openclaw config set agents.defaults.memorySearch.sources '["memory","sessions"]' --json
openclaw config set agents.defaults.memorySearch.experimental.sessionMemory true --json
openclaw config set agents.defaults.memorySearch.local.modelPath "$MODEL_FILE"

cat <<MSG

OK: local memory search configured.

Next steps:
1) Restart OpenClaw gateway.
2) Run: openclaw memory index --agent main
3) Verify: openclaw memory status --json

MSG


path: scripts/export_clawboard_backup.py
content: #!/usr/bin/env python3
"""
Export Clawboard state into deterministic JSON/JSONL files for git-friendly backups.

This exporter intentionally captures user/instance state (config/topics/tasks/logs)
and avoids derived vector/index stores that can be rebuilt.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen


def _http_json(
    api_base: str,
    path: str,
    *,
    token: str | None,
    params: dict[str, Any] | None = None,
    timeout_sec: float = 30.0,
) -> Any:
    base = api_base.rstrip("/")
    query = f"?{urlencode(params)}" if params else ""
    url = f"{base}{path}{query}"
    headers = {"Accept": "application/json"}
    if [REDACTED_SECRET]"X-Clawboard-Token"] = token
    req = Request(url, headers=headers, method="GET")
    try:
        with urlopen(req, timeout=timeout_sec) as resp:
            body = resp.read().decode("utf-8", errors="replace")
            return json.loads(body)
    except HTTPError as exc:
        payload = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {exc.code} for {path}: {payload[:400]}") from exc
    except URLError as exc:
        raise RuntimeError(f"Request failed for {path}: {exc}") from exc


def _fetch_paged(
    api_base: str,
    path: str,
    *,
    token: str | None,
    limit: int,
    extra: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    offset = 0
    while True:
        params: dict[str, Any] = {"limit": int(limit), "offset": int(offset)}
        if extra:
            params.update(extra)
        page = _http_json(api_base, path, token=token, params=params)
        if not isinstance(page, list):
            raise RuntimeError(f"Unexpected response for {path}: expected list")
        out.extend(item for item in page if isinstance(item, dict))
        if len(page) < limit:
            break
        offset += len(page)
    return out


def _sort_topics(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return sorted(rows, key=lambda r: (str(r.get("id") or ""), str(r.get("name") or "")))


def _sort_tasks(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return sorted(
        rows,
        key=lambda r: (
            str(r.get("id") or ""),
            str(r.get("topicId") or ""),
            str(r.get("title") or ""),
        ),
    )


def _sort_logs(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return sorted(
        rows,
        key=lambda r: (
            str(r.get("createdAt") or ""),
            str(r.get("id") or ""),
        ),
    )


def _write_json(path: Path, payload: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")


def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False, sort_keys=True))
            f.write("\n")


def parse_args(argv: list[str]) -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Export Clawboard state for git backup.")
    p.add_argument("--api-base", required=True, help="Clawboard API base URL (e.g., http://localhost:8010)")
    p.add_argument("--token", default="", help="Optional API token (X-Clawboard-Token)")
    p.add_argument("--out-dir", required=True, help="Output directory for backup files")
    p.add_argument("--task-page-size", type=int, default=1000, help="Pagination size for /api/tasks")
    p.add_argument("--log-page-size", type=int, default=1000, help="Pagination size for /api/log")
    p.add_argument("--include-raw", action="store_true", help="Request includeRaw=1 for logs")
    return p.parse_args(argv)


def main(argv: list[str]) -> int:
    args = parse_args(argv)
    out_dir = Path(args.out_dir).expanduser().resolve()
    [REDACTED_SECRET] or "").strip()

    config = _http_json(args.api_base, "/api/config", token=token)
    topics = _sort_topics(_http_json(args.api_base, "/api/topics", token=token) or [])
    tasks = _sort_tasks(
        _fetch_paged(
            args.api_base,
            "/api/tasks",
            token=token,
            limit=max(1, int(args.task_page_size)),
        )
    )
    log_extra = {"includeRaw": 1} if args.include_raw else None
    logs = _sort_logs(
        _fetch_paged(
            args.api_base,
            "/api/log",
            token=token,
            limit=max(1, int(args.log_page_size)),
            extra=log_extra,
        )
    )

    _write_json(out_dir / "config.json", config)
    _write_json(out_dir / "topics.json", topics)
    _write_json(out_dir / "tasks.json", tasks)
    _write_jsonl(out_dir / "logs.jsonl", logs)
    _write_json(
        out_dir / "manifest.json",
        {
            "version": 1,
            "apiBase": args.api_base.rstrip("/"),
            "counts": {
                "topics": len(topics),
                "tasks": len(tasks),
                "logs": len(logs),
            },
            "includeRaw": bool(args.include_raw),
            "newestLogCreatedAt": str(logs[-1].get("createdAt") if logs else ""),
        },
    )

    print(
        json.dumps(
            {
                "ok": True,
                "outDir": str(out_dir),
                "topics": len(topics),
                "tasks": len(tasks),
                "logs": len(logs),
            },
            sort_keys=True,
        )
    )
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main(sys.argv[1:]))
    except Exception as exc:  # pragma: no cover - command-line error path
        print(f"error: {exc}", file=sys.stderr)
        raise SystemExit(2)


path: agents/openai.yaml
content: interface:
  display_name: "Clawboard"
  short_description: "OpenClaw companion for topics & logs"
  default_prompt: "Use $clawboard to connect my OpenClaw instance to my Clawboard base URL and start auto-tracking."


path: references/openclaw-memory-local.md
content: # Local memory search (OpenClaw)

## Why

Local embeddings keep memory search private and remove dependency on external APIs. The tradeoff is local compute and disk usage.

## What it does

- Enables session memory search (index transcripts).
- Forces local embedding provider with no fallback.
- Sets compaction memory flush before compaction.

## Helper script

Run:

- `{baseDir}/scripts/setup-openclaw-local-memory.sh`

The script:

- Downloads a local embedding GGUF file into `~/.openclaw/models`.
- Sets OpenClaw config keys using `openclaw config set`.
- Prints next steps to restart the gateway and reindex memory.

## Notes

- If you already have a preferred local embedding model, set `OPENCLAW_MEMORY_MODEL_URL` before running the script.
- If memory search is not needed, keep it disabled and skip this step.
- After changing memory settings, restart the gateway and run a memory index for your main agent.



path: references/clawboard-api.md
content: # Clawboard API (current)

## Auth

- If `CLAWBOARD_TOKEN` is set on the server, send it as `X-Clawboard-Token` for all write requests.

## Endpoints

### Health

- `GET /api/health`
- Response: `{ ok: true, timestamp: string }`

### Config

- `GET /api/config`
- Response: `{ instance: { title, integrationLevel, updatedAt }, tokenRequired }`

- `POST /api/config`
- Body: `{ title?: string, integrationLevel?: "manual"|"write"|"full" }`

### Topics

- `GET /api/topics`
- Response: `{ topics: Topic[] }`

- `POST /api/topics`
- Body: `{ id?, name, description?, priority?, status?, tags?, parentId?, pinned?, agentId?, agentLabel? }`

### Tasks

- `GET /api/tasks?topicId=...&status=...`
- Response: `{ tasks: Task[] }` (newest updated first)

- `POST /api/tasks`
- Body: `{ id?, title, topicId?, status?, priority?, dueDate?, pinned?, agentId?, agentLabel? }`

### Log

- `GET /api/log?topicId=...&taskId=...&type=...&agentId=...&relatedLogId=...`
- Response: `{ logs: LogEntry[] }` (newest first)

- `POST /api/log`
- Body: `{ topicId?, taskId?, relatedLogId?, type?, content, summary?, raw?, agentId?, agentLabel?, source? }`

### Search (hybrid semantic + lexical)

- `GET /api/search?q=...&sessionKey=...&includePending=1&limitTopics=24&limitTasks=48&limitLogs=360`
- Response:
  - `topics[]` ranked topic matches
  - `tasks[]` ranked task matches
  - `logs[]` ranked log matches
  - `notes[]` curated notes linked to matched logs
  - `matchedTopicIds[]`, `matchedTaskIds[]`, `matchedLogIds[]`

Notes:
- Uses local embeddings (when available) plus lexical fallback.
- Curated notes are weighted in ranking, especially for related topic/task selection.
- `sessionKey` applies continuity boost for current thread context.

Notes:
- `summary` should be a concise 1–2 line summary generated by OpenClaw.
- `raw` should contain the full prompt/response for deep dive.
- `relatedLogId` links a user-curated note to the original entry.

## Types (summary)

- `Topic`: `{ id, name, description?, priority?, status?, tags?, parentId?, pinned?, createdAt, updatedAt }`
- `Task`: `{ id, topicId, title, status, pinned?, priority?, dueDate?, createdAt, updatedAt }`
- `LogEntry`: `{ id, topicId?, taskId?, relatedLogId?, type, content, summary?, raw?, createdAt, agentId?, agentLabel?, source? }`


path: references/openclaw-hooks.md
content: # OpenClaw hooks for reliable logging

## Goal

Use hooks so every inbound and outbound message is logged to Clawboard with no gaps.

## Recommended: Clawboard logger plugin

This repo ships a plugin at `extensions/clawboard-logger` that registers agent-loop hooks
for `message_received`, `message_sent`, `before_tool_call`, `after_tool_call`, and `agent_end`.

Install (linked for development):

```
openclaw plugins install -l /path/to/clawboard/extensions/clawboard-logger
openclaw plugins enable clawboard-logger
```

Set config with the Clawboard base URL + token.

Implementation note (code path):
- The plugin uses `api.on(<hookName>, handler)` to register hooks (see OpenClaw plugin SDK).
- Hook event names and payloads are defined in `src/plugins/types.ts` in the OpenClaw repo.

## Hook locations (internal hooks)

Internal hooks are discovered from:

- `<workspace>/hooks/` (per-agent, highest precedence)
- `~/.openclaw/hooks/` (managed/shared)

## CLI workflow

- List hooks: `openclaw hooks list`
- Enable: `openclaw hooks enable <hook-id>`
- Disable: `openclaw hooks disable <hook-id>`
- Install hook pack: `openclaw hooks install <path-or-spec>` (use `-l` to link a local dir)

Note: Plugin-managed hooks appear as `plugin:<id>` and must be enabled by enabling the plugin, not via `openclaw hooks enable`.

## Plugin hook events (best for “never miss” logging)

Use plugin hooks for message and tool events inside the agent loop:

- `message_received`
- `message_sending`
- `message_sent`
- `before_tool_call`
- `after_tool_call`
- `agent_end`

These run inside the agent loop and can emit log events for every turn.

## Internal hook events (gateway)

- `command:new`, `command:reset`, `command:stop`
- `agent:bootstrap`
- `gateway:startup`

## Logging policy

For each event, post to Clawboard immediately:

- `conversation` for message_received/message_sent
- `action` for tool calls and agent_end summaries

Include:

- `summary`: 1–2 line summary of the message
- `raw`: full prompt/response
- `agentId`, `agentLabel`, `sessionKey`, `messageId` if available

If Clawboard is unreachable, queue to disk and retry on the next event.


path: references/routing-rules.md
content: # Routing Rules

## Goal

Auto-assign every message/action to the correct topic with minimal user interruption.

## Confidence policy

Only ask the user to pick a topic when **all** are true:

- Confidence < 0.70
- No active topic in the last few turns
- No strong keyword match

If you must ask:

- Ask one short question.
- Offer 2–4 topic options.
- Save the answer as a routing rule (keyword/context -> topic).

## Signals

1. **Recency**: If a topic was active in the last N turns, prefer it.
2. **Keywords**: Match project names, systems, or people to known topics.
3. **Task overlap**: If a task matches the message, use its topic.
4. **Agent lane**: Subagent identity can bias routing (e.g., coding -> Clawboard topic).

## Examples

- “Deploy trading changes” -> Trading Systems
- “Call the insurer” -> Ops/Admin/Finance
- “Add a new client site task” -> Legacy Clients
- “Fix Clawboard UI” -> Clawboard


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)