diff --git a/.planning/RELEASE-1.7.121-TASKS.md b/.planning/RELEASE-1.7.121-TASKS.md index d58303ae..b39f5d9d 100644 --- a/.planning/RELEASE-1.7.121-TASKS.md +++ b/.planning/RELEASE-1.7.121-TASKS.md @@ -27,6 +27,143 @@ Status key: **DONE** (committed) · **READY** (written, not yet committed/tested - Scope note: `fips/app_ports.rs` holds the mesh allowlist; `is_peer_allowed_path` in `server.rs` holds the peer HTTP allowlist. Neither currently authenticates app ports. +#### Research — umbrelOS (verified from their docs/source, 2026-08-03) + +umbrelOS solves this **architecturally, not per-app**: the app's own port is never +published. Each app gets a sidecar `app_proxy` container that owns the published port and +forwards to the app on the internal network. + +- `containers/app-proxy` is described as *"a transparent HTTP proxy to add authentication + to Umbrel apps"* — **every** HTTP request and WebSocket upgrade passes through it and + has its session token checked. +- Tokens come from a separate `app-auth` service; the proxy talks to it over a local port + (default 2000) with a shared secret (`UMBREL_AUTH_SECRET`). Two JWTs exist: an **API + token** in localStorage (`{loggedIn: true}`) for the dashboard's own API, and a + **proxy token** in an **HttpOnly cookie** (`{proxyToken: true}`) for app access. Both + HS256, 7-day expiry. +- Unauthenticated requests are redirected to the login screen. +- Per-app escape hatches, all env vars on the proxy: `PROXY_AUTH_ADD` (bool, **default + true** — so apps are protected unless opted out), `PROXY_AUTH_WHITELIST` (paths exempt, + e.g. `/public/*`), `PROXY_AUTH_BLACKLIST` (paths that must be authed, e.g. `/admin/*`). +- Known friction worth designing around: apps with their own login (Frigate, and the + `PROXY_AUTH_ADD=false` tracker issue) end up double-authenticating, and non-browser API + clients (Home Assistant hitting an app's API) break because they have no cookie. Any + gate we build needs a story for machine clients, not just browsers. + +**The lesson for us:** the reason umbrel doesn't have this bug class is that there is no +unauthenticated path to bind to in the first place. Our apps publish their own ports +directly, so a gate bolted onto one transport leaves the others open — which is exactly +the shape of the `/lnd-connect-info` + `/bitcoin-rpc/` leaks. The fix likely has to move +the port binding, not just add a check. + +#### Reproduced ON archi-dev-box, 2026-08-03 — baseline before the fix + +No session cookie, over the Tailscale IP `100.69.68.39`: + +``` +port 18083 HTTP 200 LND - Archipelago +port 8334 HTTP 200 +port 8175 HTTP 200 Fedimint Guardian - Archipelago +port 8336 HTTP 200 FIPS Mesh +port 8090 HTTP 200 +port 7777 HTTP 200 +``` + +`ss -tlnp` confirms these are bound `0.0.0.0`, so the same responses are served on the LAN +IP and every other host address. Re-run this exact loop after the fix: every one must +become the login page, and the ports listed as protocol exemptions (item 1b) must be the +*only* ones still answering. + +#### Exposure map — how app ports are reachable TODAY (verified in source, 2026-08-03) + +All four transports converge on `127.0.0.1:`. This is the whole reason the fix +is tractable: it is **one gate, not four**. + +| Transport | Path to the app | Code | +|---|---|---| +| LAN / Tailscale | container publishes the port on the host (`--network host`, so `0.0.0.0:`) — reachable on *every* host IP | `scripts/container-specs.sh`, `first-boot-containers.sh` | +| FIPS mesh | daemon binds `[fips0-ULA]:` and raw-TCP-forwards to `127.0.0.1:` | `server.rs:1130` `app_port_v6_relay_loop` | +| FIPS firewall | `tcp dport { …APP_LAUNCH_PORTS… } accept` drop-in opens them all | `fips/config.rs:274`, `fips/app_ports.rs` | +| Tor | `HiddenServicePort 80 127.0.0.1:` per service | `api/rpc/tor/mod.rs:243` | + +#### Design decision (operator, 2026-08-03) + +**Gate app UIs + bearer tokens; protocol ports exempt.** HTTP app UIs get the login gate +(app name + icon, 2FA honoured). Protocol ports (LND gRPC 10009 + REST, electrum 50002, +bitcoin p2p 8333) stay open but MUST be declared `auth: none` with a rationale in the +manifest, so the exceptions are a grep rather than a discovery — see item 1b. Per-app +long-lived bearer tokens cover machine clients that speak HTTP (Home Assistant). **Zeus +and electrum wallets keep working untouched** — that was the deciding constraint. + +The gate lives in the **daemon**, not a per-app sidecar container (umbrel's `app_proxy` +model): rootless, no extra containers per app, one place to update, and it can reuse the +existing `app_port_v6_relay_loop` rather than fight it. + +#### ⚠️ Trap found while designing — an nft-only gate FAILS OPEN + +The obvious implementation is an nft redirect of inbound app-port traffic to the gate. +But `/etc/fips/fips.nft` is **provisioned out-of-band** and `fips/config.rs:290` treats its +absence as a no-op (`if try_exists("/etc/fips/fips.nft")`). A gate shipped as a `fips.d` +drop-in would therefore be **silently absent on every node without the hardening +baseline** — i.e. it fails open, which is exactly the failure class this item exists to +close. + +Two viable shapes, both fail-closed: +- **(a) Apps bind loopback only**, daemon owns every external bind. Airtight, the true + umbrel model, but requires touching each app's own listen config (nginx.conf etc.). + Note you *cannot* half-do this: while an app holds `0.0.0.0:`, the daemon cannot + bind `:` at all. +- **(b) Daemon owns a dedicated `archipelago-appgate` nft table** with its own + default-deny + redirect, independent of whether `fips.nft` exists, and refuses to start + / alarms loudly if it cannot install it. Non-invasive to apps. + +#### Enabler found — `PortMapping.bind` already does half of (a) + +`core/container/src/manifest.rs:518` — `PortMapping` has a `bind` field, documented as +*"Host address to bind the publish to. Empty = all interfaces (0.0.0.0). Set `127.0.0.1` +to keep a port host-local"*. So for **bridge apps that declare `ports:`**, going +loopback-only is a **manifest edit, not app surgery**, and the daemon can then own the +external bind. That is most of the catalog. + +The exception is **host-networked apps** (`security.network_policy: host` — `lnd-ui`, +`bitcoin-ui`, `electrs-ui`): host networking bypasses port mapping entirely, so `bind` has +no effect and `ports:` is deliberately empty. Those bind whatever their internal nginx +binds. We build those images ourselves, so the fix is a `listen 127.0.0.1:;` change +in each `docker/*-ui/nginx.conf` — still no third-party surgery. + +Watch the rootless trap documented at `manifest.rs:532`: a publish bound to an address the +host cannot actually bind crash-loops the whole unit (took bitcoin down fleet-wide on .228, +2026-07-09). Loopback binds are explicitly always accepted without probing, so this +direction is safe. + +**Tor needs separate handling either way**: the onion connects *from* localhost, so a +redirect that exempts loopback will not catch it. `HiddenServicePort` must be repointed at +the gate, and since that mapping loses the original destination port, each app needs its +own gate port (or an HTTP-level Host mapping). + +#### Primitives that already exist — do NOT build these from scratch + +The gate is mostly assembly, not invention: + +| Need | Existing API | +|---|---| +| Read the session cookie off a request | `session::extract_session_cookie(&HeaderMap) -> Option` (`session.rs:479`) | +| Validate a session | `SessionStore::validate(&token) -> bool` (`session.rs:194`) | +| **Honour 2FA** | Already modelled: `create_pending(totp_secret)` (`:176`) + `upgrade_to_full` (`:247`). A session still pending 2FA **fails `validate()`**, so the gate gets 2FA for free by calling `validate` — no TOTP code in the gate itself | +| **Machine-client bearer tokens** | `device_tokens::create/verify` (`device_tokens.rs:63/:90`) — long-lived, minted from an authenticated session, only the SHA-256 persisted, plaintext returned once. Built for the companion pairing QR; needs **per-app scoping** added for this use | +| Rate limiting | `device_tokens` verification already rides `auth.login`'s limiter | + +So the new code is: the listener/redirect, the app-identification step (which app is this port?), +the login page render (app name + icon), and per-app scoping on `device_tokens`. + +#### Research — StartOS: **NOT YET VERIFIED** + +Their public docs cover the *addressing* model (per-service `.onion` and `.local` +addresses, an explicit "make public" opt-in for clearnet) but do not state whether a +universal auth layer sits in front of service interfaces, and the source could not be +read from this box (`gh` is not installed, and raw GitHub paths 404'd). **Do not assume +they delegate auth to each service — read `Start9Labs/start-os` before designing.** + ### 2. Filebrowser ships an insecure default login — **OPEN** - Change the default credential **without breaking the dashboard's Cloud view**, which authenticates to filebrowser on the user's behalf. @@ -51,23 +188,44 @@ Two independent fail-open paths granted `Trusted` without any operator decision: - Added `FederatedNode.trust_source` (`invite` | `uninvited-join` | `transitive-merge` | `manual`, `None` = pre-existing/unknown) so existing grants are **auditable**. Per operator decision: existing peers are **left alone, not auto-demoted**. -- **Still to do:** surface `trust_source` in `federation.list-nodes` + the UI so the - operator can actually review the `None`/`uninvited-join` population. +- `trust_source` is now **surfaced** in `federation.list-nodes` (as an explicit `null` + when unknown, not omitted — "recorded before this was tracked" is the population that + needs review, so the UI must be able to tell it apart from a field it didn't read) and + rendered under the trust dropdown in the node detail modal as "Granted via:". -### 3b. Granting Trusted must require the node password — **OPEN** +### 3b. Granting Trusted must require the node password — **DONE** (uncommitted at time of writing) > "to make someone trusted must require the node password to generate the code or change > in the modal dropdown when you click a node" — operator, 2026-08-03 -Re-authentication on privilege escalation. Two entry points, both must be covered: +Re-authentication on privilege escalation. Both entry points are covered: -- **Minting a Trusted invite** (`federation.invite` with `trust_level: "trusted"`) — - "Link Your Nodes" mints Trusted today with no re-auth. -- **Changing a node's level in the UI dropdown** (`federation.set-trust-level` / - `handlers.rs:342`) — promoting Observer → Trusted. +- **Minting a Trusted invite** (`federation.invite`) — gated on the **resolved** level, + which matters because "Link Your Nodes" sends no `trust_level` at all and falls through + to the `Trusted` default. The invite is a bearer grant of Trusted to whoever redeems + it, so minting it *is* the escalation. Observer invites are untouched. +- **Changing a node's level in the UI dropdown** (`federation.set-trust`) — gated only + when the peer is **not already** Trusted, so the dropdown re-emitting its own value + doesn't demand a password for a no-op. -Demotion must NOT require the password: making something less privileged should never be -harder than leaving it. Grant `TrustSource::Manual` on the operator path so the audit -trail distinguishes it from the capped automatic ones. +Demotion is NOT gated: making something less privileged must never be harder than leaving +it, or the safe action becomes the inconvenient one. The operator path stamps +`TrustSource::Manual`; `set_trust_level` grew an `Option` so automatic +adjustments (the discovery-handshake demotion safety net) pass `None` and leave the +recorded provenance alone rather than laundering an `uninvited-join` peer into looking +operator-approved. + +Wiring: the backend is the sole authority on what counts as an escalation — it returns a +`PASSWORD_REQUIRED:` prefixed error, and the UI prompts and retries only on that. The +frontend never pre-judges, so the rule lives in exactly one place. +`TrustPasswordModal.vue` (modelled on `RotateDidModal.vue`) serves both flows. +`NodeDetailModal`'s select now snaps back to the node's real level on change, because a +cancelled or failed promotion would otherwise leave the dropdown displaying a level the +node never accepted. + +**Follow-up, deliberately not done here:** `federation.join` also grants Trusted (when +redeeming someone else's Trusted invite) with no re-auth. It is an explicit operator +paste rather than a UI toggle, and was outside the two entry points specified — but it is +the third way a node reaches Trusted and should be reviewed. --- @@ -104,8 +262,32 @@ trail distinguishes it from the capped automatic ones. Known groundwork: the signed catalog (`releases/app-catalog.json`, `sign-catalog.sh`), catalog→manifest runtime reload, `package.update` RPC, `check-app-catalog-drift.py`, and `scripts/image-versions.sh` pinning. -- Needs: registry-version awareness per app, a diff of what changed (UI vs app vs both), - the modal + detail-page affordance, and distinct iconography for the three cases. + +#### What already exists (verified in source, 2026-08-03) — the operator was right + +The whole update *pipeline* is built and is already independent of OTA: + +- `package.check-updates` (`api/rpc/package/update.rs:180`) refreshes the signed catalog + and hot-reloads manifests when it changed — no daemon restart, no OTA involved. +- `package.update` (`spawn_package_update`), `package.versions`, `package.set-config` + version pinning, and `execute_update` (stop → pull → remove → recreate → verify). +- Version awareness: `app_catalog::catalog_versions(app_id)` vs `installed_version()` + (`api/rpc/package/set_config.rs:46`). +- Frontend: `AppCard.vue` already renders an Update button off `pkg['available-update']` + (`:48`, `:128`) and emits `update`. + +#### What is actually MISSING (this is the real scope of item 6) + +1. **The UI-vs-app-vs-both distinction does not exist.** `available-update` is a single + version string — nothing classifies whether the change is the app image, its `*-ui` + image, or both. This is the core of the operator's ask ("a different graphic for just + ui, app, or both together") and needs a backend change, not just an icon. + ⚠️ Compounding factor: per `reference_app_ui_delivery_model`, `*-ui` apps are **not in + the signed catalog** at all — so "is there a UI update" cannot be answered from the + catalog today. That gap has to be closed first or the UI half is unanswerable. +2. **The modal** (Update now / Cancel) — the card currently updates on click, no confirm. +3. **The detail-page affordance** — same treatment as the card. +4. **Button copy**: "See update" rather than "Update". --- diff --git a/aiui/.claude/hooks/block-risky-bash.sh b/aiui/.claude/hooks/block-risky-bash.sh new file mode 100755 index 00000000..b27f9f0e --- /dev/null +++ b/aiui/.claude/hooks/block-risky-bash.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# PreToolUse Bash guard: block dangerous shell commands. +# Denies: rm -rf, git reset --hard, git push -f, git clean -fd, chmod -R 777, +# fork bombs, block device overwrites, mkfs, paths escaping project root. +# Uses python3 instead of jq for JSON (guaranteed on macOS). +set -euo pipefail + +INPUT=$(cat) +CMD=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('tool_input', {}).get('command', '')) +except: pass +" <<< "$INPUT") +BASE="${CLAUDE_PROJECT_DIR:-}" +[[ -z "$BASE" ]] && BASE=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('cwd', '')) +except: pass +" <<< "$INPUT") +[[ -z "$BASE" ]] && BASE="$(pwd)" + +# Normalize: collapse whitespace, strip leading/trailing +CMD_NORM=$(echo "$CMD" | tr -s '[:space:]' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + +deny() { + local reason="$1" + python3 -c " +import json +print(json.dumps({ + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': '$reason' + } +})) +" + exit 0 +} + +# Dangerous patterns (case-insensitive where sensible) +case "$CMD_NORM" in + *"rm -rf"*|*"rm -fr"*|*"rm -f -r"*|*"rm -r -f"*) deny "Destructive rm -rf blocked by security hook" ;; + *"git reset --hard"*) deny "git reset --hard would lose uncommitted work" ;; + *"git push --force"*|*"git push -f"*|*"git push -f "*) deny "git push --force would rewrite history" ;; + *"git clean -fd"*|*"git clean -f -d"*) deny "git clean -fd deletes untracked files" ;; + *"chmod -R 777"*|*"chmod -R 0777"*) deny "chmod -R 777 is a security risk" ;; + *":(){ :"*"};:"*) deny "Fork bomb pattern blocked" ;; + *"> /dev/sd"*|*">/dev/sd"*) deny "Block device overwrite blocked" ;; + *"mkfs "*|*"mkfs."*) deny "Disk format command blocked" ;; +esac + +# Check for path traversal escaping project root (../ outside project) +# Only if we have a sensible base +if [[ -n "$BASE" ]] && [[ -d "$BASE" ]]; then + # Simple heuristic: command contains .. and would resolve outside project + if echo "$CMD_NORM" | grep -qE '\.\./|/\.\.'; then + # Extract plausible paths and check - allow ../ within project + if echo "$CMD_NORM" | grep -qE '(rm|mv|cp|cat|chmod|chown)\s+.*\.\.'; then + # Could be risky; be conservative for rm/mv/cp + if echo "$CMD_NORM" | grep -qE '\brm\b.*\.\.'; then + deny "Path traversal with rm blocked" + fi + fi + fi +fi + +exit 0 diff --git a/aiui/.claude/hooks/post-push-progress.sh b/aiui/.claude/hooks/post-push-progress.sh new file mode 100755 index 00000000..fa909356 --- /dev/null +++ b/aiui/.claude/hooks/post-push-progress.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# PostToolUse Bash hook: detect git push/commit and prompt Claude to update PROGRESS.md. +# Returns structured feedback with recent commits so Claude can write a session log entry. +# Uses python3 instead of jq for JSON (guaranteed on macOS). +set -euo pipefail + +INPUT=$(cat) + +# Extract command from JSON using python3 +CMD=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('tool_input', {}).get('command', '')) +except: pass +" <<< "$INPUT") + +# Only trigger on git push or git commit commands +if ! echo "$CMD" | grep -qE '\bgit\s+(push|commit)\b'; then + exit 0 +fi + +# Gather context for the progress update +BASE="${CLAUDE_PROJECT_DIR:-$(pwd)}" +BRANCH=$(git -C "$BASE" branch --show-current 2>/dev/null || echo "unknown") +PROGRESS_FILE="$BASE/PROGRESS.md" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M') + +# Get recent commits (branch vs main, or last 10) +if git -C "$BASE" rev-parse --verify main &>/dev/null; then + COMMITS=$(git -C "$BASE" log --oneline main..HEAD 2>/dev/null | head -15) + if [ -z "$COMMITS" ]; then + COMMITS=$(git -C "$BASE" log --oneline -10 2>/dev/null) + fi +else + COMMITS=$(git -C "$BASE" log --oneline -10 2>/dev/null) +fi + +# Get changed files in recent commits +CHANGED_FILES=$(git -C "$BASE" diff --name-only main..HEAD 2>/dev/null | head -20 || \ + git -C "$BASE" diff --name-only HEAD~5..HEAD 2>/dev/null | head -20 || \ + echo "unknown") + +# Build the feedback message and output as JSON using python3 +python3 -c " +import json, sys + +message = '''Progress Update Needed + +A git push/commit was detected on branch \`$BRANCH\` at $TIMESTAMP. + +Recent commits: +\`\`\` +$COMMITS +\`\`\` + +Changed files: +\`\`\` +$CHANGED_FILES +\`\`\` + +Please update PROGRESS.md: +1. Add a session log entry under '## Session Log' with format: ### $TIMESTAMP — $BRANCH +2. Summarize what was accomplished (2-4 bullet points based on the commits above) +3. Update any roadmap checkboxes if tasks were completed +4. Commit the PROGRESS.md update''' + +output = { + 'hookSpecificOutput': { + 'hookEventName': 'PostToolUse', + 'progressUpdate': message + } +} +print(json.dumps(output)) +" diff --git a/aiui/.claude/hooks/protect-files.sh b/aiui/.claude/hooks/protect-files.sh new file mode 100755 index 00000000..a0670545 --- /dev/null +++ b/aiui/.claude/hooks/protect-files.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# PreToolUse Edit|Write guard: block edits outside project and to protected paths. +# Denies: paths outside project, .git/, .env*, lockfiles, node_modules/ +# Uses python3 instead of jq for JSON (guaranteed on macOS). +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('tool_input', {}).get('file_path', '')) +except: pass +" <<< "$INPUT") +BASE="${CLAUDE_PROJECT_DIR:-}" +[[ -z "$BASE" ]] && BASE=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('cwd', '')) +except: pass +" <<< "$INPUT") +[[ -z "$BASE" ]] && BASE="$(pwd)" + +# Resolve to absolute path +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi +ABS_BASE=$(cd "$BASE" 2>/dev/null && pwd) || true +[[ -z "$ABS_BASE" ]] && ABS_BASE=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$BASE" 2>/dev/null) || true +[[ -z "$ABS_BASE" ]] && ABS_BASE="$BASE" +# Ensure base has trailing slash for prefix check +[[ "$ABS_BASE" != */ ]] && ABS_BASE="${ABS_BASE}/" +if [[ "$FILE_PATH" != /* ]]; then + ABS_PATH="$ABS_BASE${FILE_PATH#./}" +else + ABS_PATH="$FILE_PATH" +fi +# Normalize path (collapse .. and ., no symlink resolution needed) +ABS_PATH=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$ABS_PATH" 2>/dev/null) || true +[[ -z "$ABS_PATH" ]] && ABS_PATH="$ABS_BASE${FILE_PATH#./}" + +deny() { + local reason="$1" + echo "Blocked: $ABS_PATH — $reason" >&2 + python3 -c " +import json +print(json.dumps({ + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': '$reason' + } +})) +" + exit 0 +} + +# Protected patterns (path contains or equals) +PROTECTED_PATTERNS=( + ".git/" + ".env" + ".env.local" + "node_modules/" + "package-lock.json" + "pnpm-lock.yaml" +) + +for pattern in "${PROTECTED_PATTERNS[@]}"; do + if [[ "$ABS_PATH" == *"$pattern"* ]] || [[ "$ABS_PATH" == *"/$pattern" ]]; then + deny "Edit blocked: path matches protected pattern ($pattern)" + fi +done + +# .env.*.local +if [[ "$ABS_PATH" =~ \.env\..*\.local$ ]]; then + deny "Edit blocked: .env.*.local files contain secrets" +fi + +# Ensure path is under project root (ABS_BASE has trailing /) +if [[ "$ABS_PATH" != "$ABS_BASE"* ]] && [[ "$ABS_PATH" != "$BASE"* ]]; then + deny "Edit blocked: path is outside project directory" +fi + +exit 0 diff --git a/aiui/.claude/launch.json b/aiui/.claude/launch.json new file mode 100644 index 00000000..8d6343b5 --- /dev/null +++ b/aiui/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "app", + "runtimeExecutable": "bash", + "runtimeArgs": ["packages/app/scripts/dev.sh"], + "port": 5173, + "autoPort": true + } + ] +} diff --git a/aiui/.claude/memory/MEMORY.md b/aiui/.claude/memory/MEMORY.md new file mode 100644 index 00000000..cb1a743c --- /dev/null +++ b/aiui/.claude/memory/MEMORY.md @@ -0,0 +1,61 @@ +# AIUI Project Memory + +## Session Startup +1. Run `preview_start` with name `"app"` immediately — runs both Vite (:5173) + Claude proxy (:3141) via `packages/app/scripts/dev.sh` +2. Always commit work before ending a session +3. Work on `development` branch, merge to `main` only when production ready + +## User Preferences +- NO worktrees, NO temporary branches — just `development` and `main` +- Always use combined dev script (proxy + frontend), never bare `vite` +- Commit frequently to avoid losing work + +## Current State (2026-03-04) +- Branch: `overnight/2026-03-03`, all committed and pushed to remote (git.tx1138.com) +- Typecheck passes clean + +## What's Been Built +- Chat: AI streaming with stop generation, web search, article integration, paste & extract +- Content panel tabs: Films, Music, Magazine, News, Books, TV Series, Images, Places, Code, Design System, Nostr, **Apps** +- Detail views for each content type (side-by-side desktop, overlay mobile) +- **Apps tab**: curated DB of ~30 Nostr/Bitcoin apps, AppsGrid + AppDetail with search/category filtering/how-to +- Design system viewer (grid + detail) for tokens, colors, typography, components +- Nostr feed scaffold with note/article/zap filtering +- Content extraction: contentExtraction.ts + contentFiltering.ts (overhauled classifiers) +- **Bare domain extraction** from AI text (e.g. "check out damus.io") +- Banner fallback composable (primary → API → gradient) +- Image fallbacks: Wikipedia + Google Books sources +- Loading skeletons per content type variant +- Project grid with breadcrumb nav and inline creation +- Filesystem Vite plugin for local project browsing +- PWA with star icon, TMDB proxy, Jamendo for music +- **Slash command palette**: /code, /nostr, /design, /search show in palette with auto-send +- **Chat action buttons**: wrapped in glass container (backdrop blur, border, shadow) +- **Settings modal**: Memory + Advanced Settings via gear icon +- **Chat history**: dedicated clock icon button +- **Web search**: Brave API primary, SearXNG rotation fallback, DuckDuckGo fallback +- iOS HIG mobile UX rules in `.cursor/rules/15-mobile-ux.mdc` + +## Key Files +- Dev script: `packages/app/scripts/dev.sh` +- Launch config: `.claude/launch.json` (name: "app") +- Main page: `packages/app/src/pages/ChatPage.vue` +- Content panel: `packages/app/src/components/content/ContentPanel.vue` +- Content grids: `packages/app/src/components/content/*Grid.vue` +- Detail views: `packages/app/src/components/content/*Detail.vue` +- **Apps**: `packages/app/src/data/apps.ts` (curated DB), `AppsGrid.vue`, `AppDetail.vue` +- AI composable: `packages/app/src/composables/useAI.ts` +- Content extraction: `packages/app/src/composables/contentExtraction.ts` +- Content filtering: `packages/app/src/composables/contentFiltering.ts` +- Content panel logic: `packages/app/src/composables/useContentPanel.ts` +- Image fallbacks: `packages/app/src/composables/useImageFallback.ts` +- Banner fallback: `packages/app/src/composables/useBannerFallback.ts` +- Chat input: `packages/app/src/components/chat/ChatInput.vue` +- Prompt palette: `packages/app/src/components/chat/PromptPalette.vue` +- Chat message: `packages/app/src/components/chat/ChatMessage.vue` +- Settings modal: `packages/app/src/components/chat/SettingsModal.vue` +- Web search plugin: `packages/app/vite-web-search.ts` +- Prompt templates store: `packages/app/src/stores/promptTemplates.ts` + +## Recent Session Work (2026-03-04) +See `session-2026-03-04.md` for details. diff --git a/aiui/.claude/memory/code-mode-ui.md b/aiui/.claude/memory/code-mode-ui.md new file mode 100644 index 00000000..9f69526b --- /dev/null +++ b/aiui/.claude/memory/code-mode-ui.md @@ -0,0 +1,18 @@ +# Code Mode UI — Future Work + +## After content surfacing is complete, implement: + +### 1. Code Mode Visual Treatment +- Colour the message container in orange (`#F7931A`) styling when in code mode +- Change header text from "Message AIUI" to "Code" +- Visual signal so user knows they're in coding context + +### 2. Design System Context Selection +- All design system items should be selectable with a cursor/pointer icon on hover +- Selecting a design system item provides that UI context to the code generation +- Think of it as "code with this component/token in mind" + +### 3. File Browser / Open File Context +- File browser or open file in the content panel +- Selected files provide context for coding +- Pairs with the design system selection — user picks UI + files as coding context diff --git a/aiui/.claude/memory/session-2026-03-04.md b/aiui/.claude/memory/session-2026-03-04.md new file mode 100644 index 00000000..c16258df --- /dev/null +++ b/aiui/.claude/memory/session-2026-03-04.md @@ -0,0 +1,66 @@ +# Session 2026-03-04 + +## Completed This Session + +### 1. Chat UX Changes +- **History button**: Changed from title-click dropdown to dedicated clock icon in ChatHeader +- **Settings modal**: Created `SettingsModal.vue` — Memory + Advanced Settings behind gear icon, glass-card with backdrop blur +- **PromptIndex fix**: Reverted to original behavior (current conversation only), fixed broken v-if/v-else chain where StreamingDots broke the template chain +- **Chat action buttons**: Wrapped hover icons in proper glass container (`bg-black/60 backdrop-blur-md border border-white/10`) with divider between actions and thumbs + +### 2. iOS HIG Integration +- Created `.cursor/rules/15-mobile-ux.mdc` with comprehensive iOS HIG values +- Updated CLAUDE.md Mobile UX section + +### 3. Web Search Fix +- All SearXNG instances were returning 429, DuckDuckGo rate-limiting +- Added Brave Search API as primary backend (`BRAVE_SEARCH_API_KEY` env var) +- Expanded SearXNG pool to 8 instances with rotation +- Added HTML response guard for captcha pages + +### 4. Content Detection Overhaul (MAJOR) +- **Expanded all classifiers** in `contentFiltering.ts`: isNewsQuery, isMusicQuery, isBookQuery, isTVQuery, isPlaceQuery, isWebsitesQuery + response variants +- **Added Nostr detection**: `isNostrQuery()`, `isNostrLikeResponse()` +- **Added App detection**: `isAppQuery()`, `isAppLikeResponse()` +- **Updated `filterTabsByContext()`**: new `hasNostr` + `hasApps` params, nostr/app query priority +- **Updated `preferredFirstTab()`**: nostr + app checks + +### 5. Bare Domain Extraction +- `extractBareDomainLinks(text)` in contentExtraction.ts +- Detects plain domains like "damus.io" not inside markdown/bold/URL patterns +- Known TLDs whitelist, file extension blacklist + +### 6. Apps Tab (NEW FEATURE) +- **Database**: `packages/app/src/data/apps.ts` — AppEntry interface, ~30 curated apps + - Nostr clients: Damus, Primal, Snort, Amethyst, Coracle, Iris, noStrudel + - Lightning wallets: Phoenix, Breez, Zeus, Alby, Mutiny, WoS + - Bitcoin wallets: Sparrow, BlueWallet, Nunchuk, Coldcard + - Privacy: SimpleX Chat, Signal, Mullvad VPN + - Node software: Start9, Umbrel, RaspiBlitz, myNode + - Dev tools: NDK, nostr-tools, Nak +- **Extraction**: `extractApps(text, userQuery)` — keyword matching against DB, surfaces with 1+ match for app/nostr/known-app queries, 2+ for general +- **UI**: `AppsGrid.vue` (list with search/category filter), `AppDetail.vue` (gradient header, how-to steps, related apps, external link) +- **Wired in**: useContentPanel.ts (panelApps ref, selectedApp, open/close), ContentPanel.vue (registered), PromptIndex badges + +### 7. Slash Command Palette +- `/code`, `/nostr`, `/design`, `/search` appear as commands in PromptPalette +- Commands section above Templates section with `/slash` prefix styling +- Auto-send on select (except `/search` which sets text for query input) +- 8px side margins (`left-2 right-2`), no max-height scroll limit +- `ChatInput.vue`: simplified `isPaletteMode` — no longer excludes command names + +### 8. App Detection Fix +- Queries mentioning known app names (e.g. "start9") now match via `queryMatchesApp` check +- Previously required explicit app/nostr query patterns like "what app" or "best wallet" + +## Known Issues / TODO for Next Session +- User reported "start9" search shows Brief but Apps tab was empty — FIXED in last commit +- The `/design` command was added to palette and ChatWindow handleSend +- Consider adding more apps to the curated database over time +- The plan file is at `.claude/plans/content-detection-overhaul.md` (all steps complete) + +## Git State +- Branch: `overnight/2026-03-03` +- Latest commit: `f346992` — feat(chat): slash command palette, action button containers, app detection fix +- Previous commit: `84ccdc7` — feat(app): content detection overhaul, apps tab, chat UX, web search +- All pushed to origin diff --git a/aiui/.claude/plans/content-detection-overhaul.md b/aiui/.claude/plans/content-detection-overhaul.md new file mode 100644 index 00000000..61927689 --- /dev/null +++ b/aiui/.claude/plans/content-detection-overhaul.md @@ -0,0 +1,160 @@ +# Plan: Overhaul Content Detection + Add Apps Tab + +## Context + +The content surfacing system misses many common AI response patterns. Example: AI responds about Nostr (mentioning damus.io, primal.net, snort.social) but the Nostr tab never surfaces. Query/response classifiers use narrow regexes that miss natural language variations. There's no "topic detection" layer, no app detection, and bare domains in AI text aren't extracted as websites. + +**Goals:** +1. Fix content detection to handle how AIs actually respond +2. Add Nostr tab surfacing (currently only via `/nostr` command) +3. Add Apps tab with curated Nostr + Bitcoin ecosystem apps (local DB + AI extraction fallback) +4. Extract bare domains from AI text (e.g. "check out damus.io") + +--- + +## Part 1: Expand Query & Response Classifiers + +**File:** `packages/app/src/composables/contentFiltering.ts` + +### 1A. Add Nostr classifiers (new functions) + +- `isNostrQuery(q)` — matches: nostr, npub, nip-\d, damus, primal, snort, amethyst, coracle, zap, relay, note1, nevent, nprofile, fiatjaf, nostrich, "decentralized social" +- `isNostrLikeResponse(text)` — requires literal "nostr" OR 2+ Nostr-specific signals (npub, nip-, client names, relay+wss, zap+lightning) + +### 1B. Add App classifiers (new functions) + +- `isAppQuery(q)` — matches: app, client, wallet, tool, software, download, install, "what app", "best app for", "recommend.*app" +- `isAppLikeResponse(text)` — matches: "you can use", "popular clients include", "I'd recommend", "available on", "download from" + +### 1C. Expand existing classifiers with broader patterns + +| Classifier | Add these patterns | +|---|---| +| `isNewsQuery` | "what happened today", "any updates on", "trending", "catch me up", "brief me", "current events" | +| `isMusicQuery` | "genre", "spotify", "bandcamp", "grammys", "billboard", "mixtape", "discography", "banger", "favorite jam" | +| `isBookQuery` | "what should I read", "favorite reads", "reading list", "book club", "memoir", "audiobook", "goodreads", "worth reading" | +| `isTVQuery` | "what's good on netflix", "anything to binge", "hbo", "disney+", "apple tv", "amazon prime", "docuseries", "limited series" | +| `isPlaceQuery` | "hungry", "food near me", "best brunch spot", "happy hour", "speakeasy", "rooftop bar", "food truck" | +| `isWebsitesQuery` | "point me to", "link me", "any good sites", "tools for", "platforms for" | +| `isWebsitesLikeResponse` | "here are some resources", "I'd recommend checking", "you can visit", "useful resources" | +| `isNewsLikeResponse` | "I can't access the web but", "having trouble reaching", "unable to browse but" | + +### 1D. Update `preferredFirstTab()` — add nostr + app checks + +### 1E. Update `filterTabsByContext()` — add `hasNostr` and `hasApps` params, integrate into tab ordering + +--- + +## Part 2: Bare Domain Extraction + +**File:** `packages/app/src/composables/contentExtraction.ts` + +Add `extractBareDomainLinks(text)`: +- Detect plain-text domains like "damus.io", "primal.net" not inside markdown links or bold patterns +- Skip positions covered by existing extractors (markdown links, bold-domain, full URLs) +- Require known TLDs (.com, .org, .io, .net, .social, .app, etc.) +- Block file extensions (.js, .ts, .vue, .json, .css) +- Use existing `normUrl()` for dedup + +--- + +## Part 3: Apps Tab — Curated Database + AI Extraction + +### 3A. Create app database + +**New file:** `packages/app/src/data/apps.ts` + +```ts +interface AppEntry { + id: string + name: string + description: string // One-liner + longDescription: string // Why use this, how it works + category: 'nostr-client' | 'lightning-wallet' | 'bitcoin-wallet' | 'privacy' | 'node' | 'dev-tool' | 'relay' + platforms: ('ios' | 'android' | 'web' | 'desktop' | 'cli' | 'nodeos')[] + url: string + icon?: string + keywords: string[] // For matching AI responses + howTo?: string[] // Getting started steps + relatedApps?: string[] // IDs of related apps +} +``` + +**Initial curated apps (~25-30):** +- Nostr clients: Damus, Primal, Snort, Amethyst, Coracle, Iris, Nostrudel, nos.social +- Lightning wallets: Phoenix, Mutiny, Breez, Zeus, Alby, Wallet of Satoshi +- Bitcoin wallets: Sparrow, Blue Wallet, Nunchuk, Coldcard, Green +- Privacy tools: Tor, SimpleX Chat, Signal, Mullvad VPN +- Node software: Start9, Umbrel, RaspiBlitz, myNode +- Dev tools: NDK, nostr-tools, Nak + +### 3B. Add app extraction + +**File:** `packages/app/src/composables/contentExtraction.ts` + +Add `extractApps(text, userQuery)`: +1. Match AI text against known app names/keywords from database +2. If app query detected OR 2+ known apps mentioned → return matched apps +3. For unknown apps, create basic entries from context (name + URL if bare domain found) + +### 3C. Create UI components + +**New files:** +- `packages/app/src/components/content/AppsGrid.vue` — Grid of app cards (icon, name, category badge, one-liner) +- `packages/app/src/components/content/AppDetail.vue` — Detail: icon, name, platforms, long description, how-to steps, link, related apps + +Follow existing grid/detail patterns (e.g. `BookGrid.vue`/`BookDetail.vue`). + +### 3D. Register in ContentPanel.vue + +Add rendering for `activeTab === 'app'`, add `'app'` to `ContentTab` type. + +--- + +## Part 4: Wire Everything Together + +**File:** `packages/app/src/composables/useContentPanel.ts` + +In `updatePanelFromText()`: +- Call `extractBareDomainLinks(text)`, merge with website sources +- Call `extractApps(text, userQuery)` +- Compute `hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)` +- Compute `hasApps = apps.length > 0` +- Pass `hasNostr` and `hasApps` to `filterTabsByContext()` +- Add `panelApps` ref, title logic for apps/nostr tabs + +Same changes in `getContextualInlineContent()`. + +Broaden magazine detection: add tech/protocol keywords, surface magazine for 3+ sections with no other structured content. + +--- + +## Part 5: PromptIndex badges + +**File:** `packages/app/src/components/chat/PromptIndex.vue` + +Add 'Nostr' and 'Apps' badge detection. + +--- + +## Implementation Order + +1. `contentFiltering.ts` — classifiers + filterTabsByContext signature +2. `contentExtraction.ts` — `extractBareDomainLinks()` + `extractApps()` +3. `data/apps.ts` — curated app database +4. `useContentPanel.ts` — wire everything +5. `AppsGrid.vue` + `AppDetail.vue` — UI components +6. `ContentPanel.vue` — register tab + components +7. `PromptIndex.vue` — badges +8. Typecheck + manual test + +## Verification + +1. `pnpm typecheck` passes +2. "tell me about Nostr" → Nostr + magazine tabs surface +3. "best Nostr clients?" → Apps tab with Damus, Primal, Snort +4. "recommend a bitcoin wallet" → Apps tab with Phoenix, Sparrow +5. "what happened with BIP 110?" → Magazine tab (regression) +6. "best movies of 2024" → Films tab (regression) +7. Bare domains in AI text extracted as websites +8. PromptIndex badges show Nostr/Apps diff --git a/aiui/.claude/plans/fluffy-bouncing-whisper.md b/aiui/.claude/plans/fluffy-bouncing-whisper.md new file mode 100644 index 00000000..200d13ca --- /dev/null +++ b/aiui/.claude/plans/fluffy-bouncing-whisper.md @@ -0,0 +1,74 @@ +# Plan: Code Mode UI — Orange Input, Design System Context, File Browser Context + +## Context +The user wants three connected features that enhance the coding experience in AIUI: +1. Visual indication when in code mode (orange input container, "Code" label) +2. Ability to select design system items as coding context +3. Ability to select files from file browser as coding context + +After this, the user wants to circle back and create a flawless version of content extraction/tab surfacing. + +## Changes + +### 1. Orange Code Mode Input Container +**Files**: `ChatWindow.vue`, `ChatInput.vue` + +**ChatWindow.vue** (line 106-115): +- Pass `activeTab` to ChatInput as a prop: `:active-tab="activeTab"` +- Change placeholder logic: `activeTab === 'code' ? 'Code...' : isStreaming ? 'Waiting for response...' : 'Message AIUI...'` + +**ChatInput.vue**: +- Add `activeTab` prop (optional string, default `''`) +- Conditionally style the container div (line 79-81): + - When `activeTab === 'code'`: use `bg-accent/15 border border-accent/25 backdrop-blur-xl` instead of `path-glass-bubble` + - Keep the `rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300` classes +- Conditionally style the send button orange when in code mode + +### 2. Design System Item Selection for Coding Context +**Files**: `useCodeContext.ts`, `DesignSystemGrid.vue` + +**useCodeContext.ts**: +- Add `selectedDesignTokens: ref([])` to module state (stores item IDs) +- Add `toggleDesignToken(id)` — adds/removes from selection array +- Add `clearDesignTokens()` — clears selection +- Add `isDesignTokenSelected(id)` — checks if item is in selection +- Clear on `exitCodeMode()` +- Export all new state/actions + +**DesignSystemGrid.vue**: +- Import `useCodeContext` +- When `codeMode` is true, show a selection indicator (accent ring + checkmark) on items +- `selectItem` should call `toggleDesignToken(item.id)` when in code mode (instead of `openDesignSystemItem`) +- When NOT in code mode, keep existing behavior (open detail view) +- Selected items get `ring-2 ring-accent/50 bg-accent/10` styling + +### 3. File Browser Selection for Coding Context +**Files**: `useCodeContext.ts`, `ProjectGrid.vue` + +**useCodeContext.ts**: +- Add `selectedFiles: ref([])` — paths of files selected for context +- Add `toggleFileSelection(path)` — adds/removes from selection +- Add `clearFileSelection()` — clears all +- Add `isFileSelected(path)` — checks if file in selection +- Clear on `exitCodeMode()` +- Export new state/actions + +**ProjectGrid.vue**: +- Import `useCodeContext` +- When `codeMode` is true, file clicks toggle selection instead of (or in addition to) opening +- Show visual selection state (accent highlight/checkmark) on selected files in FileTreeNode + +## Files to Modify +1. `packages/app/src/components/chat/ChatWindow.vue` — pass activeTab prop +2. `packages/app/src/components/chat/ChatInput.vue` — conditional orange styling + "Code" placeholder +3. `packages/app/src/composables/useCodeContext.ts` — add design token + file selection state +4. `packages/app/src/components/content/DesignSystemGrid.vue` — toggle selection in code mode +5. `packages/app/src/components/content/ProjectGrid.vue` — toggle file selection in code mode + +## Verification +1. `pnpm typecheck` — no type errors +2. `pnpm lint` — no new lint errors +3. Manual: `/code` command → input turns orange with "Code..." placeholder +4. Manual: In code mode, design system tab → clicking items toggles selection (accent ring) +5. Manual: In code mode, file browser → clicking files toggles selection +6. Manual: Exiting code mode clears all selections diff --git a/aiui/.claude/settings.json b/aiui/.claude/settings.json new file mode 100644 index 00000000..0454473d --- /dev/null +++ b/aiui/.claude/settings.json @@ -0,0 +1,35 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-bash.sh" + } + ] + }, + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-push-progress.sh" + } + ] + } + ] + } +} diff --git a/aiui/.claude/skills/add-content-type/SKILL.md b/aiui/.claude/skills/add-content-type/SKILL.md new file mode 100644 index 00000000..4963e586 --- /dev/null +++ b/aiui/.claude/skills/add-content-type/SKILL.md @@ -0,0 +1,43 @@ +--- +name: add-content-type +description: Scaffold a complete new content type (tag, extraction, grid, detail, prompt) +allowed-tools: Bash(*), Read, Edit, Write, Glob, Grep, Agent +--- + +Add a complete new content type to AIUI. The user will provide the content type name (e.g., "event", "product", "video"). + +Follow ALL steps — this is the full pipeline for a content type: + +1. **Tag format**: Add a new `[[{type}_ext:Field1|Field2|...]]` regex to `packages/app/src/composables/contentExtraction.ts` alongside the existing ones (FILM_EXT_RE, SONG_EXT_RE, etc.) + +2. **Type definition**: Add the TypeScript interface to `packages/core/src/types/content.ts` if it doesn't exist + +3. **Extraction function**: Add `extractAll{Type}s(text, userQuery)` to `contentExtraction.ts` following the pattern of `extractAllFilms` or `extractAllBooks` + +4. **Strip tags function**: Add `strip{Type}Tags()` and include it in `stripContentTags()` + +5. **Query classifier**: Add `is{Type}Query()` and optionally `is{Type}LikeResponse()` to `contentFiltering.ts` + +6. **ContentTab type**: Add the new tab name to the `ContentTab` union in `contentFiltering.ts` + +7. **Tab filtering**: Update `filterTabsByContext()` and `preferredFirstTab()` in `contentFiltering.ts` + +8. **Grid component**: Create `packages/app/src/components/content/{Type}Grid.vue` following the glass-morphism pattern of existing grids (BookGrid.vue is a good template) + +9. **Detail component**: Create `packages/app/src/components/content/{Type}Detail.vue` following the pattern of BookDetail.vue + +10. **Wire into ContentPanel.vue**: Add import, grid render block, detail render block, and panel state refs + +11. **Wire into ContentGridView.vue**: Add import, props, and grid render block + +12. **Wire into ChatPage.vue**: Pass the new panel data as props to ContentGridView + +13. **Wire into useContentPanel.ts**: Add panel ref, selected ref, open/close functions, extraction call in `updatePanelFromText()` + +14. **System prompt**: Add tag format instructions to the `SYSTEM_PROMPT` in `useAI.ts` + +15. **Tab label**: Add to `TAB_LABELS` in `ContentPanel.vue` + +16. **Verify**: Run `pnpm typecheck` and fix any errors + +Report what was created and the tag format to use. diff --git a/aiui/.claude/skills/add-tool/SKILL.md b/aiui/.claude/skills/add-tool/SKILL.md new file mode 100644 index 00000000..7e97db99 --- /dev/null +++ b/aiui/.claude/skills/add-tool/SKILL.md @@ -0,0 +1,32 @@ +--- +name: add-tool +description: Add a new AI tool (function call) to the Claude proxy for the AI to use +allowed-tools: Bash(*), Read, Edit, Write, Glob, Grep +--- + +Add a new tool that the AI model can call via Claude's tool_use API. The user will describe what the tool should do (e.g., "search local files", "get app status", "browse media"). + +## Steps + +1. **Read the proxy**: Read `packages/app/server/claude-proxy.ts` to understand the existing tool_use loop and `SEARCH_WEB_TOOL` definition. + +2. **Define the tool**: Add a new tool definition following the Claude tool_use format: + ```ts + const NEW_TOOL = { + name: 'tool_name', + description: 'What this tool does...', + input_schema: { + type: 'object', + properties: { ... }, + required: [...] + } + } + ``` + +3. **Add handler**: In the tool_use loop (where `search_web` calls are handled), add a handler for the new tool name. + +4. **Implement backend**: If the tool needs a new API endpoint (e.g., `/api/media/scan`), create a Vite plugin or add a route to the proxy. + +5. **Update system prompt**: Add instructions in `useAI.ts` SYSTEM_PROMPT telling the AI when and how to use the new tool. + +6. **Verify**: Run `pnpm typecheck` and test the proxy starts without errors. diff --git a/aiui/.claude/skills/audit-prompts/SKILL.md b/aiui/.claude/skills/audit-prompts/SKILL.md new file mode 100644 index 00000000..d33b93b8 --- /dev/null +++ b/aiui/.claude/skills/audit-prompts/SKILL.md @@ -0,0 +1,37 @@ +--- +name: audit-prompts +description: Deep audit of AI system prompts — find gaps, test extraction coverage, verify tag formats +allowed-tools: Bash(*), Read, Glob, Grep, Agent +--- + +Perform a comprehensive audit of AIUI's AI prompt system. Do NOT make changes — report findings only. + +## Steps + +1. **Read the full system prompt**: Read `packages/app/src/composables/useAI.ts` and reconstruct the complete system prompt including all dynamic sections (persona, Wavlake, memory, web search, Archy context, code context). + +2. **Catalog all tag formats**: List every `[[type:...]]` and `[[type_ext:...]]` format defined in the prompt. Cross-reference with regexes in `contentExtraction.ts`. + +3. **Check for gaps**: For each content type in `ContentTab` (contentFiltering.ts), verify: + - Is there a tag format in the system prompt? + - Is there a matching extraction regex? + - Is there a query classifier? + - Is there a grid + detail component? + - Is the tab wired in ContentPanel.vue and ContentGridView.vue? + +4. **Test extraction coverage**: Read the seed prompts in `src/__tests__/fixtures/seedPrompts.ts`. For each seed, verify: + - Does the extraction function find the expected number of items? + - Are there edge cases that would break extraction? + +5. **Analyze prompt quality**: Check for: + - Conflicting instructions + - Missing edge case handling (e.g., "what if the AI can't find a match?") + - Overly vague instructions + - Missing content types that should have tag formats + +6. **Check proxy tools**: Read `server/claude-proxy.ts` and verify tool definitions match what the prompt claims. + +7. **Report**: Create a structured summary with: + - Content type coverage matrix (tag/extraction/grid/detail/prompt) + - Identified gaps and inconsistencies + - Priority recommendations diff --git a/aiui/.claude/skills/check/SKILL.md b/aiui/.claude/skills/check/SKILL.md new file mode 100644 index 00000000..2df6a024 --- /dev/null +++ b/aiui/.claude/skills/check/SKILL.md @@ -0,0 +1,17 @@ +--- +name: check +description: Run all quality checks (typecheck, lint, test) and auto-fix errors +allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent +--- + +Run all quality checks for the AIUI project and fix any issues found. Execute in order: + +1. **TypeScript**: Run `pnpm typecheck`. If errors found, read the failing files and fix the type errors. +2. **Lint**: Run `pnpm lint`. If fixable errors, run `pnpm lint --fix` first, then fix remaining manually. +3. **Tests**: Run `pnpm --filter @aiui/app test -- --run`. For each failure: + - Read the test file and the source file it tests + - Determine if the test is wrong (outdated assertion) or the source has a bug + - Fix whichever is incorrect +4. Report a summary: pass/fail counts, what was fixed. + +Important: Do NOT change test expectations just to make them pass — understand WHY they fail first. diff --git a/aiui/.claude/skills/deploy/SKILL.md b/aiui/.claude/skills/deploy/SKILL.md new file mode 100644 index 00000000..39e61cb7 --- /dev/null +++ b/aiui/.claude/skills/deploy/SKILL.md @@ -0,0 +1,32 @@ +--- +name: deploy +description: Build and prepare AIUI for deployment to Archy node +allowed-tools: Bash(*), Read, Edit, Glob, Grep +--- + +Build AIUI for production deployment. Steps: + +1. **Pre-flight checks**: + - `pnpm typecheck` — must pass + - `pnpm lint` — must pass + - `pnpm --filter @aiui/app test -- --run` — report failures but continue + +2. **Build**: + - `pnpm build` + - Verify `packages/app/dist/` exists and contains `index.html` + +3. **Bundle analysis**: + - Report total dist size and gzip estimate + - List the 5 largest chunks + - Check against 250KB gzipped budget (warn if over) + +4. **Verify nginx config**: + - Read `packages/app/server/nginx-archy.conf` + - Verify SPA routing (`try_files $uri $uri/ /aiui/index.html`) + - Verify proxy paths for Claude API + +5. **Container build** (if Dockerfile exists): + - `podman build -t aiui:latest packages/app/` + - Report image size + +6. **Report**: Build status, bundle size, any warnings. diff --git a/aiui/.claude/skills/fix-tab/SKILL.md b/aiui/.claude/skills/fix-tab/SKILL.md new file mode 100644 index 00000000..c1e4e5cd --- /dev/null +++ b/aiui/.claude/skills/fix-tab/SKILL.md @@ -0,0 +1,33 @@ +--- +name: fix-tab +description: Diagnose and fix a broken content panel tab (extraction, routing, rendering) +allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent +--- + +Diagnose and fix a broken content panel tab. The user will specify which tab (e.g., "app", "code", "image", "nostr"). + +## Diagnostic pipeline — check each layer: + +1. **System prompt**: Does `useAI.ts` SYSTEM_PROMPT tell the AI to use the tag format for this content type? If not, add instructions. + +2. **Tag regex**: Does `contentExtraction.ts` have a regex for this content type's tags? If not, add one. + +3. **Extraction function**: Does `extractAll{Type}s()` or `extract{Type}s()` exist and work correctly? Test with sample input. + +4. **Query classifier**: Do `is{Type}Query()` and/or `is{Type}LikeResponse()` exist in `contentFiltering.ts`? + +5. **Tab filtering**: Is the tab included in `filterTabsByContext()` and `preferredFirstTab()`? Check the boolean flag is wired. + +6. **useContentPanel.ts**: Is the extraction called in `updatePanelFromText()`? Is the panel ref populated? Is the tab added to `availableTabs`? + +7. **ContentPanel.vue**: Is there a grid render block for `activeTab === '{tab}'`? Are imports present? Is the tab in TAB_LABELS? + +8. **ContentGridView.vue**: Same checks for the wide desktop view. + +9. **ChatPage.vue**: Are the panel data props passed to ContentGridView? + +10. **Grid component**: Does the grid component exist and render correctly? + +11. **Detail component**: Does the detail component exist? + +Fix each broken layer. Run `pnpm typecheck` after all fixes. diff --git a/aiui/.claude/skills/mock-archy/SKILL.md b/aiui/.claude/skills/mock-archy/SKILL.md new file mode 100644 index 00000000..878ac1ad --- /dev/null +++ b/aiui/.claude/skills/mock-archy/SKILL.md @@ -0,0 +1,32 @@ +--- +name: mock-archy +description: Enable/configure mock Archy data for standalone dev testing +allowed-tools: Bash(*), Read, Edit, Glob, Grep +--- + +Set up or modify mock Archy data for testing the Archipelago integration without a real Archy host. + +## How it works + +Mock data is in `packages/app/src/mocks/archy.ts`. When enabled, `useArchy.ts` loads this data instead of waiting for the Archy bridge. + +## Enable mock mode + +Two ways: +1. Add `VITE_MOCK_ARCHY=true` to `.env.local` +2. Add `?mockArchy` to the URL: `http://localhost:5173/?mockArchy` + +## Customization + +The user may ask to: +- Add/remove mock apps from the installed list +- Change wallet balance or channel count +- Add/modify files in the mock file list +- Change system info or network status +- Test specific scenarios (e.g., "node is syncing", "wallet offline", "no files") + +Edit `packages/app/src/mocks/archy.ts` accordingly. + +## Verify + +After changes, check that `buildArchyContext()` in `useArchy.ts` produces the expected system prompt section by reading the function and tracing the mock data through it. diff --git a/aiui/.claude/skills/new-detail/SKILL.md b/aiui/.claude/skills/new-detail/SKILL.md new file mode 100644 index 00000000..9209e265 --- /dev/null +++ b/aiui/.claude/skills/new-detail/SKILL.md @@ -0,0 +1,27 @@ +--- +name: new-detail +description: Generate a detail view component following AIUI glass-morphism patterns +allowed-tools: Read, Write, Edit, Glob, Grep +--- + +Create a new detail view component at `packages/app/src/components/content/{Name}Detail.vue`. + +## Requirements + +1. **Read a reference**: Read `BookDetail.vue` or `PlaceDetail.vue` as a template. + +2. **Follow conventions**: + - ` + + ``` + +2. Wrap in ChatPage.vue: + - Wrap chat column (around ``) + - Wrap content panel column (around ``) + - Wrap detail view column (around ``) + +3. Wrap in ChatWindow.vue: + - Wrap the message loop (v-for of ChatMessage) + +4. Wrap in ContentPanel.vue: + - Wrap each grid component render + - Wrap detail component render + +**Acceptance criteria**: +- A failing renderer shows error card with retry button +- Chat continues working if content panel errors +- Content panel continues if one card errors +- `pnpm typecheck` passes + +#### Task M1.3: Unit Tests for contentExtraction + +**Why**: `contentExtraction.ts` is 967 lines of regex parsing with zero tests. It's the most critical composable. + +**File to create**: `packages/app/src/__tests__/contentExtraction.test.ts` + +**Tests to write** (use vitest): +```ts +describe('contentExtraction', () => { + describe('extractAllFilms', () => { + it('extracts film_ext tags with title, year, director') + it('extracts film:id tags and looks up from library') + it('returns empty array for text with no film tags') + it('handles multiple films in one message') + it('handles malformed tags gracefully') + }) + + describe('extractAllSongs', () => { + it('extracts song_ext tags with title, artist, year') + it('extracts song:id tags from library') + it('extracts songs from markdown bold patterns') + it('deduplicates songs by title+artist') + }) + + describe('extractAllPodcasts', () => { + it('extracts podcast_ext tags') + it('extracts podcast:id from library') + }) + + describe('extractAllBooks', () => { + it('extracts book_ext tags with title, author, year') + it('handles optional fields') + }) + + describe('extractAllTVSeries', () => { + it('extracts tv_ext tags') + it('parses creator and network fields') + }) + + describe('extractAllPlaces', () => { + it('extracts place_ext tags with all fields') + it('handles missing optional fields (rating, price)') + }) + + describe('extractMagazineSections', () => { + it('extracts sections from markdown headings') + it('captures content between headings') + it('extracts hero images') + }) + + describe('stripContentTags', () => { + it('removes all tag types from text') + it('preserves non-tag content') + it('handles nested/adjacent tags') + }) + + describe('extractBoldDomainLinks', () => { + it('extracts **domain.com** patterns with URLs') + it('extracts markdown links') + }) +}) +``` + +**How to run**: `pnpm test` (vitest via turbo) + +**Acceptance criteria**: +- All tests pass +- Covers the 10 main extraction functions +- Tests edge cases (empty input, malformed tags, duplicates) +- `pnpm test` exits 0 + +#### Task M1.4: Unit Tests for useAI + +**File to create**: `packages/app/src/__tests__/useAI.test.ts` + +**Tests to write**: +```ts +describe('useAI', () => { + describe('provider selection', () => { + it('defaults to first available provider') + it('switches provider via setActiveProvider') + it('lists available models for active provider') + }) + + describe('context injection', () => { + it('includes film library in system prompt') + it('includes song library in system prompt') + it('includes content tag format instructions') + }) + + describe('sendMessage', () => { + it('adds user message to store') + it('creates assistant message placeholder') + it('sets isStreaming to true during stream') + it('sets isStreaming to false after completion') + it('handles stream errors gracefully') + }) + + describe('stopGeneration', () => { + it('aborts active stream') + it('sets isStreaming to false') + }) +}) +``` + +**Note**: Will need to mock `fetch` for streaming tests. Use vitest's `vi.fn()`. + +**Acceptance criteria**: +- All tests pass with mocked fetch/SSE +- `pnpm test` exits 0 + +#### Task M1.5: E2E Test Expansion + +**File to modify**: `packages/app/e2e/content-surfaces.spec.ts` + +**Tests to add**: +```ts +test('sends a message and receives streaming response') +test('content panel shows film cards when AI mentions films') +test('clicking a film card opens detail view') +test('mobile viewport shows full-screen overlay for content') +test('stop button halts generation') +test('web search toggle works') +test('new conversation clears messages') +test('panel side toggle switches layout') +``` + +**Acceptance criteria**: +- `pnpm test:e2e` passes (needs dev server running) + +--- + +### M2: Content Experience (UX) + +#### Task M2.1: Markdown Rendering in Chat + +**Why**: Chat messages display plain text. Markdown (bold, italic, links, code blocks, lists) should render properly. + +**Files to modify**: +- `packages/app/src/components/chat/ChatMessage.vue` (333 lines) +- Add `markdown-it` as dependency + +**Implementation**: +1. `pnpm add markdown-it` + `pnpm add -D @types/markdown-it` in `packages/app` +2. In ChatMessage.vue: + - Import and configure markdown-it with safe defaults (no HTML) + - After `stripContentTags()`, render remaining text through markdown-it + - Use `v-html` with the sanitized markdown output + - Add CSS for rendered markdown (code blocks, lists, links) in main.css + - Ensure content tags are extracted BEFORE markdown rendering + +**Security**: markdown-it with `html: false` prevents XSS. No raw HTML passthrough. + +**Acceptance criteria**: +- Bold, italic, links, code blocks, lists render in chat +- Content tags still extract correctly (films, songs, etc.) +- No XSS possible +- `pnpm typecheck` passes + +#### Task M2.2: Virtual Scrolling for Chat + +**Why**: Long conversations with many messages cause scroll jank. + +**Files to modify**: +- `packages/app/src/components/chat/ChatWindow.vue` +- Add `@tanstack/vue-virtual` dependency + +**Implementation**: +1. `pnpm add @tanstack/vue-virtual` in `packages/app` +2. Replace the message `v-for` loop with `useVirtualizer`: + - Estimate row heights (user messages ~60px, assistant ~200px) + - Use dynamic measurement for actual heights + - Maintain scroll-to-bottom behavior during streaming + - Keep overscan at 5 items + +**Acceptance criteria**: +- Scrolling is smooth with 100+ messages +- Auto-scroll to bottom during streaming still works +- `pnpm typecheck` passes + +#### Task M2.3: Music Source Resolution + +**Why**: PlayerBar exists but music source resolution is incomplete. Iframe embedding untested. + +**Files to modify**: +- `packages/app/src/composables/usePlayer.ts` (185 lines) +- `packages/app/src/components/player/PlayerBar.vue` (165 lines) + +**Implementation**: +1. In usePlayer.ts: + - Add queue management: `queue: ShallowRef`, `currentIndex: Ref` + - Add `playNext()`, `playPrevious()`, `addToQueue(song)` methods + - Fix iframe playback (lines 72-83): create Plyr instance for iframes too + - Add retry logic for failed music searches (try next source) + +2. In PlayerBar.vue: + - Add next/previous buttons + - Show queue count + - Add queue panel (slide-up from player) + +**Acceptance criteria**: +- Can play songs from search results +- Next/previous navigation works +- Queue persists across song changes +- `pnpm typecheck` passes + +#### Task M2.4: Nostr Feed Integration + +**Why**: NostrGrid.vue exists but is non-functional. No relay connection. + +**Files to modify**: +- `packages/app/src/components/content/NostrGrid.vue` +- Create `packages/app/src/composables/useNostr.ts` + +**Implementation**: +1. Create `useNostr.ts`: + - Connect to public relays (wss://relay.damus.io, wss://nos.lol, wss://relay.snort.social) + - Use raw WebSocket (no nostr-tools dependency to keep bundle small) + - Subscribe to kind:1 (text notes) with limit 50 + - Parse NIP-01 event format manually + - Export `useNostr()` returning `{ events, isConnected, connect, disconnect }` + +2. Update NostrGrid.vue: + - Use `useNostr()` composable + - Display events as cards with author npub (truncated), content, timestamp + - Lazy-load on tab activation only + +**Acceptance criteria**: +- Nostr tab shows real posts from public relays +- Connection/disconnection is clean (no leaked WebSockets) +- Handles relay errors gracefully +- `pnpm typecheck` passes + +--- + +### M3: Plugin System (Infrastructure) + +#### Task M3.1: Activate Plugin Registry at Runtime + +**Why**: `packages/core/src/plugins/registry.ts` exists with `registerPlugin()` but nothing calls it. + +**Files to modify**: +- `packages/app/src/main.ts` (26 lines) — add plugin initialization +- Create `packages/app/src/plugins/index.ts` — plugin bootstrap +- Create `packages/app/src/plugins/claude-provider.ts` — first AI provider plugin + +**Implementation**: +1. Create `plugins/index.ts`: + ```ts + export async function initializePlugins() { + // Register built-in plugins + const { claudeProvider } = await import('./claude-provider') + registerPlugin(claudeProvider) + } + ``` + +2. Create `plugins/claude-provider.ts`: + - Implement `AIProviderAdapter` interface from `@aiui/core` + - Wrap existing `useAI.ts` streaming logic as a plugin + - Export as a Tier 1 (trusted) plugin + +3. In `main.ts`: + - Call `initializePlugins()` before app mount + - Make it async with error handling + +**Acceptance criteria**: +- Plugin registry has at least 1 registered plugin at runtime +- Chat still works through the plugin adapter +- `getPluginsByType('ai-provider')` returns the Claude provider +- `pnpm typecheck` passes + +#### Task M3.2: Renderer Plugin Registration + +**Why**: Content renderers are hardcoded. Making them pluggable enables community extensions. + +**Files to modify**: +- Create `packages/app/src/plugins/renderers/film-renderer.ts` +- Create `packages/app/src/plugins/renderers/song-renderer.ts` +- Modify `packages/app/src/plugins/index.ts` — register renderers +- Modify `packages/app/src/components/content/ContentPanel.vue` — use registry lookups + +**Implementation**: +1. Create renderer plugins for film and song (as examples): + ```ts + const filmRenderer: RendererDefinition = { + id: 'film', + name: 'Film Renderer', + contentType: 'film', + surfaces: ['chat-preview', 'panel-preview', 'panel-play'], + chatPreview: FilmCard, + panelPreview: FilmGrid, + panelPlay: FilmDetail, + } + ``` + +2. Register in `plugins/index.ts` via `registerRenderer()` + +3. In ContentPanel.vue, look up renderers via `getRendererForContentType()` instead of hardcoded imports (gradual migration — start with film/song, keep others hardcoded) + +**Acceptance criteria**: +- Film and song renderers registered via plugin system +- `getAllRenderers()` returns registered renderers +- Content panel still renders correctly +- `pnpm typecheck` passes + +--- + +### M4: Social & Discovery (UX) + +#### Task M4.1: Social Embeds + +**Why**: Nostr notes referenced in chat should render as rich embeds, not raw text. + +**Files to create/modify**: +- Create `packages/app/src/components/chat/NostrEmbed.vue` +- Modify `packages/app/src/components/chat/ChatMessage.vue` — detect and render nostr: URIs + +**Implementation**: +1. Create `NostrEmbed.vue`: + - Accept `noteId` or `npub` prop + - Fetch note from relays (reuse `useNostr` composable from M2.4) + - Display: author npub (truncated), content, timestamp, relay source + - Glass card styling matching existing design system + - Loading skeleton while fetching + - Error state if note not found + +2. In ChatMessage.vue: + - Regex detect `nostr:note1...`, `nostr:npub1...`, `nostr:nevent1...` patterns + - Replace with `` component inline + - Handle bech32 decoding (NIP-19) for note/npub/nevent + +**Acceptance criteria**: +- `nostr:note1...` in chat renders as embedded card +- `nostr:npub1...` renders as profile card +- Graceful fallback if relay unreachable +- `pnpm typecheck` passes + +#### Task M4.2: Federated Search + +**Why**: Search currently only queries web. Should search across all content types simultaneously. + +**Files to create/modify**: +- Create `packages/app/src/composables/useFederatedSearch.ts` +- Modify `packages/app/src/components/chat/ChatInput.vue` — add search mode +- Create `packages/app/src/components/ui/SearchResults.vue` + +**Implementation**: +1. Create `useFederatedSearch.ts`: + ```ts + interface SearchResult { + type: 'film' | 'song' | 'podcast' | 'book' | 'article' | 'place' | 'web' + title: string + subtitle: string + thumbnail?: string + data: unknown // type-specific payload + } + export function useFederatedSearch() { + // Search across: film library, song library, podcast library, web (DDG/SearXNG) + // Return unified results sorted by relevance + // Debounce input (150ms) + // Cancel previous searches on new input + } + ``` + +2. In ChatInput.vue: + - Add `/search` command prefix detection + - When typing after `/search`, show SearchResults overlay above input + - Selecting a result inserts it as a content reference in the message + +3. Create SearchResults.vue: + - Grouped by content type with type icons + - Keyboard navigation (arrow keys + enter) + - Glass morphism dropdown styling + +**Acceptance criteria**: +- `/search matrix` returns films, songs, articles matching "matrix" +- Results grouped by type +- Selecting a result works +- `pnpm typecheck` passes + +#### Task M4.3: Bookmarks/Favorites + +**Why**: Users can't save interesting content items for later. + +**Files to create/modify**: +- Create `packages/app/src/stores/favorites.ts` — Pinia store +- Create `packages/app/src/components/ui/FavoriteButton.vue` +- Create `packages/app/src/components/content/FavoritesGrid.vue` +- Modify content card components (FilmCard, SongCard, etc.) — add favorite button +- Modify `packages/app/src/components/content/ContentPanel.vue` — add Favorites tab + +**Implementation**: +1. Create `favorites.ts` Pinia store: + ```ts + interface FavoriteItem { + id: string + type: 'film' | 'song' | 'podcast' | 'book' | 'tv' | 'place' | 'article' + title: string + data: unknown + savedAt: number + } + // Persist to IndexedDB (reuse idb-storage from M1.1) + // Methods: addFavorite, removeFavorite, isFavorited, getFavoritesByType + ``` + +2. Create `FavoriteButton.vue`: + - Heart icon toggle (outline = not saved, filled = saved) + - Animate on toggle (scale bounce) + - Bitcoin orange when favorited + +3. Create `FavoritesGrid.vue`: + - Tab in ContentPanel showing all saved items + - Filter by content type + - Sort by date saved + - Remove from favorites via swipe or button + +4. Add FavoriteButton to existing cards: FilmCard, SongCard, BookCard, etc. + +**Acceptance criteria**: +- Can favorite/unfavorite any content item +- Favorites persist across page refresh (IndexedDB) +- Favorites tab shows all saved items +- Filter by type works +- `pnpm typecheck` passes + +--- + +### M5: Security & Privacy (Infrastructure) + +#### Task M5.1: E2E Encryption + +**Why**: Conversations stored in IndexedDB are plaintext. Need encryption at rest. + +**Files to create/modify**: +- Create `packages/app/src/utils/crypto.ts` +- Modify `packages/app/src/utils/idb-storage.ts` — encrypt before write, decrypt on read + +**Implementation**: +1. Create `crypto.ts`: + ```ts + // Use Web Crypto API (no external dependencies) + export async function deriveKey(password: string, salt: Uint8Array): Promise + // PBKDF2, 100K iterations, SHA-256 + + export async function encrypt(data: string, key: CryptoKey): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> + // AES-256-GCM, random 12-byte IV + + export async function decrypt(ciphertext: ArrayBuffer, iv: Uint8Array, key: CryptoKey): Promise + // AES-256-GCM decrypt + + export async function generateSalt(): Promise + // 16 random bytes + ``` + +2. Modify `idb-storage.ts`: + - Add optional encryption parameter to save/load functions + - When `VITE_DISABLE_CRYPTO=true` (dev mode), skip encryption + - Store salt alongside encrypted data + - Key derived from user passphrase (prompted on first use) + +**Acceptance criteria**: +- Conversations encrypted in IndexedDB when crypto enabled +- Dev mode (`VITE_DISABLE_CRYPTO=true`) bypasses encryption +- Decryption with wrong passphrase fails gracefully +- `pnpm typecheck` passes +- Unit tests for encrypt/decrypt round-trip + +#### Task M5.2: Encrypted Storage Layer + +**Why**: All IndexedDB data (conversations, favorites, settings) should use the encryption layer. + +**Files to modify**: +- Modify `packages/app/src/stores/favorites.ts` — use encrypted storage +- Create `packages/app/src/components/ui/PassphraseDialog.vue` +- Modify `packages/app/src/main.ts` — prompt for passphrase on startup + +**Implementation**: +1. Create `PassphraseDialog.vue`: + - Modal dialog with passphrase input + - "Remember for this session" checkbox (holds key in memory) + - Create new / enter existing passphrase flow + - Glass card styling, min 16px font (no iOS zoom) + +2. Wire encryption into all storage operations: + - Conversations (chat.ts store) + - Favorites (favorites.ts store) + - Future: settings, API keys + +**Acceptance criteria**: +- First launch prompts for passphrase creation +- Subsequent launches prompt for passphrase entry +- Wrong passphrase shows error, does not corrupt data +- Session key held in memory (not persisted) +- `pnpm typecheck` passes + +#### Task M5.3: API Key Vault + +**Why**: API keys (Claude, OpenRouter) are currently stored in plaintext localStorage. + +**Files to create/modify**: +- Create `packages/app/src/utils/key-vault.ts` +- Create `packages/app/src/components/settings/ApiKeyManager.vue` +- Modify `packages/app/src/composables/useAI.ts` — read keys from vault + +**Implementation**: +1. Create `key-vault.ts`: + ```ts + // Encrypted storage for API keys using crypto.ts + export async function storeApiKey(provider: string, key: string): Promise + export async function getApiKey(provider: string): Promise + export async function deleteApiKey(provider: string): Promise + export async function listProviders(): Promise + // Keys encrypted with session-derived key from passphrase + // Stored in dedicated IndexedDB object store: 'api-keys' + ``` + +2. Create `ApiKeyManager.vue`: + - List configured providers + - Add/remove API keys + - Keys masked in UI (show last 4 chars) + - Test connection button per provider + +3. In `useAI.ts`: + - Replace direct env var / localStorage reads with vault lookups + - Fallback to env vars for dev mode + +**Acceptance criteria**: +- API keys encrypted at rest +- Keys never appear in console/logs +- UI shows masked keys +- Test connection verifies key works +- `pnpm typecheck` passes + +--- + +### M6: Payments & Identity (UX + Infrastructure) + +#### Task M6.1: Lightning Wallet Deep-links + +**Why**: AIUI is Bitcoin-only. Need to deep-link to external Lightning wallets for payments. + +**Files to create/modify**: +- Create `packages/app/src/utils/lightning.ts` +- Create `packages/app/src/components/ui/PaymentButton.vue` +- Create `packages/app/src/components/ui/LightningInvoice.vue` + +**Implementation**: +1. Create `lightning.ts`: + ```ts + // Generate LNURL-pay links, BIP21 URIs, Lightning: URIs + export function createLightningUri(invoice: string): string + export function createBip21Uri(address: string, amount?: number, label?: string): string + export function detectWallet(): 'strike' | 'muun' | 'phoenix' | 'zeus' | 'generic' + // Deep-link formats: lightning:BOLT11, bitcoin:?lightning=BOLT11 + ``` + +2. Create `PaymentButton.vue`: + - Bitcoin orange gradient button + - Shows sat amount + - On click: generates deep-link URI, opens wallet + - Fallback: show QR code with invoice string + - Copy invoice to clipboard button + +3. Create `LightningInvoice.vue`: + - Display BOLT11 invoice as QR code (use `qrcode` lib or canvas) + - Show amount in sats + - Expiry countdown + - Copy button + +**Acceptance criteria**: +- Payment button generates valid Lightning URIs +- Deep-link opens system wallet picker on mobile +- QR fallback for desktop +- `pnpm typecheck` passes + +#### Task M6.2: Cashu Token Support + +**Why**: Cashu ecash tokens enable offline micropayments. Display and copy Cashu tokens in chat. + +**Files to create/modify**: +- Create `packages/app/src/utils/cashu.ts` +- Create `packages/app/src/components/chat/CashuToken.vue` +- Modify `packages/app/src/components/chat/ChatMessage.vue` — detect cashu tokens + +**Implementation**: +1. Create `cashu.ts`: + ```ts + // Parse Cashu token format (cashuA...) + export function parseCashuToken(token: string): { mint: string; amount: number; unit: string } | null + export function isCashuToken(text: string): boolean + // No wallet functionality — AIUI is never a wallet + // Just parse, display, and deep-link to external wallet + ``` + +2. Create `CashuToken.vue`: + - Detect `cashuA...` strings in chat messages + - Display as card: amount, mint URL (truncated), copy button + - "Open in wallet" deep-link button + - Glass card styling with Bitcoin orange accent + +3. In ChatMessage.vue: + - Regex detect Cashu tokens + - Replace inline with `` component + +**Acceptance criteria**: +- Cashu tokens in chat render as rich cards +- Copy token to clipboard works +- Deep-link to wallet works +- Invalid tokens show graceful fallback +- `pnpm typecheck` passes + +#### Task M6.3: Nostr Identity (NIP-07) + +**Why**: Enable login via Nostr browser extension (nos2x, Alby, etc.) for identity. + +**Files to create/modify**: +- Create `packages/app/src/composables/useNostrIdentity.ts` +- Create `packages/app/src/components/settings/NostrLogin.vue` +- Modify `packages/app/src/stores/` — add user identity store + +**Implementation**: +1. Create `useNostrIdentity.ts`: + ```ts + // NIP-07: window.nostr API + export function useNostrIdentity() { + const isAvailable: Ref // window.nostr exists + const pubkey: Ref + const npub: Ref // bech32 encoded + async function login(): Promise // calls window.nostr.getPublicKey() + async function sign(event: NostrEvent): Promise // calls window.nostr.signEvent() + function logout(): void + } + ``` + +2. Create `NostrLogin.vue`: + - "Login with Nostr" button (purple/Nostr brand color) + - Shows npub when logged in (truncated with copy) + - Logout button + - Detects if NIP-07 extension is installed + +**Acceptance criteria**: +- Login with nos2x/Alby extension works +- Public key displayed as npub +- Sign events for Nostr posting +- Graceful message if no extension installed +- `pnpm typecheck` passes + +--- + +### M7: Platform (Infrastructure) + +#### Task M7.1: MCP Server Integration + +**Why**: Model Context Protocol enables rich tool use. AIUI should expose content surfaces as MCP tools. + +**Files to create/modify**: +- Create `packages/app/src/plugins/mcp-server.ts` +- Modify `packages/app/src/composables/useAI.ts` — add MCP tool handling + +**Implementation**: +1. Create `mcp-server.ts`: + ```ts + // Expose AIUI capabilities as MCP tools + const tools = [ + { name: 'search_films', description: 'Search film library', inputSchema: {...} }, + { name: 'search_songs', description: 'Search song library', inputSchema: {...} }, + { name: 'search_web', description: 'Search the web', inputSchema: {...} }, + { name: 'get_nostr_feed', description: 'Fetch Nostr notes', inputSchema: {...} }, + ] + // Handle tool_use responses from AI and route to appropriate composable + ``` + +2. In useAI.ts: + - Parse tool_use blocks from Claude responses + - Route to appropriate handler (film search, web search, etc.) + - Return tool results back in conversation + +**Acceptance criteria**: +- Claude can call tools via MCP format +- Tool results display as content in panel +- `pnpm typecheck` passes + +#### Task M7.2: Multi-provider AI Normalization + +**Why**: Different AI providers (Claude, OpenRouter, Ollama) have different APIs. Normalize them. + +**Files to create/modify**: +- Create `packages/app/src/adapters/claude-adapter.ts` +- Create `packages/app/src/adapters/openrouter-adapter.ts` +- Create `packages/app/src/adapters/ollama-adapter.ts` +- Create `packages/app/src/adapters/types.ts` — unified interface +- Modify `packages/app/src/composables/useAI.ts` — use adapter pattern + +**Implementation**: +1. Create `types.ts`: + ```ts + interface AIAdapter { + id: string + name: string + chat(messages: Message[], options: ChatOptions): AsyncIterable + models(): Promise + supportsStreaming: boolean + supportsVision: boolean + supportsTools: boolean + } + ``` + +2. Create adapters for Claude (existing logic), OpenRouter (OpenAI-compatible), Ollama (local). + +3. Refactor useAI.ts to select adapter by provider setting. + +**Acceptance criteria**: +- Can switch between Claude/OpenRouter/Ollama +- Streaming works with all providers +- Content extraction works regardless of provider +- `pnpm typecheck` passes + +#### Task M7.3: Tauri Desktop Build + +**Why**: Desktop app via Tauri for native experience with system tray, global shortcuts. + +**Files to create/modify**: +- Create `src-tauri/` directory with Tauri config +- Create `src-tauri/tauri.conf.json` +- Create `src-tauri/src/main.rs` +- Modify `packages/app/package.json` — add tauri scripts + +**Implementation**: +1. Initialize Tauri in the app package: + - `pnpm add -D @tauri-apps/cli @tauri-apps/api` in packages/app + - Configure window: frameless with custom titlebar, transparent background + - System tray with quick-access menu + - Global shortcut (Cmd+Shift+A) to show/hide window + +2. Tauri config: + - Window size: 1200x800, min 800x600 + - Transparent background (for glass morphism) + - Auto-updater enabled + - File system scope: app data directory only + +**Acceptance criteria**: +- `pnpm tauri dev` launches desktop app +- Glass morphism renders correctly with transparent window +- System tray works +- `pnpm tauri build` produces .dmg/.app +- `pnpm typecheck` passes + +#### Task M7.4: Offline Mode + +**Why**: AIUI should work without internet for browsing saved content. + +**Files to create/modify**: +- Modify `packages/app/src/sw.ts` or PWA config — cache strategies +- Create `packages/app/src/composables/useOffline.ts` +- Modify UI components — offline indicators + +**Implementation**: +1. Create `useOffline.ts`: + ```ts + export function useOffline() { + const isOnline: Ref // navigator.onLine + event listeners + const pendingSync: Ref // count of items waiting to sync + function queueForSync(action: SyncAction): void + function processSyncQueue(): Promise + } + ``` + +2. PWA cache strategies: + - App shell: cache-first (HTML, JS, CSS, fonts) + - API responses: network-first with cache fallback + - Images: cache-first with stale-while-revalidate + - IndexedDB data: always available offline + +3. UI indicators: + - Subtle banner when offline ("Offline — browsing saved content") + - Disable AI chat input when offline (grey out with tooltip) + - Show cached content (favorites, saved conversations) + +**Acceptance criteria**: +- App loads without internet +- Saved conversations and favorites accessible offline +- Chat disabled with clear offline indicator +- Reconnection triggers sync +- `pnpm typecheck` passes + +--- + +## Part 3: Automated Session Execution Order + +For automated late-night Claude sessions, execute tasks in this order: + +### Priority Queue (each session picks next incomplete task): + +**M1: Stability & Polish** +1. **Task M1.2** — Error boundaries *(verify existing ErrorBoundary.vue, wrap remaining components)* +2. **Task M1.3** — Unit tests for contentExtraction *(create __tests__/contentExtraction.test.ts)* +3. **Task M1.1** — IndexedDB persistent storage *(create idb-storage.ts, modify chat.ts)* +4. **Task M1.4** — Unit tests for useAI *(create __tests__/useAI.test.ts with mocked fetch)* +5. **Task M1.5** — E2E test expansion *(8 new tests in content-surfaces.spec.ts)* + +**M2: Content Experience** +6. **Task M2.1** — Markdown rendering in chat *(add markdown-it, modify ChatMessage.vue)* +7. **Task M2.3** — Music source resolution + queue *(fix usePlayer.ts, update PlayerBar.vue)* +8. **Task M2.2** — Virtual scrolling for chat *(add @tanstack/vue-virtual to ChatWindow.vue)* +9. **Task M2.4** — Nostr feed integration *(create useNostr.ts, update NostrGrid.vue)* + +**M3: Plugin System** +10. **Task M3.1** — Activate plugin registry *(create plugins/index.ts, claude-provider.ts)* +11. **Task M3.2** — Renderer plugin registration *(film/song renderer plugins)* + +**M4: Social & Discovery** +12. **Task M4.1** — Social embeds *(NostrEmbed.vue, nostr: URI detection in chat)* +13. **Task M4.2** — Federated search *(useFederatedSearch.ts, /search command)* +14. **Task M4.3** — Bookmarks/favorites *(favorites.ts store, FavoriteButton, FavoritesGrid)* + +**M5: Security & Privacy** +15. **Task M5.1** — E2E encryption *(crypto.ts with Web Crypto API AES-256-GCM)* +16. **Task M5.2** — Encrypted storage layer *(PassphraseDialog, wire encryption to all stores)* +17. **Task M5.3** — API key vault *(key-vault.ts, ApiKeyManager.vue)* + +**M6: Payments & Identity** +18. **Task M6.1** — Lightning wallet deep-links *(lightning.ts, PaymentButton, LightningInvoice)* +19. **Task M6.2** — Cashu token support *(cashu.ts, CashuToken.vue inline in chat)* +20. **Task M6.3** — Nostr identity NIP-07 *(useNostrIdentity.ts, NostrLogin.vue)* + +**M7: Platform** +21. **Task M7.1** — MCP server integration *(mcp-server.ts, tool routing in useAI)* +22. **Task M7.2** — Multi-provider AI normalization *(adapter pattern for Claude/OpenRouter/Ollama)* +23. **Task M7.3** — Tauri desktop build *(src-tauri config, transparent window, system tray)* +24. **Task M7.4** — Offline mode *(useOffline.ts, cache strategies, offline UI indicators)* + +### Session Protocol + +Each automated session should: +1. Read `PROGRESS.md` to find the next incomplete task +2. Read this plan file for the task's detailed spec +3. Execute the task following the spec exactly +4. Run `pnpm typecheck` after changes +5. Run `pnpm lint` after changes +6. Run `pnpm test` if unit tests exist +7. Commit with conventional format: `type(scope): description` +8. Push to current branch +9. Update PROGRESS.md session log (triggered by hook, or manually) + +--- + +## Verification + +After implementing Part 1 (progress automation): +1. Run `pnpm typecheck && pnpm lint` — should pass +2. Commit a change and push — hook should fire +3. Verify PROGRESS.md gets a session log entry +4. Test from a different worktree — should work identically + +After each M1–M3 task: +1. `pnpm typecheck` passes +2. `pnpm lint` passes +3. `pnpm test` passes (if tests exist) +4. Dev server runs without errors (`pnpm dev`) +5. Manual smoke test: send a message, see content render diff --git a/aiui/PLAN2.md b/aiui/PLAN2.md new file mode 100644 index 00000000..76c3b583 --- /dev/null +++ b/aiui/PLAN2.md @@ -0,0 +1,443 @@ +# AIUI Plan 2 — Extended Roadmap + +## Context & Philosophy + +This plan continues from M0–M7 (all complete). Every item below must honour the core philosophy: +- **Glass morphism only** — `glass`, `glass-card`, `glass-button`. No light mode, no gray-900 hacks. +- **Open source / MIT/Apache-2.0** — no proprietary dependencies +- **Decentralised-first** — no vendor lock-in, pluggable everything +- **Bitcoin only** — sats, Lightning, Cashu, Fedimint. AIUI is never a wallet — always deep-link +- **Privacy-first** — no telemetry, no tracking, E2E encryption +- **Mobile-first, everywhere-perfect** — desktop enhances mobile, never replaces it +- **Plugin-everything** — all integrations go through typed plugin interfaces +- **< 250 KB gzipped initial load** — everything else lazy-loaded + +--- + +## M8: Chat UX Polish + +### M8.1 — Message Editing & Regeneration +Edit any sent message in place; all messages after it are cleared and AI regenerates from that point. Pencil icon appears on hover. Textarea replaces bubble on click. `Escape` cancels, `Enter` submits. + +### M8.2 — Conversation Branching +Fork from any assistant message. Branch indicator in chat header (e.g. "Branch 2 of 3"). Branch switcher as a compact glass pill above the forked message. Each branch stored as a separate conversation in IDB. + +### M8.3 — Reply-to Threading +Click any message → "Reply" option. Reply shows a quoted excerpt of the target message above the input. Thread line connects quoted block to source. Visual only — does not send separate context to AI, just prepends `> quote` to the user message. + +### M8.4 — Conversation Search +`Cmd+F` / search icon opens a slide-down glass panel above chat. Real-time filtering highlights matching messages. Up/down arrows jump between matches. `Escape` closes. + +### M8.5 — Auto-Title Generation +After the first AI response in a new conversation, send a background request: `"Give a 4-word title for this conversation: {first user message}"`. Replace "New Chat" silently. No loading state — title updates smoothly. + +### M8.6 — Context Window Visualiser +Slim progress bar at top of chat column. Estimates token count from message lengths (1 token ≈ 4 chars). Shows percentage of model's context window used. Bitcoin-orange fill → red when > 80%. Tooltip: "~12,400 / 200,000 tokens used". + +### M8.7 — Conversation Export +Three-dot menu on each conversation → Export. Options: Markdown (download .md), JSON (full data), Plain text. Uses File System Access API when available, falls back to ``. No server involved. + +### M8.8 — Import Conversations +Settings → Import → drag-and-drop or file picker for AIUI JSON export or Claude.ai export JSON. Merges into existing conversations without overwriting. Shows import summary (N conversations added). + +### M8.9 — Long-press / Right-click Context Menus +Messages: Copy, Edit, Delete, Reply, Branch from here. Content cards: Favourite, Share, Open detail, Copy title. Uses a reusable `ContextMenu.vue` glass-card component positioned at cursor. Closes on outside click or `Escape`. + +### M8.10 — Scroll Position Memory +When switching between conversations, restore the previous scroll position. Store position per conversation ID in a `Map` (not persisted — session only). Virtual scroller should seek to the stored offset on mount. + +--- + +## M9: AI Experience + +### M9.1 — Multi-Model Comparison Mode +Split-screen: same prompt sent to two models simultaneously. Side-by-side layout on desktop, swipeable tabs on mobile. Model selector per pane. Shows streaming output in both. Useful for comparing Claude vs OpenRouter models. + +### M9.2 — System Prompt Editor +Settings → Personas. Create named personas (e.g. "Film Critic", "Bitcoin Analyst"). Each has a system prompt, model preference, and accent colour. Select persona per conversation via a pill menu above the input. Default persona applies to all new conversations. + +### M9.3 — Prompt Template Library +`/` in chat input opens a command palette (glass dropdown). Templates listed with title + preview. Variables in templates use `{{variable}}` syntax — on selection, a mini form appears to fill them. Templates stored in IDB, importable/exportable as JSON. + +### M9.4 — Vision Input +Drag-and-drop or paste image into chat input. Image preview appears as a thumbnail above the input. On send, image encoded as base64 and included in the message content array (Claude vision format). Only enabled when active model supports vision. Max 4 images per message. + +### M9.5 — Response Feedback +Thumbs up / thumbs down on each AI message (appears on hover). Stored locally in IDB per message ID. Shown in conversation export. Future: aggregate across sessions for personal preference tracking. Never sent anywhere. + +### M9.6 — Token & Cost Estimator +Settings toggle to show token counts. Each message shows estimated token count in a tiny badge (bottom-right of bubble). Running total shown in context window bar. Cost estimate based on current model's pricing (hardcoded table, updated with model releases). + +### M9.7 — AI Memory Panel +Settings → Memory. A list of "always remember" facts injected into every system prompt. e.g. "I live in London", "I prefer sats over fiat". Edit/delete/add. Max 20 items. Stored encrypted in IDB. Shown as a collapsed "Memory" section in the system prompt. + +### M9.8 — Model Capabilities Badge +Model selector shows capability badges: Vision 👁, Tools 🔧, Long context 📄. Tooltip explains each. Greys out vision input button when selected model doesn't support it. Updates dynamically when switching providers. + +### M9.9 — Temperature & Params Slider +Advanced settings section (collapsed by default) beneath the model selector. Sliders for: Temperature (0–1), Max tokens (256–8192), Top-P. Values persisted per conversation in IDB. Reset to defaults button. + +### M9.10 — Stop Sequence Configuration +Advanced settings: configurable stop sequences (comma-separated). Applied to all requests for that conversation. Useful for structured output tasks. Shown as a small tag list below the slider panel. + +--- + +## M10: Advanced Content Renderers + +### M10.1 — Full Article Renderer +When AI returns a long-form article (> 800 words with headings), render it in the panel as a paginated article view. Features: auto-generated table of contents (sticky left sidebar on desktop), estimated reading time, font-size control, print mode. Uses existing markdown-it instance. + +### M10.2 — PDF Viewer +Content type `pdf` renders via `pdfjs-dist` (lazy loaded, ~400 KB). Page navigation, zoom, text selection, search within PDF. Chat preview: thumbnail of page 1. Panel play: full viewer. Files loaded from URL (no local file upload in v1). + +### M10.3 — Map Renderer +Content type `place` upgrades from static card to interactive Leaflet map (lazy loaded). OpenStreetMap tiles (no API key needed). Pins for all places mentioned in conversation. Cluster pins when > 10 places. Panel play: fullscreen map with place list sidebar. + +### M10.4 — Recipe Renderer +New content type `recipe`. Tag: ``. Structured display: ingredients checklist (tap to strike through), numbered steps, metadata chips (time, servings, calories). "Scale recipe" slider (0.5×–4×) recalculates quantities. + +### M10.5 — Event Renderer +New content type `event`. Tag: ``. Shows: date chip, location, countdown. Add to calendar buttons: ICS download, Google Calendar URL, Apple Calendar. Glass card in chat, full detail in panel. + +### M10.6 — Math Renderer +Detect `$...$` (inline) and `$$...$$` (block) LaTeX in chat messages. Render using KaTeX (lazy loaded, ~70 KB). Fallback: display raw LaTeX in a code block. No re-renders during streaming — batch render on stream end. + +### M10.7 — Mermaid Diagram Renderer +Detect ` ```mermaid ` fenced code blocks. Render using Mermaid.js (lazy loaded, ~500 KB). Support: flowchart, sequence, gantt, entity-relationship. Dark theme matching glass design. Copy SVG button. Pan/zoom on mobile. + +### M10.8 — Audio Waveform Player +Upgrade PlayerBar for locally-loaded audio. Use WaveSurfer.js (lazy loaded) to show waveform visualization. Waveform rendered in Bitcoin orange on dark background. Click to seek. Existing queue/next/prev preserved. + +### M10.9 — Table Renderer +Markdown tables rendered as interactive tables: column sort (click header), row filter (search input above table), CSV export button. Uses existing markdown-it but overrides the table token renderer. Max 500 rows before virtualisation kicks in. + +### M10.10 — Timeline Renderer +New content type `timeline`. AI returns a series of `` tags. Panel renders them as a vertical timeline: date on left, event card on right, connecting line. Animate entries in as they appear during streaming. + +### M10.11 — Code Runner +Fenced code blocks with a "Run" button for HTML/CSS/JS. Opens a sandboxed `