diff --git a/system/backend/metrics-daily.mjs b/system/backend/metrics-daily.mjs index d9f75879a3..4e884e997b 100644 --- a/system/backend/metrics-daily.mjs +++ b/system/backend/metrics-daily.mjs @@ -6,6 +6,8 @@ // back as the raw data still reaches. App Store numbers live with Apple and // are read by toolchain/mcp/analytics-mcp.mjs, not here. +import { VISIT_ACTIONS, VISIT_DEPTHS } from "../public/aesthetic.computer/lib/visit-model.mjs"; + export const METRICS_DAILY_COLLECTION = "metrics-daily"; const DAY = 86400000; const BACKFILL_DAYS = 34; @@ -17,12 +19,16 @@ const tally = (rows, fields) => Object.fromEntries(rows.map(row => [row._id, async function foldDay(db, start) { const end = new Date(+start + DAY), day = start.toISOString().slice(0, 10); const flag = field => ({ $sum: { $cond: [`$${field}`, 1, 0] } }); + const audienceFlag = condition => ({ $sum: { $cond: [{ $and: [{ $not: ["$automated"] }, condition] }, 1, 0] } }); const visits = await db.collection("network-visits").aggregate([ { $match: { startedAt: { $gte: start, $lt: end } } }, { $group: { _id: "$property", visits: { $sum: { $cond: ["$automated", 0, 1] } }, interacted: { $sum: { $cond: [{ $and: ["$interacted", { $not: ["$automated"] }] }, 1, 0] } }, engaged: { $sum: { $cond: [{ $and: ["$engaged", { $not: ["$automated"] }] }, 1, 0] } }, + actionVisits: audienceFlag({ $or: VISIT_ACTIONS.map(action => ({ $eq: [`$actions.${action}`, true] })) }), + ...Object.fromEntries(VISIT_ACTIONS.map(action => [action, audienceFlag(`$actions.${action}`)])), + ...Object.fromEntries(VISIT_DEPTHS.map(seconds => [`interacted${seconds}`, audienceFlag({ $and: ["$interacted", { $gte: ["$activeSeconds", seconds] }] })])), automated: flag("automated") } }, ], { maxTimeMS: 20000 }).toArray(); @@ -39,7 +45,7 @@ async function foldDay(db, start) { ], { maxTimeMS: 20000 }).toArray(); return { day, generatedAt: new Date(), - visits: tally(visits, ["visits", "interacted", "engaged", "automated"]), + visits: tally(visits, ["visits", "interacted", "engaged", "automated", "actionVisits", ...VISIT_ACTIONS, ...VISIT_DEPTHS.map(seconds => `interacted${seconds}`)]), downloads: tally(downloads, ["downloads", "places", "automated"]), opens: tally(opens, ["active", "opens", "fresh"]) }; } diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 80275781e0..6494383eb0 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -1,4 +1,5 @@ import { NOPAINT_SESSION_SEED_KEY, noPaintHistoryTarget } from "./lib/nopaint-navigation.mjs"; +import { visitMediaAction } from "./lib/visit-model.mjs"; // 💻 BIOS @@ -1343,6 +1344,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { // 🖥️ Our main display surface. (Software Renderer) const canvas = document.createElement("canvas"); + canvas.dataset.acVisitCanvas = ""; const ctx = canvas.getContext("2d", { willReadFrequently: true }); // 🖥️🔌 WebGPU 2D Renderer Canvas @@ -15936,6 +15938,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (mediaRecorder) { console.log("🎬 Setting up MediaRecorder callbacks"); mediaRecorder.onstart = function () { + window.acVisits?.action("recording_started"); // mediaRecorderResized = false; mediaRecorderStartTime = performance.now(); captureSession = { @@ -18421,6 +18424,11 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } + if (type === "visit:action") { + window.acVisits?.action(content?.action); + return; + } + if (type === "analytics") { captureProductAction(content?.action); return; @@ -18439,6 +18447,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { const result = await response.json(); if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); activeTapeDraft = null; + window.acVisits?.action(visitMediaAction("zip", result)); activeTapeDraftXHR = null; send({ type: "tape:posted", content: { result: "success", ...result } }); } catch (error) { @@ -21420,6 +21429,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (addedData.code) { console.log(`📼 Tape code: !${addedData.code}`); captureMediaCreated(ext, userMedia); + window.acVisits?.action(visitMediaAction(ext, addedData)); // Send success callback with code console.log(`📼 Sending ${callbackMessage} event with code:`, addedData.code); @@ -21593,6 +21603,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { const added = await fetch("api/track-media", options); if (!added.ok) { console.error(`❌ track-media HTTP error: ${added.status} ${added.statusText}`); + throw new Error(`track-media HTTP ${added.status}`); } addedData = await added.json(); console.log("🗞️ track-media response:", addedData, "status:", added.status); @@ -21611,6 +21622,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (addedData.paintingId) data.paintingId = addedData.paintingId; captureMediaCreated(ext, userMedia); + window.acVisits?.action(visitMediaAction(ext, addedData)); if (!userMedia && (ext === "mjs" || ext === "lisp")) { data.url = diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index ba70ca083a..40f068cc97 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -715,6 +715,7 @@ function commitProposal(api) { persistPiece(api); persistPainting(api); recordDecision(api, "paint", layer.id); + api.send?.({ type: "visit:action", content: { action: "painting_edited" } }); clearProposal(api); chooseProposal(api); publishTestState(); diff --git a/system/public/aesthetic.computer/disks/notepat.mjs b/system/public/aesthetic.computer/disks/notepat.mjs index 2f48497241..8532377e54 100644 --- a/system/public/aesthetic.computer/disks/notepat.mjs +++ b/system/public/aesthetic.computer/disks/notepat.mjs @@ -2156,7 +2156,7 @@ function sim({ sound, simCount, num, clock, painting }) { const key = event.key; if (key) { const note = key.toLowerCase(); - const started = startButtonNote(note, 127, autopatApi); + const started = startButtonNote(note, 127, autopatApi, true); if (started) { autopatState.activeNote = note; autopatState.activeNoteEnd = now + durationSeconds; @@ -2197,7 +2197,7 @@ function sim({ sound, simCount, num, clock, painting }) { const current = song?.[songIndex]?.[0]; if (current) { const note = current.toLowerCase(); - const started = startButtonNote(note, 127, autopatApi); + const started = startButtonNote(note, 127, autopatApi, true); if (started) { autopatState.activeNote = note; autopatState.activeNoteEnd = now + noteSeconds; @@ -6407,7 +6407,7 @@ function applyPitchBendToNotes(noteKeys, { immediate = false } = {}) { }); } -function startButtonNote(note, velocity = 127, apiRef = null) { +function startButtonNote(note, velocity = 127, apiRef = null, automatic = false) { anyDown = true; perfStats.lastKeyTime = performance.now(); noteShake[note] = 3; // Trigger per-note typography shake @@ -6453,6 +6453,7 @@ function startButtonNote(note, velocity = 127, apiRef = null) { publishNotepatMidi("note_off", midi, 0, 0); } } + if (!automatic) apiRef?.send?.({ type: "visit:action", content: { action: "note_played" } }); return true; } @@ -6560,6 +6561,7 @@ function startButtonNote(note, velocity = 127, apiRef = null) { } } + if (!automatic) apiRef?.send?.({ type: "visit:action", content: { action: "note_played" } }); return true; } @@ -7548,6 +7550,7 @@ function act({ buttons[buttonNote].over = true; } + api.send?.({ type: "visit:action", content: { action: "note_played" } }); return true; }; @@ -8321,6 +8324,7 @@ function act({ pictureAdd(api, tone); udpServer?.send("tv", { note: buttonNote }); // Send udp message for note. } + api.send?.({ type: "visit:action", content: { action: "note_played" } }); } } diff --git a/system/public/aesthetic.computer/lib/visit-model.mjs b/system/public/aesthetic.computer/lib/visit-model.mjs index 9f9901ecc6..fb66dd71b5 100644 --- a/system/public/aesthetic.computer/lib/visit-model.mjs +++ b/system/public/aesthetic.computer/lib/visit-model.mjs @@ -37,7 +37,17 @@ export const VISIT_ACTIONS = Object.freeze([ "link_followed", "download_clicked", "canvas_interacted", "media_started", "round_started", "round_completed", "match_completed", "mime_interact", "mime_scroll_feed", "mime_original_open", + "note_played", "painting_edited", "recording_started", + "painting_saved", "tape_saved", ]); +export const VISIT_DEPTHS = Object.freeze([30, 60, 180, 600]); +// Public upload milestones require a returned record code, never a click. +export function visitMediaAction(extension, result) { + if (result?.error || typeof result?.code !== "string" || !result.code) return null; + if (extension === "png") return "painting_saved"; + if (["zip", "mp4", "webm"].includes(extension)) return "tape_saved"; + return null; +} export const ACTIVE_BUCKETS = Object.freeze([0, 10, 30, 60, 180, 600]); export const SURFACES = Object.freeze(["home", "play", "gallery", "read", "support", "other"]); export const INPUTS = Object.freeze(["pointer", "touch", "keyboard", "scroll", "gamepad"]); @@ -125,6 +135,10 @@ export function visitReportPipeline(start, end, byPeriod = false, scope = "studi : { surface: "$surface" }) }, visits: { $sum: 1 }, interacted: count("interacted"), engaged: count("engaged"), activeSecondsLowerBound: { $sum: "$activeSeconds" }, + actionVisits: { $sum: { $cond: [{ $or: VISIT_ACTIONS.map(action => ({ $eq: [`$actions.${action}`, true] })) }, 1, 0] } }, + ...Object.fromEntries(VISIT_DEPTHS.map(seconds => [`interacted${seconds}`, { + $sum: { $cond: [{ $and: ["$interacted", { $gte: ["$activeSeconds", seconds] }] }, 1, 0] }, + }])), ...Object.fromEntries(VISIT_ACTIONS.map(action => [action, count(`actions.${action}`)])), } }, { $sort: { "_id.property": 1, "_id.automated": 1, visits: -1 } }, diff --git a/system/public/aesthetic.computer/lib/visit-tracker.mjs b/system/public/aesthetic.computer/lib/visit-tracker.mjs index f21c6fc898..d2507eac45 100644 --- a/system/public/aesthetic.computer/lib/visit-tracker.mjs +++ b/system/public/aesthetic.computer/lib/visit-tracker.mjs @@ -92,7 +92,18 @@ export function startVisitTracker(win = window, doc = document) { // Typing in account/contact/editor fields is not collected as interaction. if (e.target?.closest?.("input,textarea,select,[contenteditable], [data-ac-no-track]")) return; interact(e.pointerType === "touch" ? "touch" : input); - if (e.target?.tagName === "CANVAS") action("canvas_interacted"); + // AC renders through pointer-transparent canvases. Only count contact + // inside its marked display, not DOM controls laid over it or prompt keys. + if (event === "pointerdown" || event === "touchstart") { + const point = e.touches?.[0] || e; + const display = doc.querySelector("canvas[data-ac-visit-canvas]"); + const rect = display?.getBoundingClientRect(); + const background = e.target === doc.body || e.target === doc.documentElement || + e.target?.id === "aesthetic-computer"; + if (e.target?.tagName === "CANVAS" || (background && rect && + point.clientX >= rect.left && point.clientX < rect.right && + point.clientY >= rect.top && point.clientY < rect.bottom)) action("canvas_interacted"); + } }, true); } on(win, "click", e => { diff --git a/system/public/network-privacy.html b/system/public/network-privacy.html index 4de7cad986..2b465a9e5a 100644 --- a/system/public/network-privacy.html +++ b/system/public/network-privacy.html @@ -5,14 +5,16 @@ Aesthetic network visit measurements

