ktg-plugin-marketplace/plugins/ai-psychosis/hooks/scripts/lib.mjs

241 lines
5.8 KiB
JavaScript

// 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;
// --- 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;
}
}
// --- 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 */ }
}