From ca6567b5019ccf3254c73466bcd08faf2f31f53f Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 1 May 2026 21:34:52 +0200 Subject: [PATCH] feat(ai-psychosis): add user-info detector (yes_people/yes_digital/no) --- .../hooks/scripts/prompt-analyzer.mjs | 83 +++++++++ .../hooks/scripts/session-start.mjs | 7 + plugins/ai-psychosis/tests/user-info.test.mjs | 169 ++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 plugins/ai-psychosis/tests/user-info.test.mjs diff --git a/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs b/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs index 27d9031..0a602c3 100644 --- a/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs +++ b/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs @@ -183,6 +183,55 @@ const domainPersonalDevPatterns = [ /\b(?:improve|grow|level up) (?:myself|my (?:self-discipline|focus|productivity))\b/i, ]; +// v1.2: User-information dimension (paper page 11). Three classes — yes_people, +// yes_digital, no. Priority: yes_people > yes_digital > no. Sticky for session. +// +// "yes_people" — user has access to humans for advice (therapist, friend, +// mentor, partner, support group, family). +const userInfoPeoplePatterns = [ + /\bmy (?:therapist|counselor|psychologist|psychiatrist)\b/i, + /\bmy (?:doctor|gp|physician|specialist)\b/i, + /\bmy (?:friend|best friend|close friend)\b/i, + /\bmy (?:partner|spouse|wife|husband|girlfriend|boyfriend)\b/i, + /\bmy (?:mom|dad|mother|father|parent|sibling|sister|brother)\b/i, + /\bmy (?:mentor|coach|advisor|sponsor)\b/i, + /\bmy support group\b/i, + /\bI (?:asked|talked to|spoke with|consulted) (?:my|a) (?:friend|therapist|doctor|mentor)\b/i, + /\bI (?:told|confided in) (?:my|a) (?:friend|therapist|partner|family)\b/i, + /\bmy (?:family|relatives) (?:said|told|think|suggest)\b/i, + /\bmy (?:lawyer|attorney|legal counsel)\b/i, + /\bmy (?:pastor|priest|rabbi|imam|spiritual (?:teacher|guide))\b/i, + /\bmy (?:teacher|professor|tutor)\b/i, + /\bmy (?:colleague|coworker|boss|manager)\b/i, + /\bI (?:reached out|called) (?:to )?(?:my|a) (?:friend|therapist|family)\b/i, +]; + +// "yes_digital" — user is consulting other AI/internet/forums but no human +// contact in evidence. +const userInfoDigitalPatterns = [ + /\bI (?:googled|searched|looked (?:it|this) up online)\b/i, + /\bI read (?:online|on the internet|on a forum|on reddit|on stack overflow)\b/i, + /\b(?:chatgpt|gpt|gemini|copilot|another ai|the other ai) (?:said|told|suggested|recommended)\b/i, + /\b(?:I |we )?(?:found|saw) (?:an? |the )?(?:forum post|reddit thread|article|blog post)\b/i, + /\b(?:youtube|tiktok|twitter|x\.com|instagram) (?:video|post|thread)\b/i, + /\baccording to (?:wikipedia|google|the internet|the article)\b/i, + /\b(?:I asked|asked) (?:chatgpt|gpt|gemini|claude|another ai|copilot)\b/i, + /\b(?:online|the internet) (?:says|claims|suggests)\b/i, + /\bsearched (?:for|on) (?:google|stackoverflow|github)\b/i, + /\bi watched (?:a youtube|videos? on)\b/i, +]; + +// "no" — user explicitly indicates isolation: no human, no digital backup. +const userInfoNoPatterns = [ + /\b(?:nobody|no one) knows\b/i, + /\bI haven'?t told (?:anyone|anybody|anything to anyone)\b/i, + /\bdealing with this alone\b/i, + /\bI (?:can'?t|cannot) tell (?:anyone|anybody|my (?:family|friends|therapist))\b/i, + /\b(?:I|we) keep (?:this|it) (?:to myself|secret|hidden)\b/i, + /\bnobody (?:in my life|around me) (?:would understand|gets it)\b/i, + /\bjust me (?:and|with) (?:my|the) (?:thoughts|head|computer|claude)\b/i, +]; + for (const p of depPatterns) { if (p.test(prompt)) { depHit = 1; break; } } for (const p of escPatterns) { if (p.test(prompt)) { escHit = 1; break; } } for (const p of fatPatterns) { if (p.test(prompt)) { fatHit = 1; break; } } @@ -202,6 +251,11 @@ let domainSpiritualityHit = 0; for (const p of domainSpiritualityPatterns) { if let domainConsumerHit = 0; for (const p of domainConsumerPatterns) { if (p.test(prompt)) { domainConsumerHit = 1; break; } } let domainPersonalDevHit = 0; for (const p of domainPersonalDevPatterns) { if (p.test(prompt)) { domainPersonalDevHit = 1; break; } } +// v1.2: User-info detection — three classes with priority yes_people > yes_digital > no. +let userInfoPeopleHit = 0; for (const p of userInfoPeoplePatterns) { if (p.test(prompt)) { userInfoPeopleHit = 1; break; } } +let userInfoDigitalHit = 0; for (const p of userInfoDigitalPatterns) { if (p.test(prompt)) { userInfoDigitalHit = 1; break; } } +let userInfoNoHit = 0; for (const p of userInfoNoPatterns) { if (p.test(prompt)) { userInfoNoHit = 1; break; } } + // Clear prompt from memory prompt = ''; @@ -214,6 +268,11 @@ if (fatHit === 1 || escHit === 1) { // Update state with new flag counts const state = readState(); + +// v1.2: turn_count drives tier-1 user-info alert (Step 9). Defaults to 0 for +// pre-v1.2 state files; session-start.mjs seeds it for fresh v1.2 sessions. +state.turn_count = (Number(state.turn_count) || 0) + 1; + const newDep = (Number(state.dep_flags) || 0) + depHit; const newEsc = (Number(state.esc_flags) || 0) + escHit; const newFat = (Number(state.fatigue_flags) || 0) + fatHit; @@ -225,6 +284,30 @@ state.fatigue_flags = newFat; state.val_flags = newVal; state.pushback_count = (Number(state.pushback_count) || 0) + pbReactiveHit + pbPreemptiveHit; +// v1.2: user-info classification (paper page 11). Priority yes_people > yes_digital > no. +// Class is sticky for the session — once set to a "stronger" signal, never +// downgrades. Counters always accumulate regardless of class transitions. +if (!state.user_info_flags || typeof state.user_info_flags !== 'object') { + state.user_info_flags = { yes_people: 0, yes_digital: 0, no: 0 }; +} +if (userInfoPeopleHit) state.user_info_flags.yes_people = (state.user_info_flags.yes_people || 0) + 1; +if (userInfoDigitalHit) state.user_info_flags.yes_digital = (state.user_info_flags.yes_digital || 0) + 1; +if (userInfoNoHit) state.user_info_flags.no = (state.user_info_flags.no || 0) + 1; + +// Class priority: people > digital > no. Sticky upward, never downward. +const RANK = { yes_people: 3, yes_digital: 2, no: 1 }; +let nextClass = state.user_info_class || null; +const candidate = userInfoPeopleHit ? 'yes_people' + : userInfoDigitalHit ? 'yes_digital' + : userInfoNoHit ? 'no' + : null; +if (candidate) { + const currentRank = nextClass ? (RANK[nextClass] || 0) : 0; + const candidateRank = RANK[candidate] || 0; + if (candidateRank > currentRank) nextClass = candidate; +} +state.user_info_class = nextClass; + // v1.2: domain_context is always an array. Coerce v1.1.0 string shape on read. const anyDomainHit = domainHit || domainLegalHit || domainParentingHit || domainHealthHit diff --git a/plugins/ai-psychosis/hooks/scripts/session-start.mjs b/plugins/ai-psychosis/hooks/scripts/session-start.mjs index 7e959dd..8be6419 100644 --- a/plugins/ai-psychosis/hooks/scripts/session-start.mjs +++ b/plugins/ai-psychosis/hooks/scripts/session-start.mjs @@ -40,6 +40,13 @@ const state = { val_flags: 0, pushback_count: 0, domain_context: null, + // v1.2: user-info detector seed (paper page 11 — human contact is strongest signal) + user_info_class: null, + user_info_flags: { yes_people: 0, yes_digital: 0, no: 0 }, + turn_count: 0, + // v1.2: validation-seeking detector seed + valseek_count: 0, + valseek_flag: 0, last_warning_epoch: 0 }; writeState(state); diff --git a/plugins/ai-psychosis/tests/user-info.test.mjs b/plugins/ai-psychosis/tests/user-info.test.mjs new file mode 100644 index 0000000..2b55ef5 --- /dev/null +++ b/plugins/ai-psychosis/tests/user-info.test.mjs @@ -0,0 +1,169 @@ +// user-info.test.mjs — verifies v1.2 user-information classifier. +// +// Three classes: yes_people > yes_digital > no (priority order). +// Class is sticky upward — yes_people once set never downgrades. +// turn_count increments on every prompt-analyzer invocation. +// Step 9 will add the tier-1 alert; this file currently locks the +// detection + sticky semantics. + +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { runHook, setupTestDir, cleanupTestDir, createStateFile, readState } from './test-helper.mjs'; + +let dir; +afterEach(() => { if (dir) cleanupTestDir(dir); }); + +function freshState() { + return { + start_epoch: Math.floor(Date.now() / 1000) - 60, + start_iso: '2026-05-01T10:00:00Z', + tool_count: 0, edit_count: 0, + last_event_epoch: 0, burst_count: 0, + dep_flags: 0, esc_flags: 0, fatigue_flags: 0, val_flags: 0, + pushback_count: 0, domain_context: null, + user_info_class: null, + user_info_flags: { yes_people: 0, yes_digital: 0, no: 0 }, + turn_count: 0, + valseek_count: 0, valseek_flag: 0, + last_warning_epoch: 0, + }; +} + +function runPrompt(prompt, stateOverrides = {}) { + dir = setupTestDir(); + createStateFile(dir, 'u1', { ...freshState(), ...stateOverrides }); + runHook('prompt-analyzer.mjs', { session_id: 'u1', prompt }, dir); + return readState(dir, 'u1'); +} + +// --- yes_people detection --- + +describe('user_info: yes_people patterns', () => { + it('matches "my therapist"', () => { + const s = runPrompt('I asked my therapist about this'); + assert.equal(s.user_info_class, 'yes_people'); + assert.equal(s.user_info_flags.yes_people, 1); + }); + + it('matches "my friend"', () => { + const s = runPrompt('my friend says I should try meditation'); + assert.equal(s.user_info_class, 'yes_people'); + }); + + it('matches "my mentor"', () => { + const s = runPrompt('my mentor mentioned this approach'); + assert.equal(s.user_info_class, 'yes_people'); + }); + + it('matches "I told my partner"', () => { + const s = runPrompt('I told my partner about it last night'); + assert.equal(s.user_info_class, 'yes_people'); + }); +}); + +describe('user_info: yes_digital patterns', () => { + it('matches "I googled"', () => { + const s = runPrompt('I googled this and got mixed results'); + assert.equal(s.user_info_class, 'yes_digital'); + }); + + it('matches "ChatGPT said"', () => { + const s = runPrompt('ChatGPT said the answer was 42'); + assert.equal(s.user_info_class, 'yes_digital'); + }); + + it('matches "I read on a forum post"', () => { + const s = runPrompt('I read on a forum post that this works'); + assert.equal(s.user_info_class, 'yes_digital'); + }); +}); + +describe('user_info: no patterns', () => { + it('matches "nobody knows"', () => { + const s = runPrompt("nobody knows I'm dealing with this"); + assert.equal(s.user_info_class, 'no'); + }); + + it('matches "haven\'t told anyone"', () => { + const s = runPrompt("I haven't told anyone about it"); + assert.equal(s.user_info_class, 'no'); + }); + + it('matches "dealing with this alone"', () => { + const s = runPrompt("I'm dealing with this alone"); + assert.equal(s.user_info_class, 'no'); + }); +}); + +// --- Priority + sticky semantics --- + +describe('user_info: priority and stickiness', () => { + it('yes_people wins over yes_digital in same prompt', () => { + const s = runPrompt("I googled it but my therapist said something else"); + assert.equal(s.user_info_class, 'yes_people'); + // Both counters increment regardless of class outcome. + assert.equal(s.user_info_flags.yes_people, 1); + assert.equal(s.user_info_flags.yes_digital, 1); + }); + + it('yes_people wins over no in same prompt', () => { + const s = runPrompt("nobody knows but I told my friend"); + assert.equal(s.user_info_class, 'yes_people'); + }); + + it('yes_digital wins over no in same prompt', () => { + const s = runPrompt("nobody knows except what I read on a forum post"); + assert.equal(s.user_info_class, 'yes_digital'); + }); + + it('sticky: yes_people set, later yes_digital prompt does NOT downgrade', () => { + dir = setupTestDir(); + createStateFile(dir, 'u-sticky', freshState()); + runHook('prompt-analyzer.mjs', { session_id: 'u-sticky', prompt: 'my therapist suggested journaling' }, dir); + runHook('prompt-analyzer.mjs', { session_id: 'u-sticky', prompt: 'I googled the rest' }, dir); + const s = readState(dir, 'u-sticky'); + assert.equal(s.user_info_class, 'yes_people', 'must not downgrade from people to digital'); + assert.equal(s.user_info_flags.yes_digital, 1, 'digital counter still increments'); + }); + + it('sticky: no → yes_people upgrades (lower → higher rank)', () => { + dir = setupTestDir(); + createStateFile(dir, 'u-up', freshState()); + runHook('prompt-analyzer.mjs', { session_id: 'u-up', prompt: 'nobody knows about this' }, dir); + runHook('prompt-analyzer.mjs', { session_id: 'u-up', prompt: 'finally told my therapist' }, dir); + const s = readState(dir, 'u-up'); + assert.equal(s.user_info_class, 'yes_people'); + }); + + it('class stays null when no user-info patterns hit', () => { + const s = runPrompt('refactor this typescript module to use generics'); + assert.equal(s.user_info_class, null); + assert.equal(s.user_info_flags.yes_people, 0); + assert.equal(s.user_info_flags.yes_digital, 0); + assert.equal(s.user_info_flags.no, 0); + }); +}); + +// --- turn_count --- + +describe('turn_count', () => { + it('increments on every prompt-analyzer call', () => { + dir = setupTestDir(); + createStateFile(dir, 'u-turn', freshState()); + for (let i = 0; i < 5; i++) { + runHook('prompt-analyzer.mjs', { session_id: 'u-turn', prompt: `prompt ${i}` }, dir); + } + const s = readState(dir, 'u-turn'); + assert.equal(s.turn_count, 5); + }); + + it('handles missing turn_count in pre-v1.2 state files (defaults to 0)', () => { + const legacy = freshState(); + delete legacy.turn_count; + dir = setupTestDir(); + createStateFile(dir, 'u-legacy', legacy); + runHook('prompt-analyzer.mjs', { session_id: 'u-legacy', prompt: 'hello' }, dir); + const s = readState(dir, 'u-legacy'); + assert.equal(s.turn_count, 1, 'should start from 0 when field absent and increment to 1'); + }); +});