x402-toolpick / tree / src/needlog.js ยท raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
// Privacy-safe demand log for ToolPick need= queries (0.1.2). Aggregate counts only, in the existing StatsCounter
// SQLite Durable Object (table `needs`): per UTC day and normalized need -> total, no_match, low_match, no_ours.
// Normalized = lowercase; emails, URLs, wallet/hex addresses, base58 keys, long tokens and long numbers removed;
// whitespace collapsed; capped at 120 chars. No IP, wallet, payment header or request id is ever stored.
// Fail-open and fire-and-forget: logging can never change or delay a response. Smoke requests are not logged.
export const NEED_MAX = 120;
export const LOW_REL = 0.5; // top relevance below this = a weak match
export const PUBLIC_MIN_COUNT = 3; // /v1/needs only shows needs asked at least this many times in the window

export function normalizeNeed(s) {
  let t = String(s ?? '').toLowerCase();
  t = t.replace(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/g, ' ')              // emails
    .replace(/\b(?:https?|wss?|ftp|ipfs):\/\/\S+/g, ' ')                       // URLs
    .replace(/\bwww\.\S+/g, ' ')
    .replace(/\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:com|io|xyz|org|net|co|ai|dev|app|so|sh|gg|me|fi|finance|eth|sol)\b\S*/g, ' ') // bare domains / ENS / SNS names
    .replace(/\b0x[0-9a-f]{6,}\b/g, ' ')                                       // EVM addresses, tx hashes, calldata
    .replace(/\b[1-9a-hj-np-z]{32,44}\b/g, ' ')                                // base58 (Solana, BTC) addresses/keys
    .replace(/\b[a-z0-9_\-+/=]{24,}\b/g, ' ')                                  // long tokens / keys
    .replace(/\d[\d.,:\-/ ]{4,}\d|\d{5,}/g, ' ')                               // long numbers (phones, ids, amounts)
    .replace(/[^\p{L}\p{N} .,'&+#\-/]+/gu, ' ')
    .replace(/\s+/g, ' ').trim();
  if (t.length > NEED_MAX) t = t.slice(0, NEED_MAX).replace(/\s+\S*$/, '').trim() || t.slice(0, NEED_MAX);
  return t.length >= 2 ? t : '';
}

/** Outcome flags from a ToolPick result (or a no_matches error). */
export function classify(result, err) {
  if (err) return err.code === 'no_matches' ? { no_match: 1, low_match: 0, no_ours: 1 } : null;
  const rs = result?.results || [];
  if (!rs.length) return { no_match: 1, low_match: 0, no_ours: 1 };
  const top = Math.max(...rs.map((x) => Number(x.relevance) || 0));
  return { no_match: 0, low_match: top < LOW_REL ? 1 : 0, no_ours: rs.some((x) => x.ours) ? 0 : 1 };
}

const day = (t) => new Date(t).toISOString().slice(0, 10);

export class NeedLog {
  constructor(counter, { flushMs = 60000, now = () => Date.now() } = {}) {
    this.counter = counter; this.flushMs = flushMs; this.now = now; this.pending = {}; this.lastFlush = 0; this.errors = 0;
  }
  stub() { return this.counter.get(this.counter.idFromName('global')); }
  /** Never throws. tier: lite | pick | preview (market is seller-side and not logged). */
  record(tier, need, result, err, waitUntil) {
    try {
      if (!['lite', 'pick', 'preview'].includes(tier)) return;
      const q = normalizeNeed(need); const f = classify(result, err);
      if (!q || !f) return;
      const bucket = (this.pending[day(this.now())] ||= {});
      if (!bucket[q] && Object.keys(bucket).length >= 2000) return; // bounded memory between flushes
      const b = (bucket[q] ||= { n: 0, no_match: 0, low_match: 0, no_ours: 0 });
      b.n++; b.no_match += f.no_match; b.low_match += f.low_match; b.no_ours += f.no_ours;
      if (this.counter && this.now() - this.lastFlush >= this.flushMs) {
        this.lastFlush = this.now();
        const p = this.flush().catch(() => { this.errors++; });
        try { waitUntil?.(p); } catch { /* not on Workers */ }
      }
    } catch { this.errors++; }
  }
  async flush() {
    if (!this.counter) return;
    const pending = this.pending; this.pending = {};
    if (!Object.keys(pending).length) return;
    await this.stub().addNeeds(pending);
  }
  /** Top needs in the last `days` days. unmet = no_match + low_match. Never throws. */
  async top({ days = 30, limit = 50, minCount = PUBLIC_MIN_COUNT, unmetOnly = true } = {}) {
    const since = day(this.now() - (days - 1) * 86400000);
    let rows = [];
    try { if (this.counter) rows = await this.stub().topNeeds(since, 500); } catch { return { enabled: true, degraded: true, needs: [] }; }
    const m = new Map();
    for (const r of rows) { const e = m.get(r.q) || { need: r.q, n: 0, no_match: 0, low_match: 0, no_ours: 0 }; e.n += r.n; e.no_match += r.no_match; e.low_match += r.low_match; e.no_ours += r.no_ours; m.set(r.q, e); }
    for (const [d, qs] of Object.entries(this.pending)) if (d >= since) for (const [q, c] of Object.entries(qs)) { const e = m.get(q) || { need: q, n: 0, no_match: 0, low_match: 0, no_ours: 0 }; e.n += c.n; e.no_match += c.no_match; e.low_match += c.low_match; e.no_ours += c.no_ours; m.set(q, e); }
    const needs = [...m.values()].map((e) => ({ ...e, unmet: e.no_match + e.low_match }))
      .filter((e) => e.n >= minCount && (!unmetOnly || e.unmet > 0 || e.no_ours > 0))
      .sort((a, b) => (b.unmet - a.unmet) || (b.no_ours - a.no_ours) || (b.n - a.n)).slice(0, limit);
    return { enabled: !!this.counter, window_days: days, min_count: minCount, needs };
  }
}

agentgate-source: code mirror on Cloudflare Pages. All repos