// Interaction Awareness — Shared library for Layer 2 hooks (Node.js) // Imported by all hook scripts. Cross-platform: macOS, Linux, Windows. // Zero npm dependencies — Node.js stdlib only. import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, unlinkSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; // --- Stdin --- let _input = {}; export function readStdin() { try { const raw = readFileSync(0, 'utf8'); _input = JSON.parse(raw); } catch { _input = {}; } } export function getField(key) { return _input[key] ?? ''; } export function getSessionId() { return getField('session_id'); } export function getToolName() { return getField('tool_name'); } export function getInput() { return _input; } // --- Paths --- const PLUGIN_DATA = process.env.CLAUDE_PLUGIN_DATA || join(homedir(), '.claude', 'plugins', 'data', 'ai-psychosis'); export const DATA_DIR = PLUGIN_DATA; export const SESSIONS_LOG = join(DATA_DIR, 'sessions.jsonl'); export const EVENTS_LOG = join(DATA_DIR, 'events.jsonl'); export const STATE_DIR = join(DATA_DIR, 'state'); // --- Layer configuration --- let LAYER2_ENABLED = true; let LAYER3_ENABLED = false; let LAYER4_ENABLED = false; export function initConfig() { const cwd = getField('cwd'); // Project-level config takes precedence over global const candidates = []; if (cwd) candidates.push(join(cwd, '.claude', 'ai-psychosis.local.md')); candidates.push(join(homedir(), '.claude', 'ai-psychosis.local.md')); let content; for (const configFile of candidates) { try { content = readFileSync(configFile, 'utf8'); break; } catch { /* try next */ } } if (!content) return; const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return; const frontmatter = match[1]; for (const line of frontmatter.split('\n')) { const m = line.match(/^(layer[234]):\s*(.+)/); if (!m) continue; const val = m[2].trim().replace(/^["']|["']$/g, ''); if (m[1] === 'layer2') LAYER2_ENABLED = val === 'true'; if (m[1] === 'layer3') LAYER3_ENABLED = val === 'true'; if (m[1] === 'layer4') LAYER4_ENABLED = val === 'true'; } } export function requireLayer(n) { let enabled = false; if (n === 2) enabled = LAYER2_ENABLED; if (n === 3) enabled = LAYER3_ENABLED; if (n === 4) enabled = LAYER4_ENABLED; if (!enabled) { outputContinue(); process.exit(0); } } // --- Time helpers --- export function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); } export function nowEpoch() { return Math.floor(Date.now() / 1000); } export function currentHour() { return new Date().getHours(); } export function isLateNight() { const h = currentHour(); return h >= 23 || h < 5; } // --- Thresholds --- export const THRESHOLD_SOFT_DURATION = 90; export const THRESHOLD_HARD_DURATION = 180; export const THRESHOLD_SOFT_SESSIONS = 6; export const THRESHOLD_HARD_SESSIONS = 10; export const THRESHOLD_SOFT_BURST = 5; export const THRESHOLD_HARD_BURST = 10; export const THRESHOLD_BURST_INTERVAL = 30; export const THRESHOLD_LOW_EDIT_RATIO = 10; export const THRESHOLD_LOW_EDIT_MIN_DURATION = 30; export const THRESHOLD_SOFT_DEP_FLAGS = 2; export const THRESHOLD_HARD_DEP_FLAGS = 5; export const COOLDOWN_SOFT = 1800; export const COOLDOWN_HARD = 3600; // v1.1.0 — counting threshold; tier-reduction logic is v1.2 scope export const THRESHOLD_PUSHBACK_FLAGS = 2; // --- v1.2 thresholds and domain-stakes table --- // // Sources: Anthropic guidance paper Appendix (April 2026), Figure A1 (stakes), // Figure A4 (domain pushback rates). All domain identifiers are SINGULAR to // match v1.1.0's `state.domain_context = 'relationship'` convention. export const TIER1_TURN_THRESHOLD = 15; export const TIER2_SESSION_THRESHOLD = 3; export const THRESHOLD_VALSEEK_FLAGS = 3; // Domain stakes weights — Figure A1 high/very-high stakes domains carry // higher multipliers; consumer/personal_dev are baseline 1.0. export const DOMAIN_STAKES = Object.freeze({ legal: 1.5, parenting: 1.5, health: 1.5, financial: 1.5, relationship: 1.3, spirituality: 1.2, professional: 1.1, wellbeing: 1.2, lifepath: 1.1, values: 1.2, personal_dev: 1.0, consumer: 1.0, default: 1.0 }); // Pushback in these domains signals validation-pressing (Figure A4 — relationships // 21%, spirituality 19%); pushback alert fires. export const HIGH_SYCOPHANCY_DOMAINS = Object.freeze(['relationship', 'spirituality']); // High-stakes guidance domains (Figure A1 high/very-high). Tier-1 user-info // alert fires only when domain_context intersects this set. export const HIGH_STAKES_DOMAINS = Object.freeze(['legal', 'parenting', 'health', 'financial']); // Info-seeking domains where pushback signals healthy self-advocacy (Figure A4 — // parenting 7.9%, legal/health/financial 80–94% pushback rate). Pushback alert // is suppressed when domain_context is entirely within this set. export const INFO_DOMAINS = Object.freeze(['legal', 'parenting', 'health', 'financial', 'professional']); // --- Session counting --- export function sessionsToday() { const today = new Date().toISOString().slice(0, 10); if (!existsSync(SESSIONS_LOG)) return 0; try { const lines = readFileSync(SESSIONS_LOG, 'utf8').split('\n').filter(Boolean); const ids = new Set(); for (const line of lines) { try { const rec = JSON.parse(line); if (rec.start && rec.start.startsWith(today)) { ids.add(rec.session_id); } } catch { /* skip malformed lines */ } } return ids.size; } catch { return 0; } } // Tail-first scan: return the N most recent end records (records with // duration_min defined) in chronological order. Cost is bounded by N, not // by total file size — a 50K-record sessions.jsonl is read once but only // the last few hundred lines are JSON-parsed before N is satisfied. export function readRecentEndRecords(n) { if (!Number.isFinite(n) || n <= 0) return []; if (!existsSync(SESSIONS_LOG)) return []; let lines; try { lines = readFileSync(SESSIONS_LOG, 'utf8').split('\n'); } catch { return []; } const collected = []; for (let i = lines.length - 1; i >= 0 && collected.length < n; i--) { const line = lines[i]; if (!line) continue; try { const rec = JSON.parse(line); if (rec.duration_min !== undefined) { collected.push(rec); } } catch { /* skip malformed */ } } // Reverse so caller receives oldest-first (chronological order). return collected.reverse(); } // --- State file management --- export function sessionStateFile(sid) { sid = sid || getSessionId(); return join(STATE_DIR, `${sid}.json`); } export function readState(sid) { const sf = sessionStateFile(sid); try { return JSON.parse(readFileSync(sf, 'utf8')); } catch { return {}; } } export function getStateField(key, sid) { const state = readState(sid); return state[key] ?? ''; } export function getStateInt(key, sid) { const state = readState(sid); return Math.floor(Number(state[key]) || 0); } export function writeState(obj, sid) { const sf = sessionStateFile(sid); writeFileSync(sf, JSON.stringify(obj, null, 2) + '\n'); } export function updateStateField(key, value, sid) { const state = readState(sid); state[key] = value; writeState(state, sid); } export function incrementStateField(key, sid) { const state = readState(sid); state[key] = (Number(state[key]) || 0) + 1; writeState(state, sid); } // --- Cooldown --- export function checkCooldown(cooldownSecs, sid) { const lastWarning = getStateInt('last_warning_epoch', sid); const now = nowEpoch(); return (now - lastWarning) >= cooldownSecs; } export function recordWarning(sid) { const state = readState(sid); state.last_warning_epoch = nowEpoch(); writeState(state, sid); } // --- Output helpers --- export function outputContinue() { process.stdout.write(JSON.stringify({ continue: true }) + '\n'); } export function outputWithContext(message) { process.stdout.write(JSON.stringify({ continue: true, hookSpecificOutput: { additionalContext: message } }) + '\n'); } // --- File helpers --- export function ensureDir(dir) { mkdirSync(dir, { recursive: true }); } export function appendJsonl(file, obj) { appendFileSync(file, JSON.stringify(obj) + '\n'); } export function removeFile(file) { try { unlinkSync(file); } catch { /* ignore if missing */ } }