← 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 };