diff --git a/.devcontainer/config.fish b/.devcontainer/config.fish index c6326e5051..8098720695 100644 --- a/.devcontainer/config.fish +++ b/.devcontainer/config.fish @@ -1922,18 +1922,8 @@ function restart-daemon end function ac-site - cd ~/aesthetic-computer/system - echo "🐱 Starting site..." - echo "🔍 Cleaning up any stuck processes..." - pkill -f "netlify dev" 2>/dev/null - pkill -f "esbuild" 2>/dev/null - sleep 1 - echo "🔌 Killing ports..." - timeout 5 npx kill-port 8880 8888 8889 8080 8000 8111 3333 3000 3001 2>/dev/null; or true - echo "🔗 Linking netlify..." - timeout 10 netlify link --id $NETLIFY_SITE_ID 2>/dev/null; or true - echo "🚀 Starting server..." - npm run local-dev + # ac-site now runs lith (monolith) instead of netlify dev + ac-lith end function ac-lith @@ -3208,35 +3198,35 @@ function ac-ableton-tunnel-simple --description "Simple UDP listener (run on Mac echo " nc -lu 7777 | while read line; do echo \"\$(date '+%H:%M:%S') \$line\"; done" end -# 🖥️ Machine Info / SSH Helpers -# Read machine configs from vault/machines.json - -function __ac_machines_file --description "Resolve machines.json with local cache fallback" - set -l machines_file "/workspaces/aesthetic-computer/aesthetic-computer-vault/machines.json" - set -l machines_cache "$HOME/.cache/ac/machines.json" - - if test -f $machines_file - mkdir -p (dirname $machines_cache) - cp $machines_file $machines_cache 2>/dev/null - echo $machines_file - return 0 - end - - if test -f $machines_cache - echo $machines_cache - return 0 - end - - return 1 -end - -function ac-host --description "Show current host SSH config from machines.json" - set -l machines_file (__ac_machines_file) - - if test $status -ne 0 -o -z "$machines_file" - echo "❌ machines.json not found in vault or local cache" - return 1 - end +# 🖥️ Machine Info / SSH Helpers +# Read machine configs from vault/machines.json + +function __ac_machines_file --description "Resolve machines.json with local cache fallback" + set -l machines_file "/workspaces/aesthetic-computer/aesthetic-computer-vault/machines.json" + set -l machines_cache "$HOME/.cache/ac/machines.json" + + if test -f $machines_file + mkdir -p (dirname $machines_cache) + cp $machines_file $machines_cache 2>/dev/null + echo $machines_file + return 0 + end + + if test -f $machines_cache + echo $machines_cache + return 0 + end + + return 1 +end + +function ac-host --description "Show current host SSH config from machines.json" + set -l machines_file (__ac_machines_file) + + if test $status -ne 0 -o -z "$machines_file" + echo "❌ machines.json not found in vault or local cache" + return 1 + end set -l machine_key $argv[1] @@ -3339,13 +3329,13 @@ function ac-machines --description "List all machines from vault/machines.json" ac-host end -function ac-host-nmap --description "Run nmap scan on local network via current host" - set -l machines_file (__ac_machines_file) - if test $status -ne 0 -o -z "$machines_file" - echo "❌ machines.json not found in vault or local cache" - return 1 - end - set -l search_term $argv[1] +function ac-host-nmap --description "Run nmap scan on local network via current host" + set -l machines_file (__ac_machines_file) + if test $status -ne 0 -o -z "$machines_file" + echo "❌ machines.json not found in vault or local cache" + return 1 + end + set -l search_term $argv[1] set -l hosts_to_try (jq -r ' .machines diff --git a/ac-m4l/AC-SpreadnobClean.amxd.json b/ac-m4l/AC-SpreadnobClean.amxd.json index d749e95ab2..21e57c9014 100644 --- a/ac-m4l/AC-SpreadnobClean.amxd.json +++ b/ac-m4l/AC-SpreadnobClean.amxd.json @@ -483,7 +483,7 @@ "maxclass": "newobj", "numinlets": 1, "numoutlets": 1, "outlettype": [""], "patching_rect": [420, 350, 85, 22], - "text": "prepend set" + "text": "prepend text" } }, { diff --git a/ac-m4l/spreadnob-ui.js b/ac-m4l/spreadnob-ui.js new file mode 100644 index 0000000000..c90694e70e --- /dev/null +++ b/ac-m4l/spreadnob-ui.js @@ -0,0 +1,139 @@ +// spreadnob-ui.js — jsui for the spreadnob dial +autowatch = 1; +mgraphics.init(); +mgraphics.relative_coords = 0; +mgraphics.autofill = 0; + +var KEYS = ["A","S","D","F","G","H","J","K","L"]; +var N = 9; +var SWEEP = 270; +var START = 225; // degrees, lower-left + +var val = 0; +var noteIdx = -1; +var name = "click a knob"; +var pmin = 0; +var pmax = 1; + +function note(n) { + // Convert MIDI note to key index (0-8) + var WHITE = [0,2,4,5,7,9,11,12,14]; + var off = n - 48; + noteIdx = -1; + for (var i = 0; i < WHITE.length; i++) { + if (WHITE[i] === off) { noteIdx = i; break; } + } + mgraphics.redraw(); +} + +function value(v) { + val = v; + mgraphics.redraw(); +} + +function target() { + var a = arrayfromargs(arguments); + name = a.join(" "); + mgraphics.redraw(); +} + +function setmin(v) { pmin = v; mgraphics.redraw(); } +function setmax(v) { pmax = v; mgraphics.redraw(); } + +function d2r(d) { return d * Math.PI / 180; } + +function paint() { + with (mgraphics) { + var w = mgraphics.size[0]; + var h = mgraphics.size[1]; + var cx = w / 2; + var cy = h / 2 + 2; + var R = Math.min(w, h) * 0.32; + var tickR = R + 4; + var labelR = R + 14; + + // Background + set_source_rgba(0.12, 0.12, 0.14, 1); + rectangle(0, 0, w, h); + fill(); + + // Track arc (background) + set_source_rgba(0.25, 0.23, 0.28, 1); + set_line_width(5); + // mgraphics angles: clockwise from right. Negate math angles. + arc(cx, cy, R, d2r(-START), d2r(-(START - SWEEP))); + stroke(); + + // Value arc + var t = 0; + if (pmax !== pmin) t = Math.max(0, Math.min(1, (val - pmin) / (pmax - pmin))); + var valDeg = START - t * SWEEP; + if (t > 0.001) { + set_source_rgba(0.42, 1.0, 0.58, 0.9); + set_line_width(5); + arc(cx, cy, R, d2r(-START), d2r(-valDeg)); + stroke(); + } + + // Value dot + var dx = cx + R * Math.cos(d2r(-valDeg)); + var dy = cy + R * Math.sin(d2r(-valDeg)); + set_source_rgba(1, 1, 1, 1); + arc(dx, dy, 4, 0, Math.PI * 2); + fill(); + + // Ticks + key labels + for (var i = 0; i < N; i++) { + var deg = START - (i / (N - 1)) * SWEEP; + var rad = d2r(-deg); + var ox = cx + tickR * Math.cos(rad); + var oy = cy + tickR * Math.sin(rad); + var ix = cx + (R - 2) * Math.cos(rad); + var iy = cy + (R - 2) * Math.sin(rad); + var lx = cx + labelR * Math.cos(rad); + var ly = cy + labelR * Math.sin(rad); + + var hit = (noteIdx === i); + + // Tick line + set_line_width(hit ? 2.5 : 1); + set_source_rgba(hit ? 1 : 0.5, hit ? 1 : 0.48, hit ? 1 : 0.55, 1); + move_to(ox, oy); + line_to(ix, iy); + stroke(); + + // Label + set_source_rgba(hit ? 1 : 0.5, hit ? 1 : 0.48, hit ? 1 : 0.55, 1); + select_font_face("Arial Bold"); + set_font_size(hit ? 11 : 9); + var te = text_measure(KEYS[i]); + move_to(lx - te[0] / 2, ly + te[1] / 3); + show_text(KEYS[i]); + } + + // Center — target name + set_source_rgba(0.85, 0.7, 0.8, 1); + select_font_face("Arial"); + set_font_size(9); + var tn = name.length > 18 ? name.substring(0, 18) + "…" : name; + var te = text_measure(tn); + move_to(cx - te[0] / 2, cy - 4); + show_text(tn); + + // Center — value + var vt = val.toFixed(3); + set_source_rgba(0.42, 1.0, 0.58, 1); + select_font_face("Arial Bold"); + set_font_size(12); + te = text_measure(vt); + move_to(cx - te[0] / 2, cy + 12); + show_text(vt); + + // Title + set_source_rgba(1.0, 0.47, 0.72, 0.6); + select_font_face("Arial Bold"); + set_font_size(8); + move_to(4, 11); + show_text("spreadnob"); + } +} diff --git a/lith/server.mjs b/lith/server.mjs index 1959cd945e..ca704ae3aa 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -589,6 +589,181 @@ app.get("/frame/:piece", async (req, res) => { `); }); +// --- /api/os-release-upload (ports Netlify edge function os-release-upload.js) --- +app.post("/api/os-release-upload", async (req, res) => { + const { createHmac } = await import("crypto"); + + // Auth: verify AC token + const authHeader = req.headers["authorization"] || ""; + const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : ""; + if (!token) return res.status(401).json({ error: "Missing Authorization: Bearer " }); + + let user; + try { + const uiRes = await fetch("https://hi.aesthetic.computer/userinfo", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`); + user = await uiRes.json(); + } catch (err) { + return res.status(401).json({ error: `Auth failed: ${err.message}` }); + } + + const userSub = user.sub || "unknown"; + const userName = user.name || user.nickname || userSub; + + const accessKey = process.env.DO_SPACES_KEY || process.env.ART_KEY; + const secretKey = process.env.DO_SPACES_SECRET || process.env.ART_SECRET; + if (!accessKey || !secretKey) return res.status(503).json({ error: "Spaces creds not configured" }); + + const bucket = "releases-aesthetic-computer"; + const host = `${bucket}.sfo3.digitaloceanspaces.com`; + + const buildName = req.headers["x-build-name"] || `upload-${Date.now()}`; + const gitHash = req.headers["x-git-hash"] || "unknown"; + const buildTs = req.headers["x-build-ts"] || new Date().toISOString().slice(0, 16); + const commitMsg = req.headers["x-commit-msg"] || ""; + const version = `${buildName} ${gitHash}-${buildTs}`; + + function presignUrl(key, contentType, expiresSec = 900) { + const expires = Math.floor(Date.now() / 1000) + expiresSec; + const stringToSign = `PUT\n\n${contentType}\n${expires}\nx-amz-acl:public-read\n/${bucket}/${key}`; + const sig = createHmac("sha1", secretKey).update(stringToSign).digest("base64"); + return `https://${host}/${key}?AWSAccessKeyId=${encodeURIComponent(accessKey)}&Expires=${expires}&Signature=${encodeURIComponent(sig)}&x-amz-acl=public-read`; + } + + async function s3Put(key, body, contentType) { + const dateStr = new Date().toUTCString(); + const stringToSign = `PUT\n\n${contentType}\n${dateStr}\nx-amz-acl:public-read\n/${bucket}/${key}`; + const sig = createHmac("sha1", secretKey).update(stringToSign).digest("base64"); + const putRes = await fetch(`https://${host}/${key}`, { + method: "PUT", + headers: { Date: dateStr, "Content-Type": contentType, "x-amz-acl": "public-read", Authorization: `AWS ${accessKey}:${sig}` }, + body: typeof body === "string" ? body : body, + }); + if (!putRes.ok) { + const text = await putRes.text(); + throw new Error(`S3 PUT ${key}: ${putRes.status} ${text.slice(0, 200)}`); + } + } + + async function loadMachineTokenSecret() { + try { + const connStr = process.env.MONGODB_CONNECTION_STRING; + if (!connStr) return null; + const { MongoClient } = await import("mongodb"); + const client = new MongoClient(connStr); + await client.connect(); + const dbName = process.env.MONGODB_NAME || "aesthetic"; + const doc = await client.db(dbName).collection("secrets").findOne({ _id: "machine-token" }); + await client.close(); + return doc?.secret || null; + } catch (e) { + console.error("[os-release-upload] Failed to load machine-token secret:", e.message); + return null; + } + } + + async function generateDeviceToken(sub, handle) { + const secret = await loadMachineTokenSecret(); + if (!secret) return null; + const payload = { sub, handle, iat: Math.floor(Date.now() / 1000) }; + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const sigB64 = createHmac("sha256", secret).update(payloadB64).digest("base64url"); + return `${payloadB64}.${sigB64}`; + } + + const isFinalize = req.headers["x-finalize"] === "true"; + + if (isFinalize) { + const sha256 = req.headers["x-sha256"] || "unknown"; + const size = parseInt(req.headers["x-size"] || "0", 10); + try { + const versionWithSize = `${version}\n${size}`; + await Promise.all([ + s3Put("os/native-notepat-latest.version", versionWithSize, "text/plain"), + s3Put("os/native-notepat-latest.sha256", sha256, "text/plain"), + ]); + let releases = { releases: [] }; + try { + const existing = await fetch(`https://${host}/os/releases.json`); + if (existing.ok) releases = await existing.json(); + } catch { /* first release */ } + const userHandle = req.headers["x-handle"] || user.nickname || user.name || userName; + releases.releases = releases.releases || []; + for (const r of releases.releases) r.deprecated = true; + releases.releases.unshift({ + version, name: buildName, sha256, size, git_hash: gitHash, build_ts: buildTs, + commit_msg: commitMsg, user: userSub, handle: userHandle, + url: `https://${host}/os/native-notepat-latest.vmlinuz`, + archive_url: `https://${host}/os/builds/${buildName}.vmlinuz`, + }); + releases.releases = releases.releases.slice(0, 50); + releases.latest = version; + releases.latest_name = buildName; + const deviceToken = await generateDeviceToken(userSub, userHandle); + if (deviceToken) releases.device_token = deviceToken; + await s3Put("os/releases.json", JSON.stringify(releases, null, 2), "application/json"); + return res.json({ ok: true, name: buildName, version, sha256, size, url: `https://${host}/os/native-notepat-latest.vmlinuz`, user: userSub, userName, deviceToken: !!deviceToken }); + } catch (err) { + return res.status(500).json({ error: `Finalize failed: ${err.message}` }); + } + } + + if (req.headers["x-versioned-upload"] === "true") { + try { + const versionedKey = req.headers["x-versioned-key"] || `os/builds/${buildName}.vmlinuz`; + return res.json({ step: "versioned-upload", versioned_put_url: presignUrl(versionedKey, "application/octet-stream", 1800), key: versionedKey, user: userSub }); + } catch (err) { + return res.status(500).json({ error: `Versioned presign failed: ${err.message}` }); + } + } + + if (req.headers["x-manifest-upload"] === "true") { + try { + return res.json({ step: "manifest-upload", manifest_put_url: presignUrl("os/latest-manifest.json", "application/json"), user: userSub }); + } catch (err) { + return res.status(500).json({ error: `Manifest presign failed: ${err.message}` }); + } + } + + if (req.headers["x-template-upload"] === "true") { + try { + return res.json({ step: "template-upload", iso_put_url: presignUrl("os/native-notepat-latest.iso", "application/x-iso9660-image"), user: userSub }); + } catch (err) { + return res.status(500).json({ error: `Template presign failed: ${err.message}` }); + } + } + + // Step 1: Return presigned URL for vmlinuz upload + try { + return res.json({ step: "upload", vmlinuz_put_url: presignUrl("os/native-notepat-latest.vmlinuz", "application/octet-stream"), version, user: userSub, userName }); + } catch (err) { + return res.status(500).json({ error: `Presign failed: ${err.message}` }); + } +}); + +// --- /api/os-image (ports Netlify edge function os-image.js) --- +app.get("/api/os-image", async (req, res) => { + const authHeader = req.headers["authorization"] || ""; + if (!authHeader) return res.status(401).json({ error: "Authorization required. Log in at aesthetic.computer first." }); + + try { + const ovenRes = await fetch("https://oven.aesthetic.computer/os-image", { + headers: { Authorization: authHeader }, + }); + res.status(ovenRes.status); + res.set("Content-Type", ovenRes.headers.get("content-type") || "application/octet-stream"); + if (ovenRes.headers.get("content-disposition")) res.set("Content-Disposition", ovenRes.headers.get("content-disposition")); + if (ovenRes.headers.get("content-length")) res.set("Content-Length", ovenRes.headers.get("content-length")); + res.set("Access-Control-Allow-Origin", "*"); + const { Readable } = await import("stream"); + Readable.fromWeb(ovenRes.body).pipe(res); + } catch (err) { + return res.status(502).json({ error: `Oven unavailable: ${err.message}` }); + } +}); + // --- /media/* handler (ports Netlify edge function media.js) --- app.all("/media/*rest", async (req, res) => { const parts = req.path.split("/").filter(Boolean); // ["media", ...] @@ -727,6 +902,94 @@ app.all("/profile/*rest", directFn("profile")); // Static files app.use(express.static(PUBLIC, { extensions: ["html"], dotfiles: "allow" })); +// --- keeps-social: SSR meta tags for social crawlers on keep/buy.kidlisp.com --- +const CRAWLER_RE = /twitterbot|facebookexternalhit|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|applebot/i; +const OBJKT_GRAPHQL = "https://data.objkt.com/v3/graphql"; + +async function keepsSocialMiddleware(req, res, next) { + const host = (req.headers.host || "").split(":")[0].toLowerCase(); + const isBuy = host.includes("buy.kidlisp.com"); + const isKeep = host.includes("keep.kidlisp.com"); + if (!isBuy && !isKeep) return next(); + + const seg = req.path.replace(/^\/+/, "").split("/")[0]; + if (!seg.startsWith("$") || seg.length < 2) return next(); + + const ua = req.headers["user-agent"] || ""; + if (!CRAWLER_RE.test(ua)) return next(); + + const code = seg.slice(1); + try { + const [tokenData, ogImage] = await Promise.all([ + fetchKeepsTokenData(code), + resolveKeepsImageUrl(`https://oven.aesthetic.computer/preview/1200x630/$${code}.png`), + ]); + + // Get the HTML from the index function + if (!functions["index"]) return next(); + const event = toEvent(req); + const result = await functions["index"](event, { clientContext: {} }); + let html = result.body || ""; + + const title = `$${code}`; + const subdomain = isBuy ? "buy" : "keep"; + const description = buildKeepsDescription(tokenData, isBuy); + const permalink = `https://${subdomain}.kidlisp.com/$${code}`; + + html = html.replace(/]*\/>/, ``); + html = html.replace(/]*\/>/, ``); + html = html.replace(/]*\/>/, ``); + html = html.replace(/]*\/>/, ``); + html = html.replace(/]*\/>/, ``); + html = html.replace(/]*\/>/, ``); + html = html.replace(/]*\/>/, ``); + + res.set("Content-Type", "text/html; charset=utf-8"); + res.set("Cache-Control", "public, max-age=3600"); + return res.status(200).send(html); + } catch (err) { + console.error("[keeps-social] error:", err); + return next(); + } +} + +async function fetchKeepsTokenData(code) { + const contract = "KT1Q1irsjSZ7EfUN4qHzAB2t7xLBPsAWYwBB"; + const query = `query { token(where: { fa_contract: { _eq: "${contract}" } name: { _eq: "$${code}" } }) { token_id name thumbnail_uri } listing_active(where: { fa_contract: { _eq: "${contract}" } token: { name: { _eq: "$${code}" } } } order_by: { price_xtz: asc } limit: 1) { price_xtz seller_address } }`; + const r = await fetch(OBJKT_GRAPHQL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }) }); + if (!r.ok) return null; + const json = await r.json(); + const tokens = json?.data?.token || []; + if (tokens.length === 0) return null; + return { token: tokens[0], listing: (json?.data?.listing_active || [])[0] || null }; +} + +function buildKeepsDescription(tokenData, isBuy) { + if (!tokenData) return isBuy ? "Buy KidLisp generative art on Tezos." : "KidLisp generative art preserved on Tezos."; + const { listing } = tokenData; + if (listing) { + const xtz = (Number(listing.price_xtz) / 1_000_000).toFixed(2); + return isBuy ? `Buy now — ${xtz} XTZ | KidLisp generative art on Tezos` : `For Sale — ${xtz} XTZ | KidLisp generative art on Tezos`; + } + return isBuy ? "Buy KidLisp generative art on Tezos." : "KidLisp generative art preserved on Tezos."; +} + +function escapeAttr(str) { + return str.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} + +async function resolveKeepsImageUrl(url) { + try { + const r = await fetch(url, { method: "HEAD", redirect: "follow" }); + if (r.ok && r.url) return r.url; + } catch (e) { + console.error("[keeps-social] image resolve error:", e); + } + return url; +} + +app.use(keepsSocialMiddleware); + // SPA fallback → index function app.use(async (req, res) => { if (functions["index"]) { diff --git a/system/netlify/edge-functions/os-release-upload.js b/system/netlify/edge-functions/os-release-upload.js index 3c9514505a..7ad23e91de 100644 --- a/system/netlify/edge-functions/os-release-upload.js +++ b/system/netlify/edge-functions/os-release-upload.js @@ -138,7 +138,7 @@ export default async (request) => { try { const connStr = Deno.env.get("MONGODB_CONNECTION_STRING"); if (!connStr) return null; - const { MongoClient } = await import("npm:mongodb@6"); + const { MongoClient } = await import("npm:mongodb@7"); const client = new MongoClient(connStr); await client.connect(); const dbName = Deno.env.get("MONGODB_NAME") || "aesthetic"; diff --git a/texput.log b/texput.log index 73bd075214..acf64221f3 100644 --- a/texput.log +++ b/texput.log @@ -1,18 +1,18 @@ -This is XeTeX, Version 3.141592653-2.6-0.999995 (TeX Live 2023) (preloaded format=xelatex 2026.3.17) 26 MAR 2026 12:00 +This is XeTeX, Version 3.141592653-2.6-0.999995 (TeX Live 2023) (preloaded format=xelatex 2026.3.29) 1 APR 2026 21:14 entering extended mode restricted \write18 enabled. %&-line parsing enabled. -**plork-cards.tex +**card.tex ! Emergency stop. -<*> plork-cards.tex - +<*> card.tex + *** (job aborted, file error in nonstop mode) Here is how much of TeX's memory you used: 2 strings out of 478682 - 21 string characters out of 5849006 + 14 string characters out of 5849006 1842018 words of memory out of 6000000 20219 multiletter control sequences out of 15000+600000 512287 words of font info for 32 fonts, out of 8000000 for 9000 diff --git a/vscode-extension/PLANS.md b/vscode-extension/PLANS.md new file mode 100644 index 0000000000..89651341ac --- /dev/null +++ b/vscode-extension/PLANS.md @@ -0,0 +1,136 @@ +# VSCode Extension: Dashboard & AT Firehose + +## Vision + +Replace the current "Source Changes" welcome panel with a **live dashboard** that acts as a firehose for all Aesthetic Computer platform activity — git changes, AT Protocol events, and Tangled knot repo activity — all in one view. + +## Current State + +The welcome panel (`extension.ts:966-1100`) is a simple git status viewer: +- Runs `git status --porcelain` + `git rev-parse --abbrev-ref HEAD` for the main repo + vault +- Displays file list with status badges (M/A/D/?) +- Click-to-open files in editor +- Debounced refresh on file changes (300ms) + +**Known bug:** `git rev-parse --abbrev-ref HEAD` fails on repos with no commits (e.g. freshly cloned submodules), showing a raw error in the UI instead of a graceful fallback. + +--- + +## Phase 1: Fix the Git Error + Improve Layout + +**Goal:** Make the existing git status panel work correctly and look better. + +- [ ] Handle `rev-parse` failure gracefully — show `(no commits)` or `(init)` instead of the raw error +- [ ] Improve the visual layout: tighter spacing, better typography, cleaner status badges +- [ ] Add a collapsible section header for "Source Changes" (so it can coexist with new sections) +- [ ] Add timestamp showing last refresh time + +## Phase 2: Dashboard Shell + +**Goal:** Transform the panel from a single-purpose git viewer into a multi-section dashboard. + +- [ ] Redesign the HTML structure with distinct dashboard sections: + 1. **Source Changes** — the existing git status (cleaned up from Phase 1) + 2. **AT Firehose** — live AT Protocol events (Phase 3) + 3. **Tangled** — knot/repo activity (Phase 4) +- [ ] Add a sticky nav/header with section toggles (show/hide each section) +- [ ] Support auto-scroll vs. paused mode (like a terminal — new events stream in, but scrolling up pauses the feed) +- [ ] Add theme-aware styling for all new sections (dark/light support already exists) +- [ ] Consider making this a sidebar webview instead of (or in addition to) an editor panel + +## Phase 3: AT Protocol Firehose + +**Goal:** Show live AT Protocol activity happening on the Aesthetic Computer PDS. + +The AC platform already has deep atproto integration: +- PDS at `at.aesthetic.computer` +- Lexicons: `computer.aesthetic.tape`, `computer.aesthetic.mood`, `computer.aesthetic.painting`, etc. +- Existing scripts in `at/scripts/atproto/` +- News posted to atproto via `system/backend/news-atproto.mjs` +- Tapes synced to atproto via `system/backend/tape-atproto.mjs` + +### What to show in the firehose: +- [ ] **New tapes** — when users record/publish tapes (video content) +- [ ] **Moods** — mood updates from users (`computer.aesthetic.mood`) +- [ ] **Paintings** — shared paintings (`computer.aesthetic.painting`) +- [ ] **News posts** — new news articles published +- [ ] **Handle registrations/updates** — new users or handle changes +- [ ] **Standard site updates** — page edits, new pages + +### Implementation options: +- **Option A: WebSocket relay** — Add a firehose WebSocket endpoint to the session server or a new lightweight service that subscribes to the PDS `com.atproto.sync.subscribeRepos` firehose and relays events to the extension. +- **Option B: Polling** — Periodically query `com.atproto.repo.listRecords` for each collection on the PDS. Simpler but less real-time. +- **Option C: Direct firehose** — Connect the extension directly to `wss://at.aesthetic.computer/xrpc/com.atproto.sync.subscribeRepos` and decode the CBOR/CAR stream in the extension itself. Most direct but complex. + +### Recommended: Option A (WebSocket relay) +The extension already has a WebSocket connection to the session server (`extension.ts:2132`). We can add a `firehose:event` message type that the session server relays from the PDS subscription. This keeps the extension lightweight and reuses existing infrastructure. + +### Event display format: +Each event in the feed should show: +- Timestamp +- Event type icon/badge (tape, mood, painting, news, etc.) +- Actor handle (clickable — opens their AC profile) +- Brief content preview (tape thumbnail, mood text, painting preview, etc.) +- Link to view the full content on aesthetic.computer + +## Phase 4: Tangled Knot Activity + +**Goal:** Show activity from the Tangled knot (AT Protocol-native git hosting). + +The AC repo is mirrored on Tangled at `tangled.org/aesthetic.computer/core`. The knot server runs on the same PDS droplet (`knot.aesthetic.computer`). + +### What to show: +- [ ] **Commits** — recent commits pushed to the Tangled knot +- [ ] **Issues/PRs** — if Tangled supports these (check API) +- [ ] **Stars/forks** — social activity on the repo +- [ ] **Cross-reference with GitHub** — show if a commit exists on both GitHub and Tangled + +### Implementation: +- [ ] Research Tangled API (likely atproto-based — check `tangled.org` for API docs) +- [ ] The knot server is at `knot.aesthetic.computer` — check what XRPC endpoints it exposes +- [ ] Display as a feed of events similar to the AT firehose section +- [ ] Include both the main `aesthetic-computer` repo and any other knot-hosted repos + +## Phase 5: Polish & Interaction + +- [ ] Add filtering — filter firehose by event type, user, time range +- [ ] Add notification badges on the sidebar icon when new events arrive +- [ ] Keyboard shortcuts for navigating the dashboard +- [ ] Click-through actions: clicking a tape opens it, clicking a commit shows the diff, etc. +- [ ] Sound/visual ping option for specific event types (e.g., new user signups) +- [ ] Consider a compact "ticker" mode that shows a single scrolling line of recent events in the status bar + +--- + +## Architecture Notes + +### Data flow +``` +PDS (at.aesthetic.computer) + └─ com.atproto.sync.subscribeRepos (firehose) + └─ Session Server (relay) + └─ WebSocket → VSCode Extension + └─ Dashboard Webview + +Knot Server (knot.aesthetic.computer) + └─ XRPC / atproto API + └─ Session Server or direct polling + └─ WebSocket → VSCode Extension + └─ Dashboard Webview + +Local Git Repos + └─ git status --porcelain (child_process) + └─ Extension host + └─ postMessage → Dashboard Webview +``` + +### Key files to modify +- `vscode-extension/extension.ts` — welcome panel HTML + git status logic +- `session-server/session.mjs` — add firehose relay endpoint +- `vscode-extension/embedded.js` — may need message bridging updates +- `vscode-extension/package.json` — new commands, settings + +### Dependencies to consider +- `@atproto/api` — already in `system/package.json` +- CBOR decoding for firehose (if doing direct connection) +- The extension currently bundles with esbuild — any new deps need to be bundleable diff --git a/vscode-extension/extension.ts b/vscode-extension/extension.ts index 45b55bced0..9fd558aed6 100644 --- a/vscode-extension/extension.ts +++ b/vscode-extension/extension.ts @@ -861,9 +861,8 @@ async function activate(context: vscode.ExtensionContext): Promise { ), ); - // ✦ Git Status Welcome Panel - // Runs `git status --porcelain` directly via child_process for maximum speed. - // Shows changes for both aesthetic-computer and aesthetic-computer-vault. + // ✦ Dashboard — Git Status + AT Protocol Firehose + Tangled Knot Activity + // Multi-section live dashboard for all Aesthetic Computer platform activity. interface GitRepoStatus { name: string; @@ -873,6 +872,28 @@ async function activate(context: vscode.ExtensionContext): Promise { error?: string; } + interface FirehoseEvent { + id: string; + type: string; // tape, mood, painting, news, handle, piece, kidlisp + action: string; // create, update, delete + handle?: string; + did?: string; + summary: string; + timestamp: string; + url?: string; + } + + interface TangledEvent { + id: string; + type: string; // push, issue, star, fork + repo: string; + author?: string; + summary: string; + timestamp: string; + commitHash?: string; + url?: string; + } + // Detect current VS Code theme kind function getVSCodeThemeKind(): 'dark' | 'light' { const themeName = vscode.window.activeColorTheme.name || ''; @@ -896,14 +917,22 @@ async function activate(context: vscode.ExtensionContext): Promise { }); } - // Get git status for a repo directory + // Get git status for a repo directory (with graceful rev-parse fallback) async function getGitStatus(repoPath: string, name: string): Promise { try { - const [statusOut, branchOut] = await Promise.all([ - gitExec('status --porcelain', repoPath), - gitExec('rev-parse --abbrev-ref HEAD', repoPath), - ]); - const branch = branchOut.trim(); + const statusOut = await gitExec('status --porcelain', repoPath); + let branch = '(init)'; + try { + branch = (await gitExec('rev-parse --abbrev-ref HEAD', repoPath)).trim(); + } catch { + // No commits yet — rev-parse fails on fresh repos, use symbolic-ref + try { + const ref = (await gitExec('symbolic-ref --short HEAD', repoPath)).trim(); + branch = ref || '(init)'; + } catch { + branch = '(no commits)'; + } + } const files = statusOut.split('\n').filter(Boolean).map(line => ({ status: line.substring(0, 2).trim(), file: line.substring(3), @@ -914,86 +943,301 @@ async function activate(context: vscode.ExtensionContext): Promise { } } + // Get recent git log for a repo + async function getGitLog(repoPath: string, count: number = 8): Promise<{ hash: string; subject: string; author: string; date: string }[]> { + try { + const logOut = await gitExec(`log --oneline --format="%h|%s|%an|%cr" -${count}`, repoPath); + return logOut.split('\n').filter(Boolean).map(line => { + const [hash, subject, author, date] = line.split('|'); + return { hash, subject, author, date }; + }); + } catch { + return []; + } + } + // Gather status for all repos - async function getAllGitStatus(): Promise { + async function getAllGitStatus(): Promise<{ repos: GitRepoStatus[]; logs: { name: string; commits: { hash: string; subject: string; author: string; date: string }[] }[] }> { await _modulesReady; const repos: { name: string; root: string }[] = []; - // Find aesthetic-computer workspace root const wsFolder = vscode.workspace.workspaceFolders?.[0]; if (wsFolder && fs && path) { const acRoot = wsFolder.uri.fsPath; - - // Verify main repo is a git repo try { - const mainGitDir = path.join(acRoot, '.git'); - if (fs.existsSync(mainGitDir)) { + if (fs.existsSync(path.join(acRoot, '.git'))) { repos.push({ name: 'aesthetic-computer', root: acRoot }); } } catch {} - - // Check for vault as a subdirectory try { const vaultPath = path.join(acRoot, 'aesthetic-computer-vault'); - const stat = fs.statSync(vaultPath); - if (stat.isDirectory()) { - const gitDir = path.join(vaultPath, '.git'); - if (fs.existsSync(gitDir)) { - repos.push({ name: 'vault', root: vaultPath }); - } + if (fs.statSync(vaultPath).isDirectory() && fs.existsSync(path.join(vaultPath, '.git'))) { + repos.push({ name: 'vault', root: vaultPath }); } } catch {} } - return Promise.all(repos.map(r => getGitStatus(r.root, r.name))); + const [statuses, logs] = await Promise.all([ + Promise.all(repos.map(r => getGitStatus(r.root, r.name))), + Promise.all(repos.map(async r => ({ name: r.name, commits: await getGitLog(r.root) }))), + ]); + return { repos: statuses, logs }; + } + + // AT Protocol firehose — poll PDS for recent records across all collections + const AT_COLLECTIONS = [ + { collection: 'computer.aesthetic.tape', label: 'Tape', icon: '\u{1F3AC}' }, + { collection: 'computer.aesthetic.mood', label: 'Mood', icon: '\u{1F30A}' }, + { collection: 'computer.aesthetic.painting', label: 'Painting', icon: '\u{1F3A8}' }, + { collection: 'computer.aesthetic.news', label: 'News', icon: '\u{1F4F0}' }, + { collection: 'computer.aesthetic.piece', label: 'Piece', icon: '\u{1F9E9}' }, + { collection: 'computer.aesthetic.kidlisp', label: 'KidLisp', icon: '\u{1F308}' }, + ]; + const PDS_URL = 'https://at.aesthetic.computer'; + const TANGLED_KNOT_URL = 'https://knot.aesthetic.computer'; + + let firehoseEvents: FirehoseEvent[] = []; + let tangledEvents: TangledEvent[] = []; + let firehoseSeenIds = new Set(); + let tangledSeenIds = new Set(); + + async function fetchFirehoseEvents(): Promise { + await _modulesReady; + if (!http) return []; + const events: FirehoseEvent[] = []; + + // Fetch recent records from each collection on the PDS + // We list records from the guest/system DID to show platform-wide activity + for (const col of AT_COLLECTIONS) { + try { + const url = `${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=5`; + // Instead, list records for known DIDs — use repo.listRecords with a broader approach + // For now, use listRepos to discover DIDs, then sample records + const reposJson = await httpGetJson(`${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=20`); + if (!reposJson?.repos) continue; + + for (const repo of reposJson.repos.slice(0, 5)) { + try { + const recordsJson = await httpGetJson( + `${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(repo.did)}&collection=${encodeURIComponent(col.collection)}&limit=3&reverse=true` + ); + if (!recordsJson?.records) continue; + for (const rec of recordsJson.records) { + const id = rec.uri || `${repo.did}:${col.collection}:${rec.cid}`; + if (firehoseSeenIds.has(id)) continue; + firehoseSeenIds.add(id); + + const val = rec.value || {}; + let summary = val.title || val.text || val.name || val.code || val.headline || col.label; + if (summary.length > 80) summary = summary.substring(0, 77) + '...'; + + events.push({ + id, + type: col.label.toLowerCase(), + action: 'create', + did: repo.did, + handle: val.handle || repo.did.substring(0, 20), + summary: `${col.icon} ${summary}`, + timestamp: val.createdAt || val.when || new Date().toISOString(), + url: val.uri || undefined, + }); + } + } catch {} + } + } catch {} + } + + // Sort by timestamp descending + events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + return events.slice(0, 50); + } + + // Simple HTTPS GET → JSON helper using Node http/https + function httpGetJson(url: string): Promise { + return new Promise((resolve) => { + try { + const https = require('https'); + const req = https.get(url, { timeout: 8000 }, (res: any) => { + let data = ''; + res.on('data', (chunk: string) => { data += chunk; }); + res.on('end', () => { + try { resolve(JSON.parse(data)); } catch { resolve(null); } + }); + }); + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + } catch { resolve(null); } + }); + } + + async function fetchTangledEvents(): Promise { + await _modulesReady; + const events: TangledEvent[] = []; + + // Query the Tangled knot server for recent repo activity + try { + // Try the knot's repo endpoint for recent activity + const repoInfo = await httpGetJson(`${TANGLED_KNOT_URL}/aesthetic-computer.git/info/refs?service=git-upload-pack`); + // The knot exposes a REST-ish API — try listing recent commits via the appview + const tangledCommits = await httpGetJson(`https://tangled.org/api/repos/aesthetic.computer/core/commits?limit=10`); + if (tangledCommits && Array.isArray(tangledCommits)) { + for (const commit of tangledCommits) { + const id = commit.sha || commit.hash || commit.id; + if (!id || tangledSeenIds.has(id)) continue; + tangledSeenIds.add(id); + events.push({ + id, + type: 'push', + repo: 'aesthetic.computer/core', + author: commit.author?.name || commit.author || 'unknown', + summary: commit.message || commit.subject || '(no message)', + timestamp: commit.date || commit.timestamp || new Date().toISOString(), + commitHash: id.substring(0, 7), + url: `https://tangled.org/aesthetic.computer/core/commit/${id}`, + }); + } + } + } catch {} + + // Also try the git log locally if the tangled remote is configured + try { + const wsFolder = vscode.workspace.workspaceFolders?.[0]; + if (wsFolder && path) { + const acRoot = wsFolder.uri.fsPath; + // Check if tangled remote exists + const remotes = await gitExec('remote -v', acRoot); + if (remotes.includes('tangled') || remotes.includes('knot')) { + const remoteName = remotes.includes('tangled') ? 'tangled' : 'knot'; + try { + await gitExec(`fetch ${remoteName} --no-tags`, acRoot); + } catch {} + const logOut = await gitExec(`log ${remoteName}/main --oneline --format="%h|%s|%an|%cr" -10`, acRoot); + for (const line of logOut.split('\n').filter(Boolean)) { + const [hash, subject, author, date] = line.split('|'); + const id = `tangled:${hash}`; + if (tangledSeenIds.has(id)) continue; + tangledSeenIds.add(id); + events.push({ + id, + type: 'push', + repo: 'aesthetic.computer/core', + author, + summary: subject, + timestamp: date, + commitHash: hash, + url: `https://tangled.org/aesthetic.computer/core`, + }); + } + } + } + } catch {} + + return events; + } + + // Send all dashboard data to the welcome panel + let dashboardPending = false; + async function sendDashboardData() { + if (!welcomePanel || dashboardPending) return; + dashboardPending = true; + try { + const gitData = await getAllGitStatus(); + if (welcomePanel) { + welcomePanel.webview.postMessage({ command: 'gitStatus', repos: gitData.repos, logs: gitData.logs }); + } + } finally { + dashboardPending = false; + } + } + + // Fetch and send firehose data (separate from git for independent refresh) + let firehosePending = false; + async function sendFirehoseData() { + if (!welcomePanel || firehosePending) return; + firehosePending = true; + try { + const events = await fetchFirehoseEvents(); + if (events.length > 0) { + firehoseEvents = events; + } + if (welcomePanel) { + welcomePanel.webview.postMessage({ command: 'firehose', events: firehoseEvents }); + } + } finally { + firehosePending = false; + } } - // Send git status update to the welcome panel - let gitStatusPending = false; - async function sendGitStatusToPanel() { - if (!welcomePanel || gitStatusPending) return; - gitStatusPending = true; + // Fetch and send Tangled data + let tangledPending = false; + async function sendTangledData() { + if (!welcomePanel || tangledPending) return; + tangledPending = true; try { - const statuses = await getAllGitStatus(); + const events = await fetchTangledEvents(); + if (events.length > 0) { + tangledEvents = events; + } if (welcomePanel) { - welcomePanel.webview.postMessage({ command: 'gitStatus', repos: statuses }); + welcomePanel.webview.postMessage({ command: 'tangled', events: tangledEvents }); } } finally { - gitStatusPending = false; + tangledPending = false; } } - function getWelcomePanelHtml(): string { + function getDashboardHtml(): string { const theme = getVSCodeThemeKind(); const c = theme === 'light' ? { bg: '#fcf7c5', fg: '#281e5a', fgMuted: '#907070', fgDim: '#b0a080', accent: '#387adf', added: '#1a7f37', modified: '#9a6700', deleted: '#cf222e', untracked: '#6e7781', - fileBg: '#f6f0d0', fileBorder: '#e8e0b0', hoverBg: '#efe8c0', repoBg: '#f0e8b8', headerBg: '#e8dfa0' } + fileBg: '#f6f0d0', fileBorder: '#e8e0b0', hoverBg: '#efe8c0', repoBg: '#f0e8b8', headerBg: '#e8dfa0', + sectionBg: '#f2ecc0', badgeBg: '#e0d8a0', tapeBg: '#e8f0d8', moodBg: '#d8e8f0', paintBg: '#f0d8e8', + newsBg: '#e8e0d8', commitBg: '#e0e8d8', liveDot: '#1a7f37', onlineBg: '#d8f0d8' } : { bg: '#181318', fg: '#e0d0e0', fgMuted: '#807080', fgDim: '#504050', accent: '#a87090', added: '#3fb950', modified: '#d29922', deleted: '#f85149', untracked: '#606870', - fileBg: '#1e1820', fileBorder: '#2a2030', hoverBg: '#28202e', repoBg: '#201828', headerBg: '#1a1220' }; + fileBg: '#1e1820', fileBorder: '#2a2030', hoverBg: '#28202e', repoBg: '#201828', headerBg: '#1a1220', + sectionBg: '#1a1520', badgeBg: '#2a2030', tapeBg: '#1a2818', moodBg: '#182028', paintBg: '#281828', + newsBg: '#282018', commitBg: '#182818', liveDot: '#3fb950', onlineBg: '#1a2818' }; return ` - + -
- ✦ - Source Changes +
+ ✦ + Dashboard + live
-
-
✦ scanning...
+ +
+
Source 0
+
AT Firehose 0
+
Tangled 0
+
+ +
+
+
✦ scanning repos...
+
+
+ +
+
+ All + Tape + Mood + Painting + News + Piece + KidLisp +
+
+
✦ connecting to PDS...
+
+ +
+
+
✦ fetching knot activity...
+
+
+ `; } - // ✨ Welcome Panel — shows git status for aesthetic-computer + vault + // ✦ Dashboard Panel — git status + AT firehose + Tangled knot function showWelcomePanel() { if (welcomePanel) { welcomePanel.reveal(vscode.ViewColumn.One); @@ -1108,7 +1573,7 @@ async function activate(context: vscode.ExtensionContext): Promise { welcomePanel = vscode.window.createWebviewPanel( "aestheticWelcome", - "✦ Source Changes", + "✦ Dashboard", vscode.ViewColumn.One, { enableScripts: true } ); @@ -1116,8 +1581,13 @@ async function activate(context: vscode.ExtensionContext): Promise { welcomePanel.webview.onDidReceiveMessage( message => { switch (message.command) { + case 'requestDashboard': + sendDashboardData(); + sendFirehoseData(); + sendTangledData(); + return; case 'requestGitStatus': - sendGitStatusToPanel(); + sendDashboardData(); return; case 'openFile': if (message.root && message.file) { @@ -1126,11 +1596,16 @@ async function activate(context: vscode.ExtensionContext): Promise { const openPath = vscode.Uri.file(filePath); vscode.workspace.openTextDocument(openPath).then( doc => vscode.window.showTextDocument(doc), - () => {} // file might be deleted + () => {} ); } } return; + case 'openExternal': + if (message.url) { + vscode.env.openExternal(vscode.Uri.parse(message.url)); + } + return; } }, undefined, @@ -1139,19 +1614,36 @@ async function activate(context: vscode.ExtensionContext): Promise { welcomePanel.onDidDispose(() => { welcomePanel = null; + if (firehoseInterval) { clearInterval(firehoseInterval); firehoseInterval = undefined; } + if (tangledInterval) { clearInterval(tangledInterval); tangledInterval = undefined; } }); - welcomePanel.webview.html = getWelcomePanelHtml(); + welcomePanel.webview.html = getDashboardHtml(); + + // Send initial data + setTimeout(() => { + sendDashboardData(); + sendFirehoseData(); + sendTangledData(); + }, 100); - // Send initial git status after a brief delay for webview to initialize - setTimeout(() => sendGitStatusToPanel(), 100); + // Poll firehose every 30 seconds, Tangled every 60 seconds + firehoseInterval = setInterval(() => sendFirehoseData(), 30000); + tangledInterval = setInterval(() => sendTangledData(), 60000); } + let firehoseInterval: NodeJS.Timeout | undefined; + let tangledInterval: NodeJS.Timeout | undefined; + // Refresh welcome panel on theme change function refreshWelcomePanel() { if (welcomePanel) { - welcomePanel.webview.html = getWelcomePanelHtml(); - setTimeout(() => sendGitStatusToPanel(), 100); + welcomePanel.webview.html = getDashboardHtml(); + setTimeout(() => { + sendDashboardData(); + sendFirehoseData(); + sendTangledData(); + }, 100); } } @@ -1160,14 +1652,12 @@ async function activate(context: vscode.ExtensionContext): Promise { const wsFolder = vscode.workspace.workspaceFolders?.[0]; if (!wsFolder) return; - // Debounce: coalesce rapid file changes into one git status refresh let refreshTimer: NodeJS.Timeout | undefined; function debouncedRefresh() { if (refreshTimer) clearTimeout(refreshTimer); - refreshTimer = setTimeout(() => sendGitStatusToPanel(), 300); + refreshTimer = setTimeout(() => sendDashboardData(), 300); } - // Watch for all file changes in the workspace (covers both repos) const watcher = vscode.workspace.createFileSystemWatcher( new vscode.RelativePattern(wsFolder, '**/*') ); @@ -1176,7 +1666,6 @@ async function activate(context: vscode.ExtensionContext): Promise { watcher.onDidDelete(debouncedRefresh); context.subscriptions.push(watcher); - // Also refresh when documents are saved (catches staging operations) context.subscriptions.push( vscode.workspace.onDidSaveTextDocument(() => debouncedRefresh()) ); diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index d8793f187b..1fde3f4972 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "aesthetic-computer-code", - "version": "1.271.2", + "version": "1.272.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aesthetic-computer-code", - "version": "1.271.2", + "version": "1.272.0", "license": "None", "dependencies": { "acorn": "^8.15.0", diff --git a/vscode-extension/package.json b/vscode-extension/package.json index d913b1a7d1..00214fb03b 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -4,7 +4,7 @@ "displayName": "Aesthetic Computer", "icon": "resources/icon.png", "author": "Jeffrey Alan Scudder", - "version": "1.271.2", + "version": "1.272.0", "description": "Code, run, and publish your pieces. Includes Aesthetic Computer themes and KidLisp syntax highlighting.", "engines": { "vscode": "^1.105.0" @@ -183,7 +183,7 @@ }, { "command": "aestheticComputer.welcome", - "title": "Aesthetic Computer: Welcome ✨" + "title": "Aesthetic Computer: Dashboard ✦" }, { "command": "aestheticComputer.showOTADetails",