diff --git a/.gitignore b/.gitignore index e3c90b8..1b46509 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ Thumbs.db .obsidian/workspace.json .obsidian/workspace-mobile.json .obsidian/cache + +# Python — virtualenvs (location convention: scripts/.venv/) and bytecode +**/.venv/ +**/venv/ +**/__pycache__/ +*.pyc diff --git a/docs/setup/audiopen.md b/docs/setup/audiopen.md new file mode 100644 index 0000000..ae2e123 --- /dev/null +++ b/docs/setup/audiopen.md @@ -0,0 +1,210 @@ +# AudioPen → PKB setup + +Voice-note capture pipeline. AudioPen records on phone, polishes on their cloud, POSTs +the polished prose to your own infra. This vault's `inbox/raw/` receives the drop; +`scripts/audiopen-ingest.sh` normalises frontmatter and moves it to `inbox/`; +`/triage-inbox` routes it into `daily/`, `notes/`, `projects/`, or `reading/`. + +Two ingest paths are supported. Pick one. Either works with the same +`/triage-inbox` downstream. + +--- + +## Path A — Webhook via Tailscale Funnel (recommended) + +**Requires.** Tailscale installed on the desktop with Funnel enabled on your tailnet. +Tailscale Funnel provides an outbound-initiated HTTPS endpoint (`*.ts.net`) with no +router configuration and no exposed ports. Nothing sits behind Authentik; AudioPen +hits the Funnel URL directly. + +### 1. Generate a shared secret + +```sh +head -c 24 /dev/urandom | base64 | tr -d '/+=' > /tmp/secret +cat /tmp/secret +``` + +Store it privately; it becomes part of the webhook URL. + +### 2. Install the receiver systemd unit + +```sh +mkdir -p ~/.config/audiopen-webhook ~/.config/systemd/user +printf 'AUDIOPEN_WEBHOOK_SECRET=%s\n' "$(cat /tmp/secret)" > ~/.config/audiopen-webhook/env +chmod 600 ~/.config/audiopen-webhook/env + +cp /var/mnt/data/projects/council/scripts/audiopen-webhook/audiopen-webhook.service.example \ + ~/.config/systemd/user/audiopen-webhook.service + +systemctl --user daemon-reload +systemctl --user enable --now audiopen-webhook.service +systemctl --user status audiopen-webhook.service # should show active (running) +``` + +Tail logs: + +```sh +journalctl --user -u audiopen-webhook.service -f +``` + +### 3. Install the ingest path + service units + +```sh +# audiopen-ingest.service — normalises raw drops +cat > ~/.config/systemd/user/audiopen-ingest.service <<'EOF' +[Unit] +Description=Normalise AudioPen drops into council/inbox/ + +[Service] +Type=oneshot +ExecStart=/var/mnt/data/projects/council/scripts/audiopen-ingest.sh +Environment=COUNCIL_REPO=/var/mnt/data/projects/council +EOF + +# audiopen-ingest.path — fires the service whenever inbox/raw/ gains a file +cat > ~/.config/systemd/user/audiopen-ingest.path <<'EOF' +[Unit] +Description=Watch council/inbox/raw for new AudioPen drops + +[Path] +PathChanged=/var/mnt/data/projects/council/inbox/raw +Unit=audiopen-ingest.service + +[Install] +WantedBy=default.target +EOF + +systemctl --user daemon-reload +systemctl --user enable --now audiopen-ingest.path +``` + +### 4. Expose the receiver via Tailscale Funnel + +```sh +tailscale funnel --bg 8765 +tailscale funnel status # show the public URL +``` + +Your endpoint is now `https://..ts.net:443/`. AudioPen will +POST to `https://..ts.net/audiopen/`. + +### 5. Configure AudioPen + +In AudioPen (web or mobile app): settings → integrations → add a generic webhook. + +- **URL:** `https://..ts.net/audiopen/` +- **Method:** POST +- **Content-Type:** application/json +- **Trigger:** after each recording is polished + +AudioPen typically sends JSON with keys like `title`, `body`, `orig_transcript`. +The receiver accepts any of these; if AudioPen changes shape, check +`journalctl --user -u audiopen-webhook.service` for the logged payload and +update `scripts/audiopen-webhook/main.py::extract_content()` if needed. + +### 6. Test end-to-end + +```sh +SECRET=$(awk -F= '/AUDIOPEN_WEBHOOK_SECRET/ {print $2}' ~/.config/audiopen-webhook/env) +FUNNEL=$(tailscale funnel status | awk '/https:/ {print $1; exit}') + +curl -X POST -H 'Content-Type: application/json' \ + -d '{"title":"Test","body":"Hello from curl. This is a test drop."}' \ + "${FUNNEL%/}/audiopen/${SECRET}" +# → ok + +ls /var/mnt/data/projects/council/inbox/raw # should contain the test file briefly +sleep 2 +ls /var/mnt/data/projects/council/inbox # should now contain the normalised file +``` + +Then record an actual voice note in AudioPen, wait for polishing, confirm the same +flow. Run `/triage-inbox` in Claude Code when you're ready to route the contents. + +--- + +## Path B — Email → IMAP fetch (no-open-ports fallback) + +Use this if you don't run Tailscale / Cloudflare Tunnel and don't want to stand one +up. Fully outbound: AudioPen emails each note; a cron on the desktop pulls via IMAP +and drops files in `inbox/raw/`. + +### 1. Dedicated email address + +Create or repurpose a mailbox for AudioPen's output. Options: + +- Fastmail alias → Fastmail IMAP on desktop (works). +- Self-hosted mail server → pull via IMAP from localhost (works). +- Gmail "+audiopen" alias → fine but adds Google to the dependency chain. + +### 2. IMAP fetch script (stub — you'll customise for your mail server) + +```python +#!/usr/bin/env python3 +"""Minimal IMAP fetcher: pulls unread messages from a mailbox, writes them as +bare markdown to inbox/raw/. Uses stdlib imaplib. Keep credentials in +~/.config/audiopen-mail/env or similar.""" +import imaplib, email, os, re, pathlib, datetime + +HOST = os.environ["IMAP_HOST"] +USER = os.environ["IMAP_USER"] +PASS = os.environ["IMAP_PASS"] +MBOX = os.environ.get("IMAP_MAILBOX", "INBOX") +INBOX = pathlib.Path(os.environ.get("COUNCIL_INBOX_RAW", + "/var/mnt/data/projects/council/inbox/raw")) +INBOX.mkdir(parents=True, exist_ok=True) + +m = imaplib.IMAP4_SSL(HOST) +m.login(USER, PASS) +m.select(MBOX) +typ, data = m.search(None, 'UNSEEN') +for num in data[0].split(): + typ, msg_data = m.fetch(num, '(RFC822)') + msg = email.message_from_bytes(msg_data[0][1]) + title = (msg.get("Subject") or "Untitled").strip() + body = "" + if msg.is_multipart(): + for p in msg.walk(): + if p.get_content_type() == "text/plain": + body = p.get_payload(decode=True).decode('utf-8', errors='replace') + break + else: + body = msg.get_payload(decode=True).decode('utf-8', errors='replace') + slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:60] or "untitled" + ts = datetime.datetime.now().strftime("%Y-%m-%d-%H%M") + (INBOX / f"{ts}_{slug}.md").write_text(f"# {title}\n\n{body}\n") + m.store(num, '+FLAGS', '\\Seen') +m.logout() +``` + +Save as `scripts/audiopen-imap-fetch.py`. Run it every N minutes via a systemd +user-timer or cron. The same `audiopen-ingest.path` unit (from Path A, step 3) +picks up the drops from there. + +### 3. Configure AudioPen + +Settings → integrations → email output → recipient = your dedicated address. +Each recording → one email. Done. + +--- + +## Routine + +Once everything is wired: + +1. **Capture**: voice note → AudioPen → (webhook or email) → desktop. +2. **Ingest**: automatic — you don't run anything; systemd fires on file change. +3. **Route**: when you want, open a Claude Code session in the vault and run + `/triage-inbox`. Koskela proposes destinations; you approve in batch. +4. **Weekly/monthly**: the promoted notes are already in their homes; `/reflect`, + `/review-week`, and `/review-month` will see them naturally. + +## Troubleshooting + +| Symptom | Check | +|---|---| +| Files pile up in `inbox/raw/` but never normalise | `systemctl --user status audiopen-ingest.path audiopen-ingest.service` — path unit enabled? service errored? Run the script manually once: `bash scripts/audiopen-ingest.sh`. | +| Webhook POSTs 200 but no file lands | Check the systemd logs for the receiver: `journalctl --user -u audiopen-webhook.service -n 50`. The logged path should be writable and under `inbox/raw/`. | +| Webhook POSTs 404 | Secret mismatch between AudioPen URL and `~/.config/audiopen-webhook/env`. Regenerate and re-set both sides. | +| Funnel URL unreachable | `tailscale status`; `tailscale funnel status`. Re-run `tailscale funnel --bg 8765`. Confirm Funnel is enabled on your tailnet (admin → DNS → HTTPS Certificates; admin → Access controls → Funnel). | +| Normalised file missing frontmatter | Bug in the ingest wrapper; open the raw file, run the script with `bash -x scripts/audiopen-ingest.sh` to trace. | diff --git a/docs/setup/sync.md b/docs/setup/sync.md new file mode 100644 index 0000000..3beaf61 --- /dev/null +++ b/docs/setup/sync.md @@ -0,0 +1,174 @@ +# Sync architecture — setup guide + +Three independent sync channels. Setting each up on a fresh machine. + +## 1. Gitea over SSH — version history + off-site backup + +**Already configured** on the desktop for this repo: + +```sh +$ git remote -v +origin ssh://git@git.schweitz.net:2222/jpmschweitzer/council.git (fetch) +origin ssh://git@git.schweitz.net:2222/jpmschweitzer/council.git (push) +``` + +On a fresh machine: + +```sh +git clone ssh://git@git.schweitz.net:2222/jpmschweitzer/council.git +cd council +``` + +Requires an SSH key registered in Gitea (Gitea settings → SSH keys → add). +Gitea is behind Authentik for HTTPS, so HTTPS clones won't work without the browser +auth dance. SSH bypasses that entirely — the ssh daemon on Gitea handles key auth +independently. + +### obsidian-git plugin (desktop) + +The `obsidian-git` plugin is already installed in `.obsidian/plugins/`. Configure it +to auto-commit and push on your chosen cadence: + +1. Obsidian settings → Community plugins → Obsidian Git. +2. "Remote URL" — leave blank; it uses the repo's existing `origin`. +3. "Auto commit-and-sync interval" — e.g. 10 minutes. Set to 0 to disable auto-push. +4. "Commit message on auto commit-and-sync" — use the default template. +5. Confirm it can push: Command Palette → "Obsidian Git: Push". + +### What NOT to commit + +Already set in `.gitignore`: +- `.env`, `.env.local`, `.env.*.local` +- `.obsidian/workspace.json`, `.obsidian/workspace-mobile.json`, `.obsidian/cache` +- `.claude/settings.local.json` +- `**/.venv/`, `**/venv/`, `**/__pycache__/`, `*.pyc` + +--- + +## 2. Syncthing — desktop ↔ phone peer-to-peer sync + +Optional. Skip if you're picking **Tier 1** from the phone-role decision (AudioPen +only, no mobile vault). + +### Desktop setup + +Syncthing is available on Bazzite via rpm-ostree (layered) or Flatpak. Flatpak is +simpler: + +```sh +flatpak install flathub me.kozec.syncthingtk +# or +rpm-ostree install syncthing +``` + +Start it: + +```sh +systemctl --user enable --now syncthing.service +# Web UI: +xdg-open http://127.0.0.1:8384 +``` + +In the Syncthing web UI: +1. **Add folder**: path = `/var/mnt/data/projects/council/`, folder ID = `council`. +2. **Ignore patterns** (crucial — click "Edit" → "Ignore Patterns" on the folder): + + ``` + .git + .obsidian/workspace.json + .obsidian/workspace-mobile.json + .obsidian/cache + .trash + **.sync-conflict-* + scripts/.venv + ``` + + This list matters. Syncthing must never propagate `.git/` — partial packs mid-commit + would corrupt the mirror. + +### Phone setup (Android) + +1. Install **Syncthing-Fork** from F-Droid (more robust than upstream Syncthing on + Android). +2. Pair with desktop: desktop shows its device ID in the web UI → enter on phone + → desktop accepts the pairing. +3. On the phone, accept the `council` folder share. Set its target directory to + e.g. `/sdcard/Documents/council/`. +4. Install **Obsidian Mobile**. Open as vault → pick the Syncthing-watched path. + +### Conflict discipline + +Both devices edit the same file while disconnected → Syncthing creates +`.sync-conflict-YYYYMMDD-HHMMSS-deviceID.md`. Rare if you edit one device +at a time. To resolve: read both, copy the good bits into the canonical file, +delete the conflict. + +### How the Syncthing/git channels interact + +- **Git lives on desktop only.** The phone never sees `.git/` (it's in `.stignore`). +- **Syncthing replicates content only.** The phone has the markdown; it doesn't have + history. +- **To push from desktop**: just `git push` as usual. obsidian-git automates it. +- **To pull new content on desktop from phone**: Syncthing already did it; there's + nothing to pull. Review the changes in `git status` and commit them like any + other working-tree edit. + +--- + +## 3. AudioPen webhook via Tailscale Funnel — voice-note ingestion + +See [`docs/setup/audiopen.md`](./audiopen.md). Independent of the two above. + +--- + +## Phone-role decision + +When you set up Obsidian Mobile (if at all), pick a tier: + +| Tier | Phone runs | Trade-off | +|---|---|---| +| 1 | AudioPen only | Simplest. No vault on phone. Read/edit on desktop. | +| 2 | AudioPen + browser → Gitea markdown view | Occasional phone read via Authentik-gated Gitea. | +| 3 | AudioPen + Syncthing-Fork + Obsidian Mobile | Full vault on phone. Maximum capability, moderate battery + ~few hundred MB storage for the vault. | + +All three are compatible with the rest of the stack. The AudioPen pipeline +(path A or B) is identical across tiers. + +--- + +## Verification + +After everything is set up: + +```sh +# Git remote reachable +git ls-remote origin | head -3 # should list refs from Gitea + +# Syncthing running (if Tier 2/3) +systemctl --user is-active syncthing.service +# → active + +# Tailscale Funnel for AudioPen webhook +tailscale funnel status +# should list the https endpoint for :8765 + +# AudioPen receiver running +systemctl --user is-active audiopen-webhook.service +# → active + +# Push a test commit +cd /var/mnt/data/projects/council +echo "# test" > /tmp/sync-test.md +mv /tmp/sync-test.md daily/2099-01-01.md +git add daily/2099-01-01.md +git commit -m "test sync pipeline" -m "Co-Authored-By: me" +git push +# should succeed + +# On phone (Tier 3): wait a moment, open Obsidian Mobile, verify daily/2099-01-01.md appears. + +# Clean up +git rm daily/2099-01-01.md +git commit -m "drop sync test" +git push +``` diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..b3dbd24 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,62 @@ +# scripts/ + +Operational scripts for the PKB. Everything here is side-effectful — writing files, +running webhooks, ingesting voice notes. Keep it small; anything analytical belongs +in `.claude/commands/` or in the `mql` CLI. + +## Current contents + +| Path | Language | Purpose | +|---|---|---| +| `audiopen-ingest.sh` | bash | Normalises raw AudioPen drops (`inbox/raw/*.md`) into properly-frontmattered fleeting notes in `inbox/`. Idempotent. | +| `audiopen-webhook/main.py` | Python (stdlib) | Tiny HTTP server that receives AudioPen webhook POSTs and writes to `inbox/raw/`. Designed to sit behind Tailscale Funnel. | +| `audiopen-webhook/*.example` | systemd | User-unit examples for the receiver + the path/service that runs `audiopen-ingest.sh` on file change. | + +Setup guide for the full pipeline: [`docs/setup/audiopen.md`](../docs/setup/audiopen.md). + +## Python venv convention + +Python code in this repo is **stdlib-only as of now**. No pip-installable dependencies. +If/when a real dependency arrives (e.g. `watchdog`, `imap-tools`, anything heavier +than stdlib), the convention is: + +- Venv lives at **`scripts/.venv/`** (gitignored — every user materialises locally). +- Dependencies tracked in **`scripts/pyproject.toml`** (committed). +- Bootstrap on a fresh machine: + + ```sh + cd scripts + python3 -m venv .venv + .venv/bin/pip install -e . + ``` + + Or with `uv` (recommended if available): + + ```sh + cd scripts + uv venv + uv pip install -e . + ``` + +- Scripts that need the venv should either: + - Invoke `scripts/.venv/bin/python3` explicitly in their shebang or systemd `ExecStart`. + - Or document activation (`source scripts/.venv/bin/activate`) before running. + +Keeping the venv under `scripts/` rather than the repo root keeps the Obsidian file +tree uncluttered — `scripts/` is already excluded from Obsidian's index +(`userIgnoreFilters` in `.obsidian/app.json`) so nothing Python-related will ever +show in the graph or search. + +**Don't create the venv preemptively.** When the first dependency lands, that's when +you create it. Keeps the repo lighter and avoids stale venv directories. + +The `pyproject.toml` uses `[project.optional-dependencies]` to split deps by tool +— e.g. `pip install -e .[imap]` installs only the IMAP fetcher's deps. This lets +the webhook receiver stay dep-free while heavier tools can pull in what they need. + +## Shell scripts + +Kept in plain bash. Portable to any Linux running GNU coreutils (which Bazzite does). +No zsh-isms, no bashisms beyond `set -euo pipefail` and arrays. If a script grows +past ~200 lines or needs real argument parsing, rewrite it in Python (and you're now +back in the venv convention above). diff --git a/scripts/audiopen-ingest.sh b/scripts/audiopen-ingest.sh new file mode 100755 index 0000000..d50cdc5 --- /dev/null +++ b/scripts/audiopen-ingest.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# audiopen-ingest.sh +# +# Normalises raw AudioPen drops in inbox/raw/ into proper fleeting notes in inbox/. +# Adds frontmatter, renames to timestamp-slug format, moves to inbox/. +# Idempotent: safe to re-run. Destructive on inbox/raw/ (files are moved, not copied). +# +# Environment: +# COUNCIL_REPO — vault root. Default: /var/mnt/data/projects/council +# +# Exit status: +# 0 = success (even if zero files were processed) +# 1 = unexpected error + +set -euo pipefail + +REPO="${COUNCIL_REPO:-/var/mnt/data/projects/council}" +RAW="$REPO/inbox/raw" +OUT="$REPO/inbox" + +if [ ! -d "$RAW" ]; then + echo "audiopen-ingest: raw dir $RAW does not exist; nothing to do." >&2 + exit 0 +fi +mkdir -p "$OUT" + +shopt -s nullglob +count=0 +for f in "$RAW"/*.md; do + # mtime as ISO-8601 (date -r works on GNU coreutils; Bazzite ships it) + captured=$(date -r "$f" -Iseconds) + ts=$(date -r "$f" +%Y-%m-%d-%H%M) + + # Title: first `# ` line, else filename (without extension) + title=$(grep -m1 '^# ' "$f" 2>/dev/null | sed 's/^# *//' || true) + if [ -z "$title" ]; then + title=$(basename "$f" .md) + fi + + # Slugify: lowercase, non-alnum → dash, collapse dashes, trim, cap at 60 + slug=$(printf '%s' "$title" | tr '[:upper:]' '[:lower:]' \ + | sed 's/[^a-z0-9]/-/g' \ + | sed 's/--*/-/g' \ + | sed 's/^-*//;s/-*$//' \ + | cut -c1-60) + [ -z "$slug" ] && slug="untitled" + + out="$OUT/${ts}_${slug}.md" + + # If target exists (same timestamp + slug), append a numeric suffix + i=2 + while [ -e "$out" ]; do + out="$OUT/${ts}_${slug}_${i}.md" + i=$((i+1)) + done + + # Body: strip the first `# title` line so we regenerate it in the normalised form + content=$(awk 'BEGIN{stripped=0} /^# / && !stripped { stripped=1; next } { print }' "$f") + + { + echo "---" + echo "type: fleeting" + echo "source: audiopen" + echo "captured: $captured" + echo "slug: $slug" + echo "---" + echo + echo "# $title" + echo + printf '%s\n' "$content" + } > "$out" + + rm "$f" + echo "ingested: $(basename "$f") → $(basename "$out")" + count=$((count+1)) +done + +echo "audiopen-ingest: $count file(s) normalised." diff --git a/scripts/audiopen-webhook/README.md b/scripts/audiopen-webhook/README.md new file mode 100644 index 0000000..efe8b12 --- /dev/null +++ b/scripts/audiopen-webhook/README.md @@ -0,0 +1,69 @@ +# audiopen-webhook + +Zero-dependency Python HTTP server that receives AudioPen webhook POSTs and +writes bare markdown files to `inbox/raw/`. The separate `scripts/audiopen-ingest.sh` +wrapper normalises those files into proper fleeting notes under `inbox/`. + +## Why this shape + +- **Receiver is minimal (stdlib-only http.server).** No Flask, no runtime to keep + updated, ~150 LOC. Reads AudioPen's JSON payload, writes one file, logs to + stderr. Anything fancier belongs in the ingest wrapper. +- **Two-step raw → normalised.** Keeps the receiver stupid; all schema decisions + (frontmatter, slug format, filename) live in one shell script you can read + in 30 seconds. +- **Designed for Tailscale Funnel.** Binds to 127.0.0.1 by default. Tailscale + Funnel (or Cloudflare Tunnel, or an SSH remote forward, or ngrok) terminates + the public endpoint and proxies to 127.0.0.1:8765. No ports opened on your + router; nothing behind Authentik. + +## Setup + +Full step-by-step lives in [`docs/setup/audiopen.md`](../../docs/setup/audiopen.md). +Quick version: + +1. **Generate a secret**: `head -c 24 /dev/urandom | base64 | tr -d '/+='`. +2. **Configure AudioPen** to POST JSON to `https://.ts.net/audiopen/`. +3. **Install the systemd user units**: + ```sh + mkdir -p ~/.config/audiopen-webhook ~/.config/systemd/user + echo "AUDIOPEN_WEBHOOK_SECRET=" > ~/.config/audiopen-webhook/env + chmod 600 ~/.config/audiopen-webhook/env + cp audiopen-webhook.service.example ~/.config/systemd/user/audiopen-webhook.service + # create two more systemd files for the ingest path/service per + # audiopen-ingest.path.example + systemctl --user daemon-reload + systemctl --user enable --now audiopen-webhook.service audiopen-ingest.path + ``` +4. **Expose via Tailscale Funnel**: + ```sh + tailscale funnel --bg 8765 + ``` +5. **Test**: + ```sh + curl -X POST -H 'Content-Type: application/json' \ + -d '{"title":"Test","body":"Hello from curl"}' \ + https://.ts.net/audiopen/ + # → OK + ls ~/path/to/vault/inbox/raw # should see the test file + # wait a beat for the inotify path unit to fire + ls ~/path/to/vault/inbox # should see the normalised file + ``` + +## Payload shape + +The receiver accepts any of these JSON keys for the title: `title`, `name`. +For the body: `body`, `output`, `summary`, `polished`, `orig_transcript`. Unknown +keys are ignored; the raw payload is logged to stderr so you can see what +AudioPen actually sent if shape drifts. + +If AudioPen's webhook format changes in a way the current extractor misses, +check `journalctl --user -u audiopen-webhook.service` for the logged shape and +update the key list in `extract_content()` at the top of `main.py`. + +## Alternative: email + IMAP fetch + +If running a public-reachable receiver isn't viable, the plan has an email-path +fallback documented in `docs/setup/audiopen.md`. It's entirely outbound: AudioPen +emails each note to a dedicated address, and a cron on the desktop pulls via +IMAP and drops files in `inbox/raw/`. Same ingest wrapper handles the rest. diff --git a/scripts/audiopen-webhook/audiopen-ingest.path.example b/scripts/audiopen-webhook/audiopen-ingest.path.example new file mode 100644 index 0000000..347d3aa --- /dev/null +++ b/scripts/audiopen-webhook/audiopen-ingest.path.example @@ -0,0 +1,34 @@ +# audiopen-ingest path + service units — example +# +# These two units let the ingest wrapper fire automatically whenever +# inbox/raw/ gains a file. Install: +# +# cp audiopen-ingest.path.example ~/.config/systemd/user/audiopen-ingest.path +# cp audiopen-ingest.service.example ~/.config/systemd/user/audiopen-ingest.service +# systemctl --user daemon-reload +# systemctl --user enable --now audiopen-ingest.path +# +# (Adjust %h paths or vault location as needed.) + +# ---- audiopen-ingest.path ---- +[Unit] +Description=Watch council/inbox/raw for new AudioPen drops + +[Path] +PathChanged=/var/mnt/data/projects/council/inbox/raw +Unit=audiopen-ingest.service + +[Install] +WantedBy=default.target + +# ---- audiopen-ingest.service ---- +# (Put this in a separate file — audiopen-ingest.service — systemd does not +# allow two units in one file.) +# +# [Unit] +# Description=Normalise AudioPen drops into council/inbox/ +# +# [Service] +# Type=oneshot +# ExecStart=/var/mnt/data/projects/council/scripts/audiopen-ingest.sh +# Environment=COUNCIL_REPO=/var/mnt/data/projects/council diff --git a/scripts/audiopen-webhook/audiopen-webhook.service.example b/scripts/audiopen-webhook/audiopen-webhook.service.example new file mode 100644 index 0000000..dd2e968 --- /dev/null +++ b/scripts/audiopen-webhook/audiopen-webhook.service.example @@ -0,0 +1,36 @@ +# audiopen-webhook user systemd unit — example +# +# Install to ~/.config/systemd/user/audiopen-webhook.service, then: +# systemctl --user daemon-reload +# systemctl --user enable --now audiopen-webhook.service +# +# Check logs: +# journalctl --user -u audiopen-webhook.service -f +# +# The shared secret lives in ~/.config/audiopen-webhook/env (chmod 600). +# Example env file contents: +# AUDIOPEN_WEBHOOK_SECRET= +# # Optional overrides: +# # AUDIOPEN_WEBHOOK_HOST=127.0.0.1 +# # AUDIOPEN_WEBHOOK_PORT=8765 +# # COUNCIL_INBOX_RAW=/var/mnt/data/projects/council/inbox/raw + +[Unit] +Description=AudioPen webhook receiver (writes to council/inbox/raw) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +EnvironmentFile=%h/.config/audiopen-webhook/env +ExecStart=/usr/bin/python3 %h/projects/council/scripts/audiopen-webhook/main.py +Restart=on-failure +RestartSec=5 +# Narrow permissions: +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/var/mnt/data/projects/council/inbox/raw + +[Install] +WantedBy=default.target diff --git a/scripts/audiopen-webhook/main.py b/scripts/audiopen-webhook/main.py new file mode 100755 index 0000000..e39546e --- /dev/null +++ b/scripts/audiopen-webhook/main.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""AudioPen webhook receiver. + +Listens for POST requests at /audiopen/ and writes the polished-prose +payload to COUNCIL_INBOX_RAW as a bare markdown file. A separate cron/systemd job +(or inotify) then runs audiopen-ingest.sh to normalise into the PKB inbox. + +Zero external dependencies (stdlib only). Designed to sit behind Tailscale Funnel +or similar outbound-initiated tunnel — binds to 127.0.0.1 by default. + +Environment: + AUDIOPEN_WEBHOOK_SECRET required. Shared secret in the URL path. + AUDIOPEN_WEBHOOK_HOST default 127.0.0.1 + AUDIOPEN_WEBHOOK_PORT default 8765 + COUNCIL_INBOX_RAW default /var/mnt/data/projects/council/inbox/raw + +AudioPen's webhook payload shape varies by version; we accept any of these JSON keys +for the body: `body`, `output`, `summary`, `polished`. For the title: `title`, +`name`. Unknown fields are ignored. The raw payload is kept in an HTTP access log +on stderr so you can inspect it if AudioPen changes shape. +""" + +from __future__ import annotations +import datetime as _dt +import json +import os +import pathlib +import re +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + + +INBOX_RAW = pathlib.Path( + os.environ.get("COUNCIL_INBOX_RAW", "/var/mnt/data/projects/council/inbox/raw") +) +SECRET = os.environ.get("AUDIOPEN_WEBHOOK_SECRET") +HOST = os.environ.get("AUDIOPEN_WEBHOOK_HOST", "127.0.0.1") +PORT = int(os.environ.get("AUDIOPEN_WEBHOOK_PORT", "8765")) + +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def slugify(text: str, max_len: int = 60) -> str: + s = _SLUG_RE.sub("-", text.strip().lower()).strip("-") + return (s or "untitled")[:max_len] + + +def extract_content(payload: dict) -> tuple[str, str]: + title = str( + payload.get("title") + or payload.get("name") + or "" + ).strip() + body = str( + payload.get("body") + or payload.get("output") + or payload.get("summary") + or payload.get("polished") + or payload.get("orig_transcript") + or "" + ).strip() + if not title and body: + title = body.splitlines()[0][:60] + return title or "Untitled", body + + +class Handler(BaseHTTPRequestHandler): + server_version = "audiopen-webhook/1" + + def _reply(self, status: int, msg: str = "") -> None: + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(msg))) + self.end_headers() + if msg: + self.wfile.write(msg.encode()) + + def do_POST(self) -> None: + parts = [p for p in self.path.split("/") if p] + if len(parts) < 2 or parts[0] != "audiopen" or parts[1] != SECRET: + self._reply(404, "not found") + return + + length = int(self.headers.get("Content-Length", "0") or 0) + raw = self.rfile.read(length) if length > 0 else b"" + try: + payload = json.loads(raw) if raw else {} + except json.JSONDecodeError: + self._reply(400, "invalid JSON") + return + if not isinstance(payload, dict): + self._reply(400, "expected JSON object") + return + + title, body = extract_content(payload) + if not body: + self._reply(400, "empty body") + return + + slug = slugify(title) + ts = _dt.datetime.now().strftime("%Y-%m-%d-%H%M") + filename = f"{ts}_{slug}.md" + + INBOX_RAW.mkdir(parents=True, exist_ok=True) + target = INBOX_RAW / filename + # Avoid overwriting on same-minute collision + suffix = 2 + while target.exists(): + target = INBOX_RAW / f"{ts}_{slug}_{suffix}.md" + suffix += 1 + + target.write_text(f"# {title}\n\n{body}\n", encoding="utf-8") + sys.stderr.write(f"[audiopen-webhook] wrote {target}\n") + self._reply(200, "ok\n") + + def do_GET(self) -> None: + # Health check at /health; everything else returns 404. + if self.path == "/health": + self._reply(200, "ok\n") + else: + self._reply(404, "not found") + + def log_message(self, fmt: str, *args) -> None: + sys.stderr.write(f"[audiopen-webhook] {self.address_string()} {fmt % args}\n") + + +def main() -> int: + if not SECRET: + sys.stderr.write("AUDIOPEN_WEBHOOK_SECRET env var is required.\n") + return 1 + sys.stderr.write( + f"[audiopen-webhook] listening on http://{HOST}:{PORT}, inbox_raw={INBOX_RAW}\n" + ) + HTTPServer((HOST, PORT), Handler).serve_forever() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml new file mode 100644 index 0000000..7081b58 --- /dev/null +++ b/scripts/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "council-scripts" +version = "0.1.0" +description = "Operational scripts for the Council PKB — AudioPen ingest, webhook receiver, inbox normalisation." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +# Empty today. Stdlib-only code in this directory. Add entries here when a real +# pip-installable dependency arrives (e.g. watchdog, imap-tools). Bootstrap: +# +# cd scripts +# python3 -m venv .venv +# .venv/bin/pip install -e . +# +# Or with uv: +# +# cd scripts +# uv venv +# uv pip install -e . + +[project.optional-dependencies] +# Example shape for when deps arrive — split by tool so you can install only what +# you run. Uncomment and populate as needed. +# +# imap = [ +# "imap-tools>=1.4.0", +# ] +# watch = [ +# "watchdog>=4.0.0", +# ] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +# There's no package to build — these are standalone scripts. Point setuptools +# at an empty package list so `pip install -e .` succeeds as a no-op installer +# for the dependency manifest, without trying to import a module named +# `council-scripts`. +packages = []