diff --git a/system/public/slab/index.html b/system/public/slab/index.html
index 19fa5bb8e3..e9024c0901 100644
--- a/system/public/slab/index.html
+++ b/system/public/slab/index.html
@@ -32,6 +32,7 @@
:root{color-scheme:light;--ink:#393047;--soft:#65546e;--purple:#79568b;--line:#ddcedc;--mono:"Berkeley Mono Variable",monospace}
*{box-sizing:border-box}body{margin:0;color:var(--ink);background:radial-gradient(ellipse 650px 520px at 95% 0%,#e4d4f780,transparent),radial-gradient(ellipse 600px 600px at 0% 8%,#d4eedb80,transparent),radial-gradient(circle,#ae8cb32b 1px,transparent 1px) 0 0/24px 24px,#faf3e8;font:16px/1.6 -apple-system,BlinkMacSystemFont,sans-serif}a{color:inherit;text-underline-offset:4px}a:hover{color:var(--purple)}a:focus-visible,summary:focus-visible{outline:3px solid var(--purple);outline-offset:5px}.masthead,main,footer{max-width:1008px;margin:auto;padding-left:24px;padding-right:24px}.masthead{padding-top:24px;display:flex;justify-content:space-between;font-size:14px}.masthead a{text-decoration:none}.hero{text-align:center;padding:64px 0 42px}.icon{display:block;width:144px;height:144px;border-radius:32px;margin:0 auto 20px;transform:rotate(-6deg);filter:drop-shadow(0 8px 0 #d9c6de)}h1{font:normal 96px/1 Pixel,monospace;color:var(--purple);text-shadow:3px 4px #e2cdea;margin:0 0 26px}.download{display:inline-flex;align-items:center;gap:20px;padding:13px 24px;background:var(--purple);color:white;border:2px solid #684478;border-radius:999px;box-shadow:0 5px #d2b6df;text-decoration:none;font-weight:600;transition:transform 120ms ease}.download:hover{color:white;transform:translateY(-2px)}.requirements{font-size:13px;color:var(--soft);margin:18px 0 0}.shot{margin:0}.shot img{display:block;width:100%;height:auto;border-radius:18px;border:1px solid var(--line);box-shadow:0 6px #ddcfdf}.setup{max-width:680px;margin:42px auto 0}details{border-top:1px solid var(--line);padding:16px 0}summary{cursor:pointer;font-weight:600}details p{color:var(--soft);margin:14px 0}code{font-family:var(--mono);font-size:.88em}.hash{overflow-wrap:anywhere;font-size:11px}footer{display:flex;flex-wrap:wrap;gap:20px;font-size:13px;color:var(--soft);padding-top:32px;padding-bottom:36px}footer a:first-child{margin-right:auto}@media(max-width:600px){.hero{padding:48px 0 32px}.icon{width:112px;height:112px;border-radius:26px}h1{font-size:80px}.masthead,main,footer{padding-left:20px;padding-right:20px}.requirements{font-size:12px}.setup{margin-top:30px}.shot img{border-radius:10px}}@media(prefers-reduced-motion:reduce){.download{transition:none}}
+
@@ -39,7 +40,7 @@
@@ -95,7 +96,9 @@
.then((data) => {
const latest = (data.versions || []).find((v) => v.version === data.latest);
if (!latest || !latest.dmg) return;
- button.href = latest.dmg;
+ // Through lith's counter, which redirects on to the CDN.
+ const file = latest.dmg.split('/').pop();
+ button.href = `/api/download?app=slab&file=${encodeURIComponent(file)}`;
const mb = (latest.size / (1024 * 1024)).toFixed(1);
if (meta) meta.textContent = `${latest.version} · ${mb} MB`;
if (hash && latest.sha256) hash.textContent = `sha256 ${latest.sha256}`;
diff --git a/toolchain/analytics/downloads-report.mjs b/toolchain/analytics/downloads-report.mjs
new file mode 100644
index 0000000000..fe6bb603bd
--- /dev/null
+++ b/toolchain/analytics/downloads-report.mjs
@@ -0,0 +1,29 @@
+#!/usr/bin/env node
+// Run on Lith: cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/downloads-report.mjs --days 30 --exclude ,
+// Direct DMG downloads counted by /api/download (system/netlify/functions/download.mjs).
+import { connect, closePool } from "../../system/backend/database.mjs";
+const args = process.argv.slice(2);
+const value = name => args[args.indexOf(name) + 1];
+const days = args.includes("--days") ? Number(value("--days")) : 30;
+if (!Number.isFinite(days) || days <= 0 || days > 3650) throw new Error("Use --days 1..3650");
+const exclude = args.includes("--exclude") ? value("--exclude").split(",").filter(Boolean) : [];
+const start = new Date(Date.now() - days * 86400000);
+const { db } = await connect();
+try {
+ const rows = await db.collection("downloads").aggregate([
+ { $match: { at: { $gte: start } } },
+ { $group: {
+ _id: { app: "$app", version: "$version", automated: "$automated", self: { $in: ["$hash", exclude] } },
+ downloads: { $sum: 1 }, places: { $addToSet: "$hash" },
+ countries: { $push: "$country" }, platforms: { $push: "$platform" },
+ first: { $min: "$at" }, last: { $max: "$at" },
+ } },
+ ], { maxTimeMS: 20000 }).toArray();
+ const first = await db.collection("downloads").find({}, { projection: { at: 1 } }).sort({ at: 1 }).limit(1).next();
+ const tally = list => list.reduce((out, key) => ({ ...out, [key ?? "?"]: (out[key ?? "?"] || 0) + 1 }), {});
+ console.log(JSON.stringify({ format: "ac.downloads.v1", start, end: new Date(),
+ earliestDownload: first?.at || null, excluded: exclude.length,
+ rows: rows.map(({ places, countries, platforms, ...row }) => ({ ...row,
+ places: places.length, countries: tally(countries), platforms: tally(platforms) })),
+ }, null, 2));
+} finally { await closePool(); }
diff --git a/toolchain/mcp/analytics-mcp.mjs b/toolchain/mcp/analytics-mcp.mjs
index 50d55df0ca..4fcca6eae4 100644
--- a/toolchain/mcp/analytics-mcp.mjs
+++ b/toolchain/mcp/analytics-mcp.mjs
@@ -1,5 +1,5 @@
#!/usr/bin/env node
-// analytics-mcp.mjs — network visits and App Store downloads as tools.
+// analytics-mcp.mjs — network visits, direct DMG downloads and App Store downloads as tools.
//
// visits_report runs toolchain/analytics/visits-report.mjs on lith over ssh
// (see toolchain/analytics/VISITS.md for what a "visit" can and cannot mean)
@@ -33,6 +33,7 @@ const APPS = {
fingerquilt: "1153451161",
softwallpaper: "1390237091",
aestheticcomputer: "6450940883",
+ aesel: "6812823093",
};
const ACTIONS = ["download_clicked", "media_started", "link_followed", "canvas_interacted",
"round_started", "round_completed", "match_completed", "mime_interact", "mime_scroll_feed", "mime_original_open"];
@@ -80,6 +81,36 @@ async function visitsReport({ hours = 48, scope = "all", end } = {}) {
};
}
+// ⬇️ Direct downloads
+
+// The Slab and Aesel DMGs go through lith's /api/download counter. Rows keep
+// only a keyed hash of the address, so leaving the fleet out means asking
+// lith for this machine's own hash and passing it (and any others) along.
+async function directDownloads({ days = 30, exclude = [], excludeSelf = true } = {}) {
+ days = Number(days);
+ if (!Number.isFinite(days) || days <= 0 || days > 3650) throw new Error("days must be 1..3650");
+ const hashes = [...exclude];
+ if (excludeSelf) hashes.push((await (await fetch("https://aesthetic.computer/api/download?whoami=1")).json()).hash);
+ if (hashes.some((h) => !/^[a-f0-9]{16}$/.test(h))) throw new Error("exclude takes 16-hex hashes from /api/download?whoami=1");
+ const remote = `cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/downloads-report.mjs --days ${days}${hashes.length ? ` --exclude ${hashes.join(",")}` : ""}`;
+ const { stdout } = await pexec("ssh", ["-i", SSH_KEY, "-o", "ConnectTimeout=10", LITH, remote],
+ { timeout: 90_000, maxBuffer: 32 * 1024 * 1024 });
+ const report = JSON.parse(stdout.slice(stdout.indexOf("{")));
+ const apps = {};
+ for (const row of report.rows) {
+ const app = apps[row._id.app] ??= { downloads: 0, places: 0, self: 0, automated: 0, byVersion: {}, countries: {}, platforms: {} };
+ if (row._id.automated) { app.automated += row.downloads; continue; }
+ if (row._id.self) { app.self += row.downloads; continue; }
+ app.downloads += row.downloads;
+ app.places += row.places;
+ app.byVersion[row._id.version] = (app.byVersion[row._id.version] || 0) + row.downloads;
+ for (const [k, v] of Object.entries(row.countries)) app.countries[k] = (app.countries[k] || 0) + v;
+ for (const [k, v] of Object.entries(row.platforms)) app.platforms[k] = (app.platforms[k] || 0) + v;
+ }
+ return { window: { start: report.start, end: report.end, earliestDownload: report.earliestDownload },
+ note: "downloads/places leave out automated and excluded (self) traffic; places = distinct address hashes per version, summed", apps };
+}
+
// 📱 App Store
let ascKey;
@@ -163,6 +194,18 @@ const TOOLS = [
},
},
},
+ {
+ name: "direct_downloads",
+ description: "Direct DMG downloads of Slab and Aesel counted by lith's /api/download redirect (counting began 2026-09-28): per app downloads, distinct places, versions, countries, platforms. Leaves out bots and, by default, this machine's own network.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ days: { type: "number", description: "How many days back (default 30)" },
+ exclude: { type: "array", items: { type: "string" }, description: "More address hashes to leave out (from /api/download?whoami=1 on other fleet machines)" },
+ excludeSelf: { type: "boolean", description: "Leave out this machine's own network (default true)" },
+ },
+ },
+ },
{
name: "app_downloads",
description: "App Store downloads per app from Apple's App Downloads Standard analytics report: first-time downloads plus a daily breakdown by download type and device.",
@@ -178,6 +221,7 @@ const TOOLS = [
async function callTool(name, args = {}) {
const result = name === "visits_report" ? await visitsReport(args)
+ : name === "direct_downloads" ? await directDownloads(args)
: name === "app_downloads" ? await appDownloads(args)
: (() => { throw new Error(`unknown tool ${name}`); })();
return [{ type: "text", text: JSON.stringify(result, null, 2) }];
@@ -215,4 +259,4 @@ async function handleMessage(message) {
const port = httpPort(process.argv, 0);
if (port) serveHttp({ handleMessage, port, banner: "📈 analytics-mcp shared daemon" });
-else serveStdio({ handleMessage, banner: "📈 analytics-mcp started (visits_report, app_downloads)" });
+else serveStdio({ handleMessage, banner: "📈 analytics-mcp started (visits_report, direct_downloads, app_downloads)" });