← back to Desktop Dotbar

jev-dots.js

343 lines

'use strict';
// jev-dots — re-classify each terminal's DOT COLOR through Jev (TypeSafe System One:
// unstructured per-terminal state in, a typed choice over the dot-color labels out).
//
// Scoped to the DOTBAR's OWN read path only: server.js getDots() already runs
// `allcolordots --json` and gets per-terminal {tty,pid,label,color,variant,parked}.
// This module re-decides the displayed COLOR at that seam, so the shared fleet
// allcolordots.sh is never touched. If Jev is unavailable/capped/errors, each row
// falls back to allcolordots' original heuristic color — never blank, never a throw.
//
// $0 BY DEFAULT: the built-in deterministic classifier runs locally with no network
// and no spend. The PAID TypeSafe backend is gated OFF behind DOTBAR_JEV_PAID=1 AND a
// hard daily spend cap; flipping it on is a separate Steve-approved step.
//
// Load-bearing cost control = the per-terminal STATE-HASH CACHE: a terminal is only
// (re)classified when its state hash changes, so "every terminal every 2.5s refresh"
// collapses to a few classifications/day (real transitions only) with no loss of
// "jev decides every color."

const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const https = require('https');
const { execFile } = require('child_process');

// ---- config constants (trivially changeable) --------------------------------
const LABELS = ['lightblue', 'orange', 'purple', 'yellow', 'green', 'pink', 'none'];
// Semantic priority (needs-Steve first) — matches allcolordots / server.js ORDER.
// A higher-priority signal in the label text outweighs a lower-priority one.
const PRIORITY = { lightblue: 6, orange: 5, purple: 4, yellow: 3, green: 2, pink: 1, none: 0 };

const CONFIG = {
  // PAID PATH DEFAULT-OFF. Only DOTBAR_JEV_PAID=1 arms it; anything else = $0 builtin.
  paidEnabled: process.env.DOTBAR_JEV_PAID === '1',
  // Hard daily spend cap (USD). Steve may set DOTBAR_JEV_CAP_USD=2; default $5.
  capUsd: Number(process.env.DOTBAR_JEV_CAP_USD || '5'),
  // Price-INDEPENDENT hard daily call-count cap — the real backstop when the true
  // per-call price is unknown. Bounds worst-case spend to (maxPaidCalls x price)
  // regardless of the rate. DOTBAR_JEV_MAX_CALLS overrides; default 800
  // (800 x $0.001 placeholder = $0.80; even at 10x real price = $8).
  maxPaidCalls: Number(process.env.DOTBAR_JEV_MAX_CALLS || '800'),
  provider: process.env.DOTBAR_JEV_PROVIDER || 'typesafe',
  timeoutMs: Number(process.env.DOTBAR_JEV_TIMEOUT_MS || '800'),
  ledgerPath: path.join(os.homedir(), '.claude', 'cost-ledger.jsonl'),
  costLogJs: path.join(os.homedir(), '.claude', 'skills', 'cost-tracker', 'scripts', 'log.js'),
  costApiKey: 'typesafe_jev',
  costApp: 'desktop-dotbar',
};

// ---- observability counters (verification reads these) ----------------------
const stats = {
  classifications: 0, // rows that actually ran the classifier (cache miss)
  cacheHits: 0,       // rows served from the state-hash cache (no reclassify)
  cacheMisses: 0,
  builtinCalls: 0,    // classifier decided via the $0 local path
  paidCalls: 0,       // classifier decided via the paid TypeSafe API
  paidFellBack: 0,    // paid attempted but errored -> builtin
  capBlocked: 0,      // paid armed but daily $ cap reached -> builtin
  countCapBlocked: 0, // paid armed but daily call-COUNT cap reached -> builtin
  paidEnabled: CONFIG.paidEnabled,
  capUsd: CONFIG.capUsd,
  maxPaidCallsPerDay: CONFIG.maxPaidCalls,
  lastSpendUsd: 0,
  paidCallsToday: 0,  // shape default only — getStats() overrides with the LIVE ledger count (== what the cap enforces)
  updated: 0,
};

// tty -> { hash, color, source, confidence }
const cache = new Map();

