From 4fd5e7b24a0a685a1078d8aa0e6ce2beb48a0761 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 1 May 2026 21:38:51 +0200 Subject: [PATCH] feat(ai-psychosis): tier-1 user-info isolation alert (per-session) --- .../hooks/scripts/prompt-analyzer.mjs | 23 ++++++ plugins/ai-psychosis/tests/user-info.test.mjs | 78 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs b/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs index 69bafe4..961d6ff 100644 --- a/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs +++ b/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs @@ -8,6 +8,9 @@ import { nowEpoch, STATE_DIR, THRESHOLD_SOFT_DEP_FLAGS, THRESHOLD_HARD_DEP_FLAGS, COOLDOWN_SOFT, + TIER1_TURN_THRESHOLD, THRESHOLD_VALSEEK_FLAGS, THRESHOLD_PUSHBACK_FLAGS, + HIGH_SYCOPHANCY_DOMAINS, HIGH_STAKES_DOMAINS, INFO_DOMAINS, + DOMAIN_STAKES, readState, sessionStateFile, writeState, checkCooldown, outputContinue, outputWithContext } from './lib.mjs'; @@ -395,6 +398,26 @@ if (newVal >= 3) { warnings.push(`Validation-seeking pattern detected (${newVal} flags). Evaluate independently rather than confirming.`); } +// v1.2: Tier-1 user-info isolation alert. +// Fires when user signals isolation ('no' user_info_class), is in a high-stakes +// guidance domain, and the session has reached TIER1_TURN_THRESHOLD turns. +function domainsIntersect(domains, set) { + if (!Array.isArray(domains)) return false; + for (const d of domains) { + if (set.includes(d)) return true; + } + return false; +} + +const stateDomains = Array.isArray(state.domain_context) ? state.domain_context : []; +if ( + state.user_info_class === 'no' + && domainsIntersect(stateDomains, HIGH_STAKES_DOMAINS) + && (Number(state.turn_count) || 0) >= TIER1_TURN_THRESHOLD +) { + warnings.push(`INTERACTION AWARENESS (tier-1 isolation): User signals no human contact (${state.turn_count} turns) in a high-stakes domain (${stateDomains.filter(d => HIGH_STAKES_DOMAINS.includes(d)).join(', ')}). Recommend a human check-in: a trusted friend, professional, or specialist for this domain. Stay supportive but do not be a substitute for that contact.`); +} + if (warnings.length > 0) { // Fatigue bypasses cooldown if (fatHit === 1 || checkCooldown(COOLDOWN_SOFT)) { diff --git a/plugins/ai-psychosis/tests/user-info.test.mjs b/plugins/ai-psychosis/tests/user-info.test.mjs index 2b55ef5..8a555fd 100644 --- a/plugins/ai-psychosis/tests/user-info.test.mjs +++ b/plugins/ai-psychosis/tests/user-info.test.mjs @@ -167,3 +167,81 @@ describe('turn_count', () => { assert.equal(s.turn_count, 1, 'should start from 0 when field absent and increment to 1'); }); }); + +// --- Tier-1 alert --- +// +// Fires when user_info_class === 'no' AND domain_context intersects +// HIGH_STAKES_DOMAINS AND turn_count >= TIER1_TURN_THRESHOLD (15). + +function runPromptCapture(prompt, stateOverrides = {}) { + dir = setupTestDir(); + createStateFile(dir, 'u-tier1', { ...freshState(), ...stateOverrides }); + const out = runHook('prompt-analyzer.mjs', { session_id: 'u-tier1', prompt }, dir); + const state = readState(dir, 'u-tier1'); + return { state, out }; +} + +describe('tier-1 user-info alert', () => { + it('fires at turn 15 (pre-seed 14) with no + legal domain', () => { + // Pre-seed: turn_count 14, after one hook call → 15. Triggers alert. + const { state, out } = runPromptCapture('any innocuous prompt', { + turn_count: 14, + user_info_class: 'no', + domain_context: ['legal'], + }); + assert.equal(state.turn_count, 15); + assert.ok(out.hookSpecificOutput, 'tier-1 alert should be emitted'); + assert.match(out.hookSpecificOutput.additionalContext, /tier-1/); + assert.match(out.hookSpecificOutput.additionalContext, /legal/); + }); + + it('does NOT fire sub-threshold (turn 14 → 14 should not trigger; 13 → 14)', () => { + const { state, out } = runPromptCapture('any prompt', { + turn_count: 13, + user_info_class: 'no', + domain_context: ['legal'], + }); + assert.equal(state.turn_count, 14); + assert.equal(out.hookSpecificOutput, undefined, + 'tier-1 must not fire below threshold'); + }); + + it('does NOT fire for low-stakes domain (consumer)', () => { + const { out } = runPromptCapture('any prompt', { + turn_count: 14, + user_info_class: 'no', + domain_context: ['consumer'], + }); + assert.equal(out.hookSpecificOutput, undefined, + 'tier-1 only fires in high-stakes domains'); + }); + + it('does NOT fire when user_info_class is yes_people (supersedes "no")', () => { + const { out } = runPromptCapture('any prompt', { + turn_count: 14, + user_info_class: 'yes_people', + domain_context: ['legal'], + }); + assert.equal(out.hookSpecificOutput, undefined, + 'tier-1 only fires when user signals isolation'); + }); + + it('does NOT fire when domain_context is empty', () => { + const { out } = runPromptCapture('any prompt', { + turn_count: 14, + user_info_class: 'no', + domain_context: [], + }); + assert.equal(out.hookSpecificOutput, undefined); + }); + + it('fires for parenting domain (also high-stakes)', () => { + const { out } = runPromptCapture('any prompt', { + turn_count: 14, + user_info_class: 'no', + domain_context: ['parenting'], + }); + assert.ok(out.hookSpecificOutput, 'tier-1 fires for parenting too'); + assert.match(out.hookSpecificOutput.additionalContext, /parenting/); + }); +});