From af853ae669dfab7f7f382ff4fab65f176362e29a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 30 Sep 2026 17:31:30 -0700 Subject: [PATCH] Track repeated Laklok feature use and report per-account activity trends --- system/public/aesthetic.computer/bios.mjs | 4 ++ .../public/aesthetic.computer/disks/chat.mjs | 14 ++++- .../aesthetic.computer/disks/laklok.mjs | 11 +++- .../lib/account-activity-model.mjs | 6 +- .../lib/account-activity.mjs | 23 +++++--- .../lib/laklok-activity.mjs | 22 ++++++++ system/public/html/index.html | 33 +++++++++-- system/public/network-privacy.html | 10 +++- system/tests/account-activity.test.mjs | 23 ++++++++ system/tests/journey-report.test.mjs | 27 ++++++++- system/tests/laklok-activity-browser.test.mjs | 56 +++++++++++++++++++ toolchain/analytics/VISITS.md | 32 +++++++++++ toolchain/analytics/journey-report.mjs | 11 ++-- toolchain/analytics/laklok-feature-report.mjs | 56 +++++++++++++++++++ toolchain/laklok-sisters/PARITY.md | 8 +++ toolchain/mcp/analytics-mcp.mjs | 15 +++-- 16 files changed, 322 insertions(+), 29 deletions(-) create mode 100644 system/public/aesthetic.computer/lib/laklok-activity.mjs create mode 100644 system/tests/laklok-activity-browser.test.mjs create mode 100644 toolchain/analytics/laklok-feature-report.mjs diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 08a837017f..823de07783 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -18428,6 +18428,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } + if (type === "account:action") { + accountActivity.action(content?.action); + return; + } if (type === "visit:action") { window.acVisits?.action(content?.action); return; diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index 817f1f70ab..4580164afa 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -1045,6 +1045,7 @@ async function boot( token, sub: user.sub, }); + options?.onAction?.("message_edit_requested"); notice("EDITED"); } return; @@ -1054,7 +1055,11 @@ async function boot( // to start/stop the mini-player without reaching for the button. const radioCmd = parseRadioCommand(text); if (radioCmd) { - applyRadioCommand(radioCmd, send); + applyRadioCommand(radioCmd, message => { + send(message); + if (message.type === "stream:play") options?.onAction?.("radio_play_requested"); + if (message.type === "stream:pause") options?.onAction?.("radio_pause_requested"); + }); notice(radioCmd.toast || "", ["orange", 0]); return; } @@ -1103,6 +1108,7 @@ async function boot( sub: user.sub, font: userSelectedFont, // 🔤 Include selected font }); + options?.onAction?.("message_send_requested"); notice("SENT"); } } @@ -3395,7 +3401,11 @@ function act( pen.y >= r8dioPlayerBounds.y && pen.y < r8dioPlayerBounds.y + r8dioPlayerBounds.h; if (inBar) { beep(); - toggleR8dioPlayback(send); + toggleR8dioPlayback(message => { + send(message); + if (message.type === "stream:play") options?.onAction?.("radio_play_requested"); + if (message.type === "stream:pause") options?.onAction?.("radio_pause_requested"); + }); } } } diff --git a/system/public/aesthetic.computer/disks/laklok.mjs b/system/public/aesthetic.computer/disks/laklok.mjs index 8f0ab97ffb..e9d22e24bb 100644 --- a/system/public/aesthetic.computer/disks/laklok.mjs +++ b/system/public/aesthetic.computer/disks/laklok.mjs @@ -18,6 +18,7 @@ import { Chat } from "../lib/chat.mjs"; // TODO: Eventually expand to `net.Socket` import * as chat from "./chat.mjs"; // Import chat everywhere. +import { laklokAction } from "../lib/laklok-activity.mjs"; import { LAK_THEMES, realtimeTick, @@ -142,7 +143,7 @@ function chatView() { function boot({ api, wipe, debug, send, hud, store, colon, params, jump, net, query }) { client = new Chat(debug, send); client.connect("clock"); // Connect to 'clock' chat. (DB stays `chat-clock`.) - chat.boot(api, client.system); // Use default font + chat.boot(api, client.system, { onAction: name => laklokAction(api, name) }); // Use default font // 🚫 chat.boot stamps a prompt.ac/chat QR to the LEFT of the HUD label; clear // it so laklok shows only its own laklok.com QR in the top-right (paintCorner). @@ -393,12 +394,14 @@ function act($) { // so a tap on a chip never scrolls the chat underneath. if (e.is("touch") && hit(gearBox)) { settingsOpen = !settingsOpen; + if (settingsOpen) laklokAction($, "settings_opened"); needsPaint?.(); return; } // 📬 The envelope is the door to mail. if (e.is("touch") && !settingsOpen && hit(mailBox)) { + laklokAction($, "mail_open_requested"); jump("mail"); return; } @@ -413,19 +416,23 @@ function act($) { if (chip) { const { type, value } = chip.action; if (type === "mode" && value === "vector") { + laklokAction($, "mode_switch_requested"); jump("out:https://laklok.com/html/"); } else if (type === "theme") { + if (lakTheme !== value) laklokAction($, "theme_changed"); lakTheme = value; saveTema(store, value); if (value === "realtime") realtimeTick(); // catch up before first paint chat.refresh(client.system); // recolor cached message lines reportTema(net, value); } else if (type === "links") { + if (lakLinksOnly !== value) laklokAction($, "filter_changed"); lakLinksOnly = value; store["laklok:links"] = value; store.persist("laklok:links"); chat.refresh(client.system); // relayout the filtered feed } else if (type === "lang") { + if (lakLang !== value) laklokAction($, "language_changed"); lakLang = value; saveLang(store, value); } @@ -437,7 +444,7 @@ function act($) { return; } - chat.act($, chatView(), { allowDelete: true }); + chat.act($, chatView(), { allowDelete: true, onAction: name => laklokAction($, name) }); } function sim($) { diff --git a/system/public/aesthetic.computer/lib/account-activity-model.mjs b/system/public/aesthetic.computer/lib/account-activity-model.mjs index f266a60c03..3867114e3d 100644 --- a/system/public/aesthetic.computer/lib/account-activity-model.mjs +++ b/system/public/aesthetic.computer/lib/account-activity-model.mjs @@ -1,8 +1,9 @@ import { VISIT_ACTIONS, visitProperty, visitSurface, visitReferrer, visitGroup } from "./visit-model.mjs"; +import { LAKLOK_ACTIONS, LAKLOK_PIECES, LAKLOK_FEATURE_VERSION } from "./laklok-activity.mjs"; export const ACCOUNT_ACTIVITY_COLLECTION = "account-activity"; export const SOTCE_ACTIONS = Object.freeze(["sotce_page_viewed", "sotce_page_visible_30s", "sotce_page_touched", "sotce_question_submitted"]); -export const ACCOUNT_ACTIONS = Object.freeze(["piece_opened", ...VISIT_ACTIONS, ...SOTCE_ACTIONS]); +export const ACCOUNT_ACTIONS = Object.freeze(["piece_opened", ...VISIT_ACTIONS, ...SOTCE_ACTIONS, ...LAKLOK_ACTIONS]); export function accountActivityRoute(host, path, action) { if (visitSurface(path) === null) return false; @@ -31,7 +32,10 @@ export function validateAccountActivity(body, origin) { !/^[a-z0-9-]{1,64}$/.test(body.piece) || visitSurface(`/${body.piece}`) === null || typeof body.automated !== "boolean") return null; if (SOTCE_ACTIONS.includes(body.action) && (property !== "sotce.net" || body.piece !== "sotce")) return null; + const laklok = property !== "sotce.net" && LAKLOK_PIECES.includes(body.piece) && body.featureVersion === LAKLOK_FEATURE_VERSION; + if (LAKLOK_ACTIONS.includes(body.action) && !laklok) return null; return { id: body.id.toLowerCase(), session: body.session.toLowerCase(), sequence: body.sequence, property, + ...(laklok ? { featureVersion: LAKLOK_FEATURE_VERSION } : {}), tenant: property === "sotce.net" ? "sotce" : "aesthetic", piece: body.piece, action: body.action, automated: body.automated, referrerHost: typeof body.referrerHost === "string" ? visitReferrer(body.referrerHost) : null }; diff --git a/system/public/aesthetic.computer/lib/account-activity.mjs b/system/public/aesthetic.computer/lib/account-activity.mjs index 4d89fb9d67..d502ea478c 100644 --- a/system/public/aesthetic.computer/lib/account-activity.mjs +++ b/system/public/aesthetic.computer/lib/account-activity.mjs @@ -1,4 +1,5 @@ import { activityPiece, accountActivityRoute, SOTCE_ACTIONS } from "./account-activity-model.mjs"; +import { LAKLOK_ACTIONS, LAKLOK_PIECES, LAKLOK_FEATURE_VERSION } from "./laklok-activity.mjs"; import { automatedVisit, visitProperty, visitSurface, visitReferrer, VISIT_ACTIONS } from "./visit-model.mjs"; // Authenticated activity has its own short-lived session and endpoint. It does @@ -9,7 +10,7 @@ export function startAccountActivity(win = window, doc = document, { } = {}) { if (win.acAccountActivity) return win.acAccountActivity; let piece = null, ready = false, owner = null, session = null, generation = 0, sequence = 0, stopped = false; - let sent = new Set(), pending = new Set(), retryAt = 0; + let sent = new Set(), pending = new Set(), retryAt = 0, inFlight = 0; const allowed = action => !stopped && win === win.top && doc.visibilityState === "visible" && !win.acPACK_MODE && !win.acVisitTrackingDisabled && win.navigator.doNotTrack !== "1" && win.doNotTrack !== "1" && !win.navigator.globalPrivacyControl && @@ -24,13 +25,18 @@ export function startAccountActivity(win = window, doc = document, { } async function record(action) { syncOwner(); - const repeated = SOTCE_ACTIONS.includes(action); - if (repeated && (visitProperty(win.location.hostname) !== "sotce.net" || piece !== "sotce")) return; - if (!owner || !piece || !ready || !allowed(action) || (!repeated && sent.has(action)) || pending.has(action) || Date.now() < retryAt) return; + const laklok = LAKLOK_ACTIONS.includes(action); + const sotce = SOTCE_ACTIONS.includes(action); + const repeated = laklok || sotce; + if (sotce && (visitProperty(win.location.hostname) !== "sotce.net" || piece !== "sotce")) return; + if (laklok && !LAKLOK_PIECES.includes(piece)) return; + if (!owner || !piece || !ready || !allowed(action) || (!repeated && sent.has(action)) || + (!laklok && pending.has(action)) || inFlight >= 20 || Date.now() < retryAt) return; const epoch = generation, subject = owner, activeSession = session, activePiece = piece; const order = ++sequence; const activePending = pending, activeSent = sent; activePending.add(action); + inFlight++; let timeout; const controller = new AbortController(); try { @@ -38,15 +44,16 @@ export function startAccountActivity(win = window, doc = document, { const token = await Promise.race([Promise.resolve().then(getToken), expired]); if (!token || epoch !== generation || subject !== getUser()?.sub || !allowed(action)) return; const response = await win.fetch("https://aesthetic.computer/api/account-activity", { - method: "POST", credentials: "omit", referrerPolicy: "no-referrer", signal: controller.signal, + method: "POST", credentials: "omit", referrerPolicy: "no-referrer", signal: controller.signal, keepalive: true, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ version: 1, id: win.crypto.randomUUID(), session: activeSession, - sequence: order, piece: activePiece, action, automated: false, referrerHost: visitReferrer(doc.referrer) }), + sequence: order, piece: activePiece, action, automated: false, referrerHost: visitReferrer(doc.referrer), + ...(LAKLOK_PIECES.includes(activePiece) ? { featureVersion: LAKLOK_FEATURE_VERSION } : {}) }), }); if (response.ok) activeSent.add(action); else retryAt = Date.now() + 30000; } catch { retryAt = Date.now() + 30000; } - finally { win.clearTimeout(timeout); activePending.delete(action); } + finally { inFlight--; win.clearTimeout(timeout); activePending.delete(action); } } const timer = win.setInterval(() => { syncOwner(); @@ -60,7 +67,7 @@ export function startAccountActivity(win = window, doc = document, { const api = { load(path) { piece = activityPiece(path); ready = false; sent = new Set(); pending = new Set(); generation++; }, ready() { ready = true; void record("piece_opened"); }, - action(name) { if (VISIT_ACTIONS.includes(name) || SOTCE_ACTIONS.includes(name)) void record(name); }, + action(name) { if (VISIT_ACTIONS.includes(name) || SOTCE_ACTIONS.includes(name) || LAKLOK_ACTIONS.includes(name)) void record(name); }, stop() { stopped = true; generation++; win.clearInterval(timer); if (win.acAccountActivity === api) delete win.acAccountActivity; }, }; win.acAccountActivity = api; diff --git a/system/public/aesthetic.computer/lib/laklok-activity.mjs b/system/public/aesthetic.computer/lib/laklok-activity.mjs new file mode 100644 index 0000000000..f4680c67d3 --- /dev/null +++ b/system/public/aesthetic.computer/lib/laklok-activity.mjs @@ -0,0 +1,22 @@ +// Reviewed feature names only: no messages, links, recipients or preference values. +export const LAKLOK_FEATURE_VERSION = 1; +export const LAKLOK_PIECES = ["laklok", "laklok-vector"]; +export const LAKLOK_FEATURES = Object.freeze({ + laklok_settings_opened: ["raster", "vector"], + laklok_theme_changed: ["raster", "vector"], + laklok_filter_changed: ["raster", "vector"], + laklok_language_changed: ["raster"], + laklok_mode_switch_requested: ["raster", "vector"], + laklok_mail_open_requested: ["raster"], + laklok_radio_play_requested: ["raster"], + laklok_radio_pause_requested: ["raster"], + laklok_message_send_requested: ["raster", "vector"], + laklok_message_edit_requested: ["raster", "vector"], + laklok_history_older_requested: ["vector"], + laklok_media_open_requested: ["vector"], +}); +export const LAKLOK_ACTIONS = Object.freeze(Object.keys(LAKLOK_FEATURES)); +export function laklokAction(api, name) { + const action = `laklok_${name}`; + if (LAKLOK_ACTIONS.includes(action)) api.send?.({ type: "account:action", content: { action } }); +} diff --git a/system/public/html/index.html b/system/public/html/index.html index 03379f21b4..d44749a695 100644 --- a/system/public/html/index.html +++ b/system/public/html/index.html @@ -407,6 +407,8 @@