Website visit measurements

-

Updated September 23, 2026.

+

Updated September 30, 2026.

Our public websites send small visit summaries to Aesthetic Computer so we can understand which projects people open, interact with, and use.

A random identifier lasts for one page visit. It is held in memory, with no tracking cookie or persistent browser identifier. We do not connect visits across websites. Each summary contains the property, a broad page category, visible-time bucket, interaction types and a few action flags such as following -a link or completing a game round. The tracker does not send page text, full +a link, triggering a musical note, accepting a painting edit, starting a +recording, saving media or completing a game round. Actions are yes/no flags +per visit, not recordings of notes, drawings or clicks. The tracker does not send page text, full URLs, search queries, form contents, raw keys, pointer movements or account identity. Known automated browsers are counted separately.

The visit database retains records for 35 days. Network requests necessarily diff --git a/system/tests/human-fishery.test.mjs b/system/tests/human-fishery.test.mjs new file mode 100644 index 0000000000..df2ac0e995 --- /dev/null +++ b/system/tests/human-fishery.test.mjs @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { fisheryOptions, fisherySnapshot } from "../../toolchain/analytics/human-fishery.mjs"; + +const now = new Date("2026-09-30T23:00:00Z"); +test("fishery requires interaction, excludes automation and clients by default, and bounds queries", () => { + const options = fisheryOptions({ startedAfter: "2026-09-30T22:58:00Z" }, now); + assert.equal(options.match.automated, false); + assert.equal(options.match.interacted, true); + assert.ok(!options.match.property.$in.includes("false.work")); + assert.equal(options.match.startedAt.$gte.toISOString(), "2026-09-30T22:58:00.000Z"); + assert.equal(options.since.toISOString(), "2026-09-30T22:55:00.000Z"); + for (const args of [{minutes:0},{minutes:61},{limit:201},{limit:1.5},{scope:"typo"},{startedAfter:"bad"},{startedAfter:"2026-10-01"}]) + assert.throws(() => fisheryOptions(args, now)); +}); + +test("fishery uses temporary visit aliases and never exposes raw identifiers or unreviewed fields", () => { + const row = { _id: "private-visit-id", property: "aesthetic.computer", surface: "play", + startedAt: now, lastSeenAt: now, activeSeconds: 60, engaged: true, + actions: { note_played: true, private: true }, handle: "private-handle", ip: "private-address" }; + const options = fisheryOptions({limit:1}, now); + const snapshot = fisherySnapshot([row,row], options); + assert.equal(snapshot.truncated, true); + assert.equal(snapshot.fish.length, 1); + assert.deepEqual(snapshot.fish[0].actions, ["note_played"]); + assert.ok(!JSON.stringify(snapshot).includes("private")); + assert.equal(snapshot.fish[0].fish, fisherySnapshot([row], options).fish[0].fish); + assert.notEqual(snapshot.fish[0].fish, fisherySnapshot([{...row,_id:"another-visit"}],options).fish[0].fish); + assert.notEqual(snapshot.fish[0].fish, fisherySnapshot([row],fisheryOptions({},new Date("2026-10-01T00:00:00Z"))).fish[0].fish); +}); diff --git a/system/tests/visit-tracking-browser.test.mjs b/system/tests/visit-tracking-browser.test.mjs index 4b4f115386..3f610afffc 100644 --- a/system/tests/visit-tracking-browser.test.mjs +++ b/system/tests/visit-tracking-browser.test.mjs @@ -63,3 +63,56 @@ test("browser funnel, automation, privacy opt-out, private SPA routes and duplic await context.close(); } finally { await browser.close(); } }); + +test("AC transparent display and runtime actions respect interaction, deduplication and private boundaries", async () => { + const browser = await chromium.launch({ headless: true, channel: process.env.PLAYWRIGHT_CHANNEL }); + try { + const context = await browser.newContext(); + const received = []; + await context.route("**/*", async route => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/visit-track") { + received.push(JSON.parse(route.request().postData())); + return route.fulfill({ status: 204 }); + } + if (/\/visit-(tracker|model)\.mjs$/.test(url.pathname)) { + return route.fulfill({ contentType: "text/javascript", body: await readFile(new URL(`../public/aesthetic.computer/lib/${url.pathname.split("/").at(-1)}`, import.meta.url), "utf8") }); + } + return route.fulfill({ contentType: "text/html", body: ` + +

+ + ` }); + }); + const page = await context.newPage(); + await page.goto("https://aesthetic.computer/notepat"); + await page.waitForFunction(() => !!window.acVisits); + assert.equal(await page.evaluate(() => acVisits.action("note_played")), false, "an automatic note cannot establish interaction"); + await page.locator("input").fill("secret"); + await page.locator("button").click(); + await page.waitForTimeout(50); + assert.ok(!received.some(row => row.actions.includes("canvas_interacted")), "overlay controls are not canvas contact"); + await page.mouse.click(350, 350); + await page.waitForTimeout(50); + assert.ok(!received.some(row => row.actions.includes("canvas_interacted")), "outside display bounds is not canvas contact"); + await page.mouse.click(150, 150); + await page.waitForTimeout(50); + assert.ok(received.some(row => row.actions.includes("canvas_interacted")), "pointer-transparent display is measured"); + assert.equal(await page.evaluate(() => acVisits.action("note_played")), true); + await page.waitForTimeout(50); + const notes = received.filter(row => row.actions.includes("note_played")).length; + await page.evaluate(() => { acVisits.action("note_played"); acVisits.action("arbitrary-private-label"); }); + await page.waitForTimeout(50); + assert.equal(received.filter(row => row.actions.includes("note_played")).length, notes, "repeated notes do not produce repeated network events"); + assert.equal(await page.evaluate(() => { history.pushState({}, "", "/mail"); return acVisits.action("painting_saved"); }), false, "private navigation blocks actions before the next poll"); + assert.equal(await page.evaluate(() => { history.pushState({}, "", "/nopaint"); return acVisits.action("painting_saved"); }), false, "returning from private routes requires fresh interaction"); + await page.mouse.click(150, 150); + assert.equal(await page.evaluate(() => acVisits.action("painting_edited")), true); + assert.equal(await page.evaluate(() => { window.acVisitTrackingDisabled = true; return acVisits.action("painting_saved"); }), false); + await page.waitForTimeout(50); + assert.ok(!JSON.stringify(received).includes("secret")); + assert.ok(!JSON.stringify(received).includes("arbitrary-private-label")); + assert.ok(received.every(row => row.automated)); + await context.close(); + } finally { await browser.close(); } +}); diff --git a/system/tests/visit-tracking.test.mjs b/system/tests/visit-tracking.test.mjs index 8c06c7c5df..b4a1a1d903 100644 --- a/system/tests/visit-tracking.test.mjs +++ b/system/tests/visit-tracking.test.mjs @@ -1,13 +1,29 @@ import test from "node:test"; import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { validateVisit, visitUpdate, visitProperty, visitSurface, automatedVisit } from "../public/aesthetic.computer/lib/visit-model.mjs"; +import { validateVisit, visitUpdate, visitProperty, visitSurface, automatedVisit, visitMediaAction, VISIT_ACTIONS } from "../public/aesthetic.computer/lib/visit-model.mjs"; import { handler } from "../netlify/functions/visit-track.mjs"; const snapshot = () => ({ version: 1, id: randomUUID(), surface: "home", activeSeconds: 10, interacted: true, automated: false, inputs: ["pointer"], actions: ["link_followed"] }); +test("creation milestones require a confirmed media record and carry no content", () => { + for (const result of [null, {}, { slug: "upload-only" }, { code: "" }, { code: 123 }, { code: "abc", error: "failed" }]) + assert.equal(visitMediaAction("png", result), null); + assert.equal(visitMediaAction("png", { code: "abc", slug: "private-name" }), "painting_saved"); + for (const ext of ["zip", "mp4", "webm"]) + assert.equal(visitMediaAction(ext, { code: "abc" }), "tape_saved"); + assert.equal(visitMediaAction("mjs", { code: "abc" }), null); + const value = snapshot(); + value.actions = [...VISIT_ACTIONS]; + assert.ok(Buffer.byteLength(JSON.stringify(value)) < 2048, "all action flags fit the collector limit"); + const visit = validateVisit(value, "https://aesthetic.computer"); + assert.ok(visit); + assert.equal(visitUpdate(visit).$max["actions.painting_saved"], true); + assert.equal(validateVisit({ ...value, interacted: false }, "https://aesthetic.computer"), null); +}); + test("property identity comes from a reviewed HTTPS origin, not submitted data", () => { const value = snapshot(); assert.equal(validateVisit({ ...value, property: "jas.life" }, "https://nopaint.art").property, "nopaint.art"); diff --git a/toolchain/analytics/VISITS.md b/toolchain/analytics/VISITS.md index 825e21937c..47acea9820 100644 --- a/toolchain/analytics/VISITS.md +++ b/toolchain/analytics/VISITS.md @@ -46,6 +46,28 @@ Product code can call `window.acVisits?.action(name)` for a reviewed action. New actions must be added to the shared allowlist with a documented success condition and a test. Do not infer publish/purchase/account success from clicks. +AC pieces running in the worker can send +`{ type: "visit:action", content: { action: "reviewed_name" } }` through their +existing `api.send`. BIOS forwards only the action name to the same collector; +no piece source, notes, command text, media identifiers or account data are sent. +The collector still requires visible-page interaction and respects private +routes, opt-outs and known automation. These hooks do not use PostHog. + +| Action | Success condition | +| --- | --- | +| `canvas_interacted` | Trusted pointer/touch contact with a canvas, or inside AC's marked pointer-transparent display; overlaid DOM controls and keyboard input alone do not qualify | +| `note_played` | Notepat accepts a manual pad, keyboard or MIDI note and triggers its voice path; wrong song notes and Autopat do not qualify; this is not proof of audible output | +| `painting_edited` | No Paint commits an accepted proposal to the artwork and undo history; generated previews do not qualify | +| `recording_started` | The runtime's MediaRecorder emits `start`; recorder requests or permission prompts alone do not qualify | +| `painting_saved` | PNG upload tracking returns a successful media record with a code | +| `tape_saved` | ZIP/MP4/WebM upload tracking or tape draft finalization returns a successful record with a code; downstream transcoding may still be pending | + +MIDI-only use still needs an independently recorded interaction in the visit. +The new creation hooks cover Notepat, No Paint and the shared media upload +paths, not every piece or every recording implementation. These per-visit +flags describe occurrence, not note/stroke totals or unique people. Historical +zeros before these hooks ship mean unmeasured activity, not absence of use. + ## Storage and privacy `network-visits` stores one document per property + random visit UUID. A @@ -97,6 +119,36 @@ Check the deployment/coverage record before interpreting a zero. Archived aggregate reports may be kept without visit IDs. No retrospective backfill is possible for the period before installation. +Reports include `actionVisits` (visits with any reviewed action) and cumulative +`interacted30`, `interacted60`, `interacted180`, `interacted600` visible-time +thresholds. The analytics MCP exposes thresholds under `depth`, keyed by seconds. +Action totals overlap; do not sum them to count visits. Lith's daily rollup +retains these counts and each action flag for newly folded days. Previously +written daily rows are unchanged and may lack these fields; missing means +unavailable, not zero. Raw visit reports can still aggregate retained records. + +### AC Human Fishery + +The analytics MCP's `human_fishery` tool reads Silo's existing `_firehose` +(`silo/server.mjs`, MongoDB change stream → history + WebSocket dashboard), +filters to `network-visits`, and resolves recent, non-automated visits with +interaction. It adds no separate stream or raw event storage. Firehose +throttling/deduplication means this is a sampled operational feed, not a complete +audit log. Call with `{ "minutes": 5, "scope": "studio" }`, then repeat +after at least 15 seconds. Use `startedAfter` (ISO UTC) to watch only new visits +after a deployment. The maximum lookback is 60 minutes and the maximum result +is 200 visits; `truncated` explicitly marks an incomplete snapshot. + +Each fish has a temporary name derived from its random visit ID and the UTC +day. Raw visit IDs stay on Lith. The tool exposes only the public property, +broad landing category, arrival/last-report time, visible-time bucket and +reviewed action flags. A fish is one visit, not a person. Same-page actions can +accumulate, but separate page loads and domains cannot be connected. There is +no stored event sequence: compare successive snapshots to see newly observed +milestones, not the exact order or time in which actions occurred. The last +report is the last changed snapshot, not continuous presence or departure. +The tool reads existing data; it adds no browser identifiers or new retention. + ## Coverage The shared AC shell covers AC, notepat.com, nopaint.art, laklok.com and mime.ac diff --git a/toolchain/analytics/human-fishery-report.mjs b/toolchain/analytics/human-fishery-report.mjs new file mode 100644 index 0000000000..8e9226d3ca --- /dev/null +++ b/toolchain/analytics/human-fishery-report.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node +// On Lith: node --env-file=.env ../toolchain/analytics/human-fishery-report.mjs '{"minutes":5}' +import { connect, closePool } from "../../system/backend/database.mjs"; +import { fisheryOptions, fisherySnapshot, fisheryPipeline } from "./human-fishery.mjs"; + +const options = fisheryOptions(JSON.parse(process.argv[2] || "{}")); +const { db } = await connect(); +try { + const rows = await db.collection("_firehose").aggregate(fisheryPipeline(options), { maxTimeMS: 10000 }).toArray(); + console.log(JSON.stringify(fisherySnapshot(rows, options))); +} finally { await closePool(); } diff --git a/toolchain/analytics/human-fishery.mjs b/toolchain/analytics/human-fishery.mjs new file mode 100644 index 0000000000..ed5fe5e174 --- /dev/null +++ b/toolchain/analytics/human-fishery.mjs @@ -0,0 +1,50 @@ +// AC Human Fishery: bounded snapshots of anonymous visits with interaction. +import { createHash } from "node:crypto"; +import { VISIT_ACTIONS, visitScopeMatch } from "../../system/public/aesthetic.computer/lib/visit-model.mjs"; + +const COLORS = ["coral", "gold", "mint", "blue", "violet", "silver", "pink", "amber"]; +const FISH = ["guppy", "tetra", "minnow", "koi", "trout", "perch", "goby", "tang"]; + +export function fisheryOptions({ minutes = 5, scope = "studio", limit = 50, startedAfter } = {}, now = new Date()) { + if (!Number.isFinite(minutes) || minutes < 1 || minutes > 60) throw new Error("minutes must be 1..60"); + if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new Error("limit must be 1..200"); + const start = startedAfter === undefined ? null : new Date(startedAfter); + if (start && (!Number.isFinite(+start) || +start > +now)) throw new Error("startedAfter must be a past ISO date"); + return { scope, limit, since: new Date(+now - minutes * 60000), observedAt: now, + match: { ...visitScopeMatch(scope), automated: false, interacted: true, + lastSeenAt: { $gte: new Date(+now - minutes * 60000), $lte: now }, + ...(start ? { startedAt: { $gte: start } } : {}) } }; +} + +export function fisherySnapshot(rows, options) { + const day = options.observedAt.toISOString().slice(0, 10); + return { + name: "AC Human Fishery", source: "silo._firehose → network-visits", observedAt: options.observedAt, since: options.since, + scope: options.scope, truncated: rows.length > options.limit, + unit: "anonymous page visits with interaction; not unique people or continuous presence", + note: "Uses Silo's existing throttled firehose. Fish names last for this visit and UTC day. Actions are cumulative flags, not an ordered path. Repeat after at least 15 seconds to see changes; sites and separate page visits are not linked.", + fish: rows.slice(0, options.limit).map(row => { + const hash = createHash("sha256").update(`${day}\0${row._id}`).digest(); + return { fish: `${COLORS[hash[0] % COLORS.length]}-${FISH[hash[1] % FISH.length]}-${hash.subarray(2, 6).toString("hex")}`, + property: row.property, surface: row.surface, arrivedAt: row.startedAt, + lastFirehoseAt: row.lastFirehoseAt, + lastReportedAt: row.lastSeenAt, visibleSecondsAtLeast: row.activeSeconds, + engaged: row.engaged === true, actions: VISIT_ACTIONS.filter(action => row.actions?.[action] === true) }; + }), + }; +} + +export function fisheryPipeline(options) { + return [ + { $match: { ns: "network-visits", time: { $gte: options.since, $lte: options.observedAt }, op: { $in: ["insert", "update", "replace"] } } }, + { $group: { _id: "$docId", lastFirehoseAt: { $max: "$time" } } }, + { $lookup: { from: "network-visits", localField: "_id", foreignField: "_id", pipeline: [ + { $match: options.match }, + { $project: { property: 1, surface: 1, startedAt: 1, lastSeenAt: 1, activeSeconds: 1, engaged: 1, actions: 1 } }, + ], as: "visit" } }, + { $unwind: "$visit" }, + { $replaceWith: { $mergeObjects: ["$visit", { lastFirehoseAt: "$lastFirehoseAt" }] } }, + { $sort: { lastFirehoseAt: -1, _id: 1 } }, + { $limit: options.limit + 1 }, + ]; +} diff --git a/toolchain/mcp/analytics-mcp.mjs b/toolchain/mcp/analytics-mcp.mjs index 35cc23b864..e14b208de6 100644 --- a/toolchain/mcp/analytics-mcp.mjs +++ b/toolchain/mcp/analytics-mcp.mjs @@ -16,6 +16,8 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import zlib from "node:zlib"; import { serveStdio, serveHttp, httpPort } from "./http-front.mjs"; +import { VISIT_ACTIONS, VISIT_DEPTHS } from "../../system/public/aesthetic.computer/lib/visit-model.mjs"; +import { fisheryOptions } from "../analytics/human-fishery.mjs"; const pexec = promisify(execFile); const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -35,8 +37,17 @@ const APPS = { 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"]; +const ACTIONS = VISIT_ACTIONS; + +async function humanFishery(args = {}) { + fisheryOptions(args); // Validate before making an SSH call. + const { minutes = 5, scope = "studio", limit = 50, startedAfter } = args; + const quoted = "'" + JSON.stringify({ minutes, scope, limit, startedAfter }).replaceAll("'", "'\\''") + "'"; + const remote = `cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/human-fishery-report.mjs ${quoted}`; + const { stdout } = await pexec("ssh", ["-i", SSH_KEY, "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", LITH, remote], + { timeout: 30000, maxBuffer: 1024 * 1024 }); + return JSON.parse(stdout.slice(stdout.indexOf("{"))); +} // 🌐 Visits @@ -52,10 +63,12 @@ async function visitsReport({ hours = 48, scope = "all", end } = {}) { const sites = {}; for (const row of report.audience) { - const site = sites[row._id.property] ??= { group: row.group, visits: 0, interacted: 0, engaged: 0, visibleMinutes: 0, surfaces: {}, actions: {} }; + const site = sites[row._id.property] ??= { group: row.group, visits: 0, interacted: 0, engaged: 0, actionVisits: 0, depth: {}, visibleMinutes: 0, surfaces: {}, actions: {} }; site.visits += row.visits; site.interacted += row.interacted; site.engaged += row.engaged; + site.actionVisits += row.actionVisits || 0; + for (const seconds of VISIT_DEPTHS) site.depth[seconds] = (site.depth[seconds] || 0) + (row[`interacted${seconds}`] || 0); site.visibleMinutes += row.activeSecondsLowerBound / 60; site.surfaces[row._id.surface] = (site.surfaces[row._id.surface] || 0) + row.visits; for (const a of ACTIONS) if (row[a]) site.actions[a] = (site.actions[a] || 0) + row[a]; @@ -74,6 +87,8 @@ async function visitsReport({ hours = 48, scope = "all", end } = {}) { window: { start: report.start, end: report.end, scope, earliestRetainedVisit: report.earliestRetainedVisit }, unit: report.unit, totals: { visits: sum("visits"), interacted: sum("interacted"), engaged: sum("engaged"), + actionVisits: sum("actionVisits"), + depth: Object.fromEntries(VISIT_DEPTHS.map(seconds => [seconds, Object.values(sites).reduce((n, site) => n + site.depth[seconds], 0)])), automated: Object.values(automated).reduce((n, v) => n + v, 0) }, sites: Object.fromEntries(Object.entries(sites).sort((a, b) => b[1].visits - a[1].visits)), automatedBySite: automated, @@ -229,6 +244,17 @@ async function appDownloads({ days = 7, apps = Object.keys(APPS) } = {}) { // 🔌 MCP const TOOLS = [ + { + name: "human_fishery", + description: "AC Human Fishery: watch recent likely-human activity through Silo's existing MongoDB firehose on Lith. Returns temporary fish names for non-automated visits with interaction, public property, broad surface, visible-time depth and action flags. Read-only snapshots; repeat after at least 15 seconds for changes. Does not identify people, link separate visits or infer cross-site journeys. lastReportedAt is the last changed snapshot, not proof someone is still online.", + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { type: "object", additionalProperties: false, properties: { + minutes: { type: "number", minimum: 1, maximum: 60, description: "Look back this many minutes for reported activity; default 5" }, + scope: { type: "string", enum: ["studio", "clients", "all"], description: "Default studio" }, + limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum fish returned; default 50" }, + startedAfter: { type: "string", description: "Optional ISO date: only visits arriving after this time, useful for watching the first arrival after a deploy" }, + } }, + }, { name: "visits_report", description: "First-party page visits across AC web properties (from lith's network-visits collector), folded per site: visits, interacted, engaged (10s+), visible minutes, actions, automated traffic. Page visits, not unique people. Retention is 35 days; collection began 2026-09-23.", @@ -287,6 +313,7 @@ const TOOLS = [ async function callTool(name, args = {}) { const result = name === "visits_report" ? await visitsReport(args) + : name === "human_fishery" ? await humanFishery(args) : name === "direct_downloads" ? await directDownloads(args) : name === "daily_metrics" ? await dailyMetrics(args) : name === "app_opens" ? await appOpens(args)