// ---- state extraction: what we feed Jev per terminal ------------------------
function cleanLabel(label) {
  if (!label) return '';
  return String(label).replace(/^[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}️\s]+/u, '').trim();
}
// The meaningful per-terminal state (comparable to what the heuristic sees, plus
// the label text the heuristic ignores): the heuristic's own color as a prior,
// the cleaned dot title, and whether the session is stopped.
function extractState(row) {
  return {
    heuristicColor: LABELS.includes(row.color) ? row.color : 'none',
    label: cleanLabel(row.label),
    variant: row.variant || '',
    stopped: row.variant === 'stopped',
  };
}
function stateHash(s) {
  return crypto.createHash('sha1')
    .update(`${s.heuristicColor}\u0000${s.label}\u0000${s.variant}`)
    .digest('hex');
}

// ---- $0 built-in classifier -------------------------------------------------
// Deterministic typed choice over the dot-color labels. Designed as a strict
// REFINEMENT of the heuristic: the heuristic's own color is a strong prior, and
// label-text keywords can only pull the decision toward a DIFFERENT, correctly
// prioritized label when the text genuinely says so. With no keyword signal it
// keeps the heuristic color, so it is at least as good as the heuristic by
// construction and cannot randomly regress.
const KEYWORDS = [
  // color, weight, regex (tested against lowercased label)
  ['lightblue', 4.0, /needs?\s+steve|waiting on steve|blocked on steve|need (?:you|steve)|your input|awaiting steve/],
  ['orange', 3.6, /\bpaste|!\s*(?:ssh|bash|sudo|psql|open)|run this|in your console|console step|\bpastes?\b/],
  ['purple', 3.6, /gated|pending[- ]approval|awaiting approval|memo drafted|approve\/reject|approval queue/],
  ['yellow', 3.4, /needs? direction|clarify|which approach|decision fork|askuser|1 question|\bquestions?\b/],
  ['pink', 3.0, /\bparked\b|nothing left|handed off|\bdone\b|complete(?:d)?\b|finished\b/],
  ['green', 3.0, /working|monitoring|running|building|in progress|started \d|next \d|deploy|scanning|sweeping/],
];
function classifyBuiltin(state) {
  const scores = Object.fromEntries(LABELS.map((c) => [c, 0]));
  // Prior: trust the heuristic color. 'none' is a weak prior (the session painted
  // nothing), so label text is allowed to name a real color over it.
  scores[state.heuristicColor] += state.heuristicColor === 'none' ? 0.6 : 3.0;
  const text = state.label.toLowerCase();
  for (const [color, weight, re] of KEYWORDS) {
    if (re.test(text)) scores[color] += weight;
  }
  // A stopped session with no needs-Steve / working signal reads as parked, not working.
  if (state.stopped) scores.pink += 1.0;
  // Pick the argmax; break ties toward the higher semantic priority (needs-Steve first).
  let best = 'none', bestScore = -1;
  for (const c of LABELS) {
    const sc = scores[c];
    if (sc > bestScore || (sc === bestScore && PRIORITY[c] > PRIORITY[best])) {
      best = c; bestScore = sc;
    }
  }
  const sum = LABELS.reduce((a, c) => a + scores[c], 0) || 1;
  const distribution = Object.fromEntries(LABELS.map((c) => [c, +(scores[c] / sum).toFixed(3)]));
  return { color: best, confidence: +(bestScore / sum).toFixed(3), source: 'builtin', distribution };
}

// ---- daily spend cap (fail-closed) ------------------------------------------
// Sum today's desktop-dotbar rows in the cost ledger. On ANY read failure this
// returns Infinity so the cap is treated as reached -> paid path is skipped -> no spend.
function spendTodayUsd(readLedger) {
  try {
    const raw = readLedger();
    if (!raw) return 0;
    const today = new Date().toISOString().slice(0, 10);
    let sum = 0;
    for (const line of raw.split('\n')) {
      if (!line.trim()) continue;
      let e; try { e = JSON.parse(line); } catch { continue; }
      if (e && e.app === CONFIG.costApp && typeof e.cost_usd === 'number'
          && String(e.ts || '').slice(0, 10) === today) {
        sum += e.cost_usd;
      }
    }
    return sum;
  } catch {
    return Infinity; // cannot confirm under cap -> never spend
  }
}

