From 18f1271a5b1398e8d91a66a847f8427fa0023d41 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 3 Sep 2026 14:23:35 -0400 Subject: [PATCH] =?UTF-8?q?prompt-log:=20record=20every=20prompt=20submiss?= =?UTF-8?q?ion=20=E2=80=94=20the=20corpus=20of=20what=20people=20enter,=20?= =?UTF-8?q?misses=20first-class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DfdByEQCKzXHsRsXPwPLPA --- shared/posthog-policy.mjs | 3 +- system/netlify/functions/prompt-log.mjs | 189 ++++++++++++++++++ .../aesthetic.computer/disks/prompt.mjs | 9 + toolchain/prompt-stats.mjs | 83 ++++++++ 4 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 system/netlify/functions/prompt-log.mjs create mode 100644 toolchain/prompt-stats.mjs diff --git a/shared/posthog-policy.mjs b/shared/posthog-policy.mjs index 6658515086..5f753d31c0 100644 --- a/shared/posthog-policy.mjs +++ b/shared/posthog-policy.mjs @@ -31,7 +31,7 @@ const RULES = [ { class: "operational-telemetry", pattern: - /boot-log|bundle-telemetry|kidlisp-log|piece-hit|piece-log|metrics|reports|status|track-|menuband-logs|paper-hit/, + /boot-log|bundle-telemetry|kidlisp-log|piece-hit|piece-log|prompt-log|metrics|reports|status|track-|menuband-logs|paper-hit/, posthog: "existing-lith-silo-only", exclusion: "Keep raw logs, errors, stacks, IP and performance samples in Lith/Silo.", @@ -202,6 +202,7 @@ const REVIEWED_FUNCTION_NAMES = new Set([ "presigned-url", "print", "profile", + "prompt-log", "push", "push-devices", "redirect-proxy", diff --git a/system/netlify/functions/prompt-log.mjs b/system/netlify/functions/prompt-log.mjs new file mode 100644 index 0000000000..f36f45654a --- /dev/null +++ b/system/netlify/functions/prompt-log.mjs @@ -0,0 +1,189 @@ +// Prompt Log, 2026.09.03 +// Record every prompt submission (anonymous) so the corpus of what people +// enter — including misses — is searchable and countable. +// POST: fire-and-forget from the prompt piece: { text } +// GET (admin): ?q=substring&limit=N to search raw entries, +// ?stats=1&since=ISO&limit=N for grouped top entries + totals. + +import { existsSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { connect } from "../../backend/database.mjs"; +import { authorize, hasAdmin } from "../../backend/authorization.mjs"; +import { respond } from "../../backend/http.mjs"; + +const COLLECTION = "prompts"; +const MAX_LENGTH = 256; +const REDACTED_HEADS = new Set(["email"]); // arguments may carry PII + +let disksDir = null; +try { + disksDir = join( + dirname(fileURLToPath(import.meta.url)), + "../../public/aesthetic.computer/disks", + ); + if (!existsSync(disksDir)) disksDir = null; +} catch { + disksDir = null; +} + +function pieceOnDisk(slug) { + if (!disksDir || !/^[a-z0-9-]+$/i.test(slug)) return false; + return ( + existsSync(join(disksDir, `${slug}.mjs`)) || + existsSync(join(disksDir, `${slug}.lisp`)) + ); +} + +// Classify a submission: what kind of thing was entered, and did it resolve? +// `resolved: false` on kind "word" is the miss corpus — commands the prompt +// handles internally also land there and get filtered during analysis. +async function classify(text, db) { + const head = text.split(/\s+/)[0]; + + if (text.startsWith("(") || text.includes("\n")) { + return { kind: "kidlisp-source", resolved: true }; + } + + if (/^\$[a-z0-9]+$/i.test(head)) { + const record = await db + .collection("kidlisp") + .findOne({ code: head.slice(1) }, { projection: { _id: 1 } }); + return { kind: "kidlisp-code", resolved: !!record }; + } + + if (head.startsWith("@")) return { kind: "user-piece", resolved: null }; + + const slug = head.split(":")[0].split("~")[0]; + if (pieceOnDisk(slug)) return { kind: "piece", resolved: true }; + + return { kind: "word", resolved: false }; +} + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(204, ""); + + let database; + try { + database = await connect(); + } catch (err) { + console.error("❌ Database connection failed:", err); + return respond(500, { error: "Database connection failed" }); + } + + try { + const prompts = database.db.collection(COLLECTION); + + if (event.httpMethod === "GET") { + const user = await authorize(event.headers); + if (!user) { + await database.disconnect(); + return respond(401, { error: "Authentication required" }); + } + if (!(await hasAdmin(user))) { + await database.disconnect(); + return respond(403, { error: "Admin access required" }); + } + + const qs = event.queryStringParameters || {}; + const limit = Math.min(500, Math.max(1, Number(qs.limit || 50))); + + if (qs.stats) { + const match = {}; + if (qs.since) match.createdAt = { $gte: new Date(qs.since) }; + if (qs.kind) match.kind = qs.kind; + if (qs.resolved === "true") match.resolved = true; + if (qs.resolved === "false") match.resolved = false; + + const top = await prompts + .aggregate([ + { $match: match }, + { + $group: { + _id: "$text", + count: { $sum: 1 }, + kind: { $first: "$kind" }, + resolved: { $first: "$resolved" }, + first: { $min: "$createdAt" }, + last: { $max: "$createdAt" }, + }, + }, + { $sort: { count: -1 } }, + { $limit: limit }, + ]) + .toArray(); + + const totals = await prompts + .aggregate([ + { $match: match }, + { $group: { _id: "$kind", count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + ]) + .toArray(); + + await database.disconnect(); + return respond(200, { top, totals }); + } + + const query = {}; + if (qs.q) { + const escaped = qs.q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + query.text = { $regex: escaped, $options: "i" }; + } + if (qs.kind) query.kind = qs.kind; + + const entries = await prompts + .find(query) + .sort({ createdAt: -1 }) + .limit(limit) + .toArray(); + + await database.disconnect(); + return respond(200, { entries }); + } + + if (event.httpMethod === "POST") { + const body = JSON.parse(event.body || "{}"); + let text = (body.text || "").trim().slice(0, MAX_LENGTH); + + if (!text) { + await database.disconnect(); + return respond(400, { error: "text required" }); + } + + const head = text.split(/\s+/)[0].toLowerCase(); + if (REDACTED_HEADS.has(head) && text.length > head.length) { + text = `${head} [redacted]`; + } + + const { kind, resolved } = await classify(text, database.db); + + const now = new Date(); + try { + await prompts.createIndex({ createdAt: -1 }); + await prompts.createIndex({ text: 1 }); + await prompts.createIndex({ kind: 1, resolved: 1 }); + } catch (indexErr) { + // Indexes already exist - that's fine + } + + await prompts.insertOne({ + text, + kind, + resolved, + day: now.toISOString().split("T")[0], + createdAt: now, + }); + + await database.disconnect(); + return respond(200, { success: true }); + } + + await database.disconnect(); + return respond(405, { error: "Method not allowed" }); + } catch (err) { + console.error("❌ prompt-log error:", err); + if (database) await database.disconnect(); + return respond(500, { error: "Internal server error" }); + } +} diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs index d8bbfa9336..87961ea03e 100644 --- a/system/public/aesthetic.computer/disks/prompt.mjs +++ b/system/public/aesthetic.computer/disks/prompt.mjs @@ -1428,6 +1428,15 @@ async function boot({ // 🛑 Halt: (Intercept input and route it to commands.) async function halt($, text) { + // 🛰️ Prompt corpus: record what was entered (anonymous, fire-and-forget). + if (text?.trim().length > 0) { + fetch("/api/prompt-log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: text.trim().slice(0, 256) }), + }).catch(() => {}); + } + const { api, broadcast, diff --git a/toolchain/prompt-stats.mjs b/toolchain/prompt-stats.mjs new file mode 100644 index 0000000000..7d13a2fadf --- /dev/null +++ b/toolchain/prompt-stats.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// Prompt Stats, 2026.09.03 +// Search and tally the prompt corpus recorded by /api/prompt-log. +// Auth: admin token from ~/.ac-token (shared session). +// +// node toolchain/prompt-stats.mjs top entries, last 7 days +// node toolchain/prompt-stats.mjs --since 2026-09-01 top entries since a date +// node toolchain/prompt-stats.mjs --misses top unresolved entries +// node toolchain/prompt-stats.mjs --q flower search raw entries +// node toolchain/prompt-stats.mjs --kind kidlisp-code --limit 100 + +import { readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +const HOST = process.env.AC_HOST || "https://aesthetic.computer"; + +const args = process.argv.slice(2); +function flag(name) { + const i = args.indexOf(`--${name}`); + if (i === -1) return null; + const next = args[i + 1]; + return next && !next.startsWith("--") ? next : true; +} + +let token; +try { + token = JSON.parse(readFileSync(join(homedir(), ".ac-token"), "utf8")) + .access_token; +} catch { + console.error("❌ No admin token — expected ~/.ac-token with access_token."); + process.exit(1); +} + +const q = flag("q"); +const kind = flag("kind"); +const limit = flag("limit") || 50; +const since = + flag("since") || + new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString().slice(0, 10); + +const params = new URLSearchParams({ limit }); +let mode; +if (q) { + mode = "search"; + params.set("q", q); + if (kind) params.set("kind", kind); +} else { + mode = "stats"; + params.set("stats", "1"); + params.set("since", since); + if (kind) params.set("kind", kind); + if (flag("misses")) params.set("resolved", "false"); +} + +const res = await fetch(`${HOST}/api/prompt-log?${params}`, { + headers: { Authorization: `Bearer ${token}` }, +}); +if (!res.ok) { + console.error(`❌ ${res.status} ${await res.text()}`); + process.exit(1); +} +const data = await res.json(); + +if (mode === "search") { + for (const e of data.entries) { + const mark = e.resolved === false ? "✗" : e.resolved ? "·" : "?"; + console.log( + `${e.createdAt.slice(0, 16)} ${mark} [${e.kind}] ${e.text}`, + ); + } + console.log(`\n${data.entries.length} entries`); +} else { + console.log(`since ${since}${kind ? ` · kind=${kind}` : ""}\n`); + for (const t of data.top) { + const mark = t.resolved === false ? "✗" : t.resolved ? "·" : "?"; + console.log(`${String(t.count).padStart(6)} ${mark} [${t.kind}] ${t._id}`); + } + console.log("\ntotals by kind:"); + for (const t of data.totals) { + console.log(`${String(t.count).padStart(6)} ${t._id}`); + } +} -- 2.51.2