// ---- daily call-COUNT cap (price-independent, fail-closed) -------------------
// Count today's desktop-dotbar PAID classification rows in the cost ledger — each
// paid call logs exactly one typesafe_jev row (app+api+today). This is the real
// backstop: it bounds worst-case paid volume regardless of the unknown per-call
// price. On ANY read failure returns Infinity so the cap is treated as reached ->
// paid path is skipped -> no spend. Mirrors spendTodayUsd's fail-closed discipline.
function countPaidCallsToday(readLedger) {
  try {
    const raw = readLedger();
    if (!raw) return 0;
    const today = new Date().toISOString().slice(0, 10);
    let n = 0;
    for (const line of raw.split('\n')) {
      if (!line.trim()) continue;
      let e; try { e = JSON.parse(line); } catch { continue; }
      if (e && e.app === CONFIG.costApp && e.api === CONFIG.costApiKey
          && String(e.ts || '').slice(0, 10) === today) {
        n += 1;
      }
    }
    return n;
  } catch {
    return Infinity; // cannot confirm under cap -> never spend
  }
}

// Shared default ledger reader — the SAME source the count-cap enforces on, so the
// displayed paidCallsToday (getStats) and the enforced value (classifyDots) can't diverge.
function defaultReadLedger() {
  try { return fs.readFileSync(CONFIG.ledgerPath, 'utf8'); } catch { return ''; }
}

function logPaidCall(logSpend) {
  // Log every paid call to the cost ledger via cost-tracker's log.js. Best-effort:
  // a logging failure must never crash a refresh (but is counted).
  try { logSpend(); } catch { /* counted by caller */ }
}
function defaultLogSpend() {
  execFile(process.execPath, [CONFIG.costLogJs, '--api', CONFIG.costApiKey,
    '--units', '1:call', '--app', CONFIG.costApp, '--note', 'dotbar color classification'],
    { timeout: 4000 }, () => {});
}

// ---- paid TypeSafe backend (UNREACHABLE unless DOTBAR_JEV_PAID=1) ------------
// Reuses jev-model-router's typed-choice wire shape (choice question + per-answer
// confidence), adding only the new state->dot-color schema. Reads the key lazily
// and ONLY when armed, so with the flag off nothing is ever read or sent.
function readTypesafeKey() {
  if (process.env.TYPESAFE_API_KEY) return process.env.TYPESAFE_API_KEY;
  try {
    const env = fs.readFileSync(path.join(os.homedir(), 'Projects', 'secrets-manager', '.env'), 'utf8');
    const m = /^TYPESAFE_API_KEY=(.+)$/m.exec(env);
    return m ? m[1].trim() : '';
  } catch { return ''; }
}
const DOT_CRITERIA = {
  lightblue: 'The session has stopped and needs Steve: any stop that requires his input.',
  orange: 'A paste is waiting: a shell command / console step Steve must run himself.',
  purple: 'Gated: a memo is drafted to pending-approval awaiting Steve\'s approve/reject.',
  yellow: 'Needs direction: a question or decision fork is waiting on Steve\'s answer.',
  green: 'Working: a process, loop, or agent is actively executing or monitoring.',
  pink: 'Parked: work handed off or complete, nothing left to progress.',
  none: 'No dot / no discernible state.',
};
function typeSafeClassify(state, deps) {
  const key = (deps && deps.key) || readTypesafeKey();
  if (!key) return Promise.reject(new Error('no TYPESAFE_API_KEY'));
  const body = JSON.stringify({
    model: 'jev-latest',
    state: { heuristicColor: state.heuristicColor, label: state.label, stopped: state.stopped },
    questions: {
      dot: {
        type: 'choice',
        instructions: 'Which dot color best classifies this terminal\'s current state?',
        criteria: DOT_CRITERIA,
      },
    },
  });
  return new Promise((resolve, reject) => {
    const req = https.request('https://api.typesafe.ai/v1/systemone', {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${key}`,
        'content-length': Buffer.byteLength(body) },
      timeout: CONFIG.timeoutMs,
    }, (res) => {
      let out = '';
      res.on('data', (c) => (out += c));
      res.on('end', () => {
        if (res.statusCode < 200 || res.statusCode >= 300) return reject(new Error(`http ${res.statusCode}`));
        try {
          const ans = JSON.parse(out).answers && JSON.parse(out).answers.dot;
          if (ans && LABELS.includes(ans.choice)) {
            resolve({ color: ans.choice, confidence: typeof ans.confidence === 'number' ? ans.confidence : null, source: 'typesafe' });
          } else reject(new Error('bad answer shape'));
        } catch (e) { reject(e); }
      });
    });
    req.on('error', reject);
    req.on('timeout', () => { req.destroy(new Error('timeout')); });
    req.end(body);
  });
}

// ---- the seam server.js calls ----------------------------------------------
// rows: allcolordots --json rows (already filtered to active/non-parked by getDots).
// opts: dependency-injection seam for tests (paidEnabled, capUsd, readLedger,
//       paidTransport, logSpend). Production uses the real env/files.
// Returns Map<tty, {color, source, confidence}>. Never throws.
async function classifyDots(rows, opts = {}) {
  const paidEnabled = opts.paidEnabled !== undefined ? opts.paidEnabled : CONFIG.paidEnabled;
  const capUsd = opts.capUsd !== undefined ? opts.capUsd : CONFIG.capUsd;
  const maxPaidCallsPerDay = opts.maxPaidCallsPerDay !== undefined ? opts.maxPaidCallsPerDay : CONFIG.maxPaidCalls;
  const readLedger = opts.readLedger || defaultReadLedger;
  const paidTransport = opts.paidTransport || typeSafeClassify;
  const logSpend = opts.logSpend || defaultLogSpend;

  const seen = new Set();
  const misses = [];
  for (const row of rows || []) {
    if (!row || !row.tty) continue;
    seen.add(row.tty);
    const state = extractState(row);
    const hash = stateHash(state);
    const cached = cache.get(row.tty);
    if (cached && cached.hash === hash) { stats.cacheHits++; continue; }
    stats.cacheMisses++;
    misses.push({ tty: row.tty, state, hash });
  }

  // Compute today's spend AND paid-call COUNT ONCE per refresh so a burst of misses
  // can't each blow past either cap before the ledger flushes.
  let spend = 0;
  let paidCount = 0;
  if (paidEnabled) {
    spend = spendTodayUsd(readLedger);
    stats.lastSpendUsd = Number.isFinite(spend) ? spend : stats.lastSpendUsd;
    paidCount = countPaidCallsToday(readLedger);
  }

  for (const m of misses) {
    stats.classifications++;
    let result = null;
    const underDollarCap = spend < capUsd;
    const underCountCap = paidCount < maxPaidCallsPerDay;
    // Paid path is structurally UNREACHABLE unless armed AND provably under BOTH caps.
    // The two caps compose: whichever limit is hit first wins.
    if (paidEnabled && underDollarCap && underCountCap) {
      try {
        result = await paidTransport(m.state, {});
        stats.paidCalls++;
        logPaidCall(logSpend);
        spend += 0.001;   // reserve this call's $ so the in-loop $ cap holds before the ledger flushes
        paidCount += 1;   // reserve this call's COUNT so a burst within one refresh can't exceed the count cap
      } catch {
        stats.paidFellBack++;
        result = classifyBuiltin(m.state); stats.builtinCalls++;
      }
    } else {
      // armed but a cap reached -> fall back, no spend. Attribute the block to the
      // binding cap ($ checked first, matching the original single-cap behavior).
      if (paidEnabled && !underDollarCap) stats.capBlocked++;
      else if (paidEnabled && !underCountCap) stats.countCapBlocked++;
      result = classifyBuiltin(m.state); stats.builtinCalls++;
    }
    cache.set(m.tty, { hash: m.hash, color: result.color, source: result.source, confidence: result.confidence });
  }

  // Prune cache entries for ttys that vanished (keep it bounded).
  for (const tty of cache.keys()) if (!seen.has(tty)) cache.delete(tty);

  stats.updated = Date.now();
  const out = new Map();
  for (const tty of seen) { const c = cache.get(tty); if (c) out.set(tty, { color: c.color, source: c.source, confidence: c.confidence }); }
  return out;
}

function getStats(readLedger = defaultReadLedger) {
  // paidCallsToday is computed LIVE from the same ledger the count-cap enforces on,
  // so what /api/jev shows == what the cap actually counts (no per-process drift).
  // A fail-closed read (Infinity) surfaces as null = "unreadable -> cap treats as at-cap".
  const n = countPaidCallsToday(readLedger);
  return { ...stats, paidCallsToday: Number.isFinite(n) ? n : null, cacheSize: cache.size };
}
function _resetForTest() { cache.clear(); for (const k of Object.keys(stats)) if (typeof stats[k] === 'number') stats[k] = 0; }

module.exports = { classifyDots, classifyBuiltin, extractState, stateHash, spendTodayUsd, countPaidCallsToday, getStats, LABELS, _resetForTest };