local ADDON_NAME, RPAL = ...

-- ============================================================
-- ODYN Persona Beta — Cognitive Event Layer
--
-- RP INPUT -> CONTEXT -> EVENT -> APPRAISAL -> EMOTION/STATE
--          -> ACTIVE CONCERN -> THOUGHT INTENT -> VOICE -> THOUGHT
--
-- The goal of this layer is conservative, explainable RP interpretation:
-- strong patterns combine with recent-turn context, TRP relationship importance,
-- and learned character traits. Every
-- cognitive thought keeps a WHY record so a player can inspect its cause.
-- ============================================================

local VERSION = 13
local MAX_EVENTS = 96
local MAX_CONCERNS = 24
local MAX_CONTEXT = 80
local MAX_WHY = 120
local MAX_DECISIONS = 160
local MAX_UNCLASSIFIED = 80
local THREAD_WINDOW = 120 -- ownership is intentionally short-lived; nearby RP must not become "my conversation" by inertia

local function trim(s) return tostring(s or ""):gsub("^%s+",""):gsub("%s+$","") end
local function clamp(v,a,b) v=tonumber(v) or a;if v<a then return a elseif v>b then return b end;return v end
local function now() return time and time() or 0 end
local function gtime() return GetTime and GetTime() or 0 end
local function clean(s)
    s=tostring(s or "");if RPAL.StripPersonaLanguageMarkers then s=RPAL.StripPersonaLanguageMarkers(s)end
    s=s:gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
    s=s:gsub("|H.-|h(.-)|h","%1"):gsub("|T.-|t",""):gsub("|A.-|a","")
    s=s:gsub("’", "'"):gsub("‘", "'"):gsub("“", '"'):gsub("”", '"')
    s=s:gsub("—"," - "):gsub("–"," - "):gsub("[\r\n]+"," "):gsub("%s+"," ")
    return trim(s):lower()
end
local function stripRealm(v) return trim(v):match("^[^-]+") or trim(v) end
local function norm(v) return stripRealm(v):lower():gsub("[^%w']","") end
local function shortFirst(v)
    local s=trim(v):gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
    return s:match("^([^%s%-]+)") or stripRealm(s)
end
local function pushFront(t,row,max) table.insert(t,1,row);while #t>(max or 50) do table.remove(t) end end
local function contains(text,patterns)
    for _,p in ipairs(patterns or {}) do if text:find(p,1,true) then return p end end
end

-- Slang is normalized for semantic reading without destroying the original RP
-- evidence.  The raw text is still what WHY/export shows.  Normalization only
-- gives the rule engine a second, literal reading of colloquial phrasing.
local SLANG_PHRASES={
    {"my bad","i am sorry","apology"},{"we cool","are we okay again","repair-check"},{"we good","are we okay again","repair-check"},{"all good","everything is okay","repair"},{"no worries","it is okay","repair"},
    {"you good","are you okay","support"},{"i got you","i am here for you","support"},{"got your back","you can count on me","support"},{"i've got your back","you can count on me","support"},
    {"piss off","go away","hostile-rejection"},{"fuck off","go away","hostile-rejection"},{"screw you","go away","hostile-rejection"},{"shove off","go away","hostile-rejection"},{"sod off","go away","hostile-rejection"},{"bugger off","go away","hostile-rejection"},{"beat it","go away","hostile-rejection"},{"back off","stay away from me","hostile-rejection"},{"lay off","stop bothering me","rejection"},
    {"cut the crap","stop lying","hostility"},{"cut the shit","stop lying","hostility"},{"knock it off","stop that","rejection"},{"drop it","stop talking about it","dismissal"},{"give me a break","stop bothering me","dismissal"},
    {"taking the piss","mocking","mockery"},{"take the piss","mock","mockery"},{"what a joke","this is ridiculous","mockery"},{"big talk","boastful talk","mockery"},
    {"ain't half bad","is pretty good","compliment"},{"not half bad","is pretty good","compliment"},{"nice one","well done","compliment-surface"},{"good one","well done","compliment-surface"},{"you're alright","you are good","compliment"},{"you are alright","you are good","compliment"},
    {"you've got some nerve","you are being offensively bold","confrontation"},{"you got some nerve","you are being offensively bold","confrontation"},{"what's your problem","why are you hostile","confrontation"},{"what is your problem","why are you hostile","confrontation"},
    {"don't give me that","i do not believe you","distrust"},{"dont give me that","i do not believe you","distrust"},{"full of shit","lying","distrust"},{"bullshit","i do not believe that","distrust"},{"my ass","i do not believe that","distrust"},
    {"no fucking way","absolutely not","strong-rejection"},{"no damn way","absolutely not","strong-rejection"},{"not a chance","absolutely not","strong-rejection"},{"over my dead body","absolutely not","strong-rejection"},
    {"damn straight","yes exactly","agreement"},{"fair enough","i agree","agreement"},{"you bet","yes","agreement"},{"sure thing","yes","agreement"},
    {"suit yourself","do what you want","dismissal"},{"whatever","i do not care","dismissal"},
}
local function boundarySwap(s,word,repl)
    return s:gsub("%f[%w']"..word.."%f[^%w']",repl)
end
local function slangPhraseMatches(raw,phrase,tag)
    if not raw:find(phrase,1,true)then return false end
    -- Semantic aliases are tags, never destructive text rewrites. Short
    -- check-ins such as "you good?" are especially dangerous as substrings:
    -- "wish you good luck" must remain a well-wish, not become "are you okay".
    if phrase=="you good"then
        local short=trim(raw)
        if short:find("you good luck",1,true)or short:find("you good at ",1,true)or short:find("you good with ",1,true)or short:find("you good for ",1,true)then return false end
        return short=="you good"or short=="you good?"or short=="you good."or short=="you good!"or short:find("?",1,true)~=nil
    elseif phrase=="we good"or phrase=="we cool"then
        local short=trim(raw)
        return short==phrase or short==phrase.."?"or(short:find(phrase,1,true)==1 and short:find("?",1,true)~=nil)
    end
    return true
end
local function normalizeSlang(text)
    local raw=clean(text);local s=raw;local sig={hits=0,tags={}}
    for _,r in ipairs(SLANG_PHRASES) do
        if slangPhraseMatches(raw,r[1],r[3])then sig.hits=sig.hits+1;sig.tags[r[3]]=true end
    end
    -- "ain't" needs a tiny amount of grammar context before generic word
    -- normalization or "I ain't" would become "I is not".
    local n
    s,n=s:gsub("%f[%a]i ain't%f[^%a']","i am not");if n>0 then sig.hits=sig.hits+n;sig.tags["ain't"]=true end
    s,n=s:gsub("%f[%a]you ain't%f[^%a']","you are not");if n>0 then sig.hits=sig.hits+n;sig.tags["ain't"]=true end
    s,n=s:gsub("%f[%a]we ain't%f[^%a']","we are not");if n>0 then sig.hits=sig.hits+n;sig.tags["ain't"]=true end
    s,n=s:gsub("%f[%a]they ain't%f[^%a']","they are not");if n>0 then sig.hits=sig.hits+n;sig.tags["ain't"]=true end
    s,n=s:gsub("%f[%a]he ain't%f[^%a']","he is not");if n>0 then sig.hits=sig.hits+n;sig.tags["ain't"]=true end
    s,n=s:gsub("%f[%a]she ain't%f[^%a']","she is not");if n>0 then sig.hits=sig.hits+n;sig.tags["ain't"]=true end
    s,n=s:gsub("%f[%a]it ain't%f[^%a']","it is not");if n>0 then sig.hits=sig.hits+n;sig.tags["ain't"]=true end
    local words={
        {"ain't","is not"},{"gonna","going to"},{"wanna","want to"},{"gotta","have to"},{"kinda","kind of"},{"sorta","sort of"},
        {"lemme","let me"},{"gimme","give me"},{"dunno","do not know"},{"nope","no"},{"nah","no"},{"yep","yes"},{"ya","you"},{"yer","your"},
    }
    for _,r in ipairs(words) do local n; s,n=boundarySwap(s,r[1],r[2]);if n and n>0 then sig.hits=sig.hits+n;sig.tags[r[1]]=true end end
    local n;s,n=s:gsub("%f[%a]([%a]+)in'%f[^%a']","%1ing");if n and n>0 then sig.hits=sig.hits+n;sig.tags.droppedG=true end
    s,n=s:gsub("%f[%w']'em%f[^%w']","them");if n and n>0 then sig.hits=sig.hits+n;sig.tags.em=true end
    return clean(s),sig
end
RPAL.NormalizePersonaSlang=function(text)local n,sig=normalizeSlang(text);return n,sig end

local SARCASM_POSITIVE={"oh wonderful","oh, wonderful","oh great","oh, great","brilliant","fantastic","lovely","perfect","excellent","marvelous","marvellous","splendid","well done","good job","nice one","good one","genius","clever","just what we needed","exactly what we needed"}
local SARCASM_STRONG={"yeah right","yeah, right","right, because","right because","because that worked so well","that worked so well last time","what a surprise","of course you did","how delightful","what could possibly go wrong","sure, why not","sure why not","exactly what we needed","just what we needed"}
local SARCASM_EMOTES={"rolls their eyes","rolls his eyes","rolls her eyes","scoffs","sneers","smirks","deadpans","slow claps","slowly claps","laughs dryly","dryly"}
local function recentByAuthor(q,authorKey,seconds)
    local t=now();for i=#q.context,1,-1 do local r=q.context[i];if t-(tonumber(r.at)or 0)>(seconds or 12)then break end;if tostring(r.authorKey or"")==tostring(authorKey or"")then return r end end
end
local function sarcasmRead(q,raw,normalized,eventName,mine,person,authorKey)
    local low=clean(raw);local score=0;local why={};local positive=contains(low,SARCASM_POSITIVE)and true or false
    if contains(low,SARCASM_STRONG)then score=score+.70;why[#why+1]="strong sarcastic phrasing" end
    local metaphorical=contains(low,{"it was as if","it is as if","felt as if","feels as if","looked as if","looks as if","seemed as if","seems as if","almost as if"})
    local pointedAsIf=(low:find("^as if[%s%p]")~=nil)or contains(low,{"as if you ","as if he ","as if she ","as if they ","as if i would","as if that would"})
    if pointedAsIf and not metaphorical then score=score+.70;why[#why+1]="dismissive as-if construction"end
    if positive then score=score+.16;why[#why+1]="exaggerated positive wording" end
    if contains(low,SARCASM_EMOTES)then score=score+.42;why[#why+1]="sarcastic body-language marker" end
    local explicitMarker=contains(low,{"/sarcasm","(sarcasm)","[sarcasm]","<sarcasm>"})and true or false
    if explicitMarker then score=score+.80;why[#why+1]="explicit sarcasm marker" end
    local prev=recentByAuthor(q,authorKey,10)
    if prev and not prev.mine and positive and (prev.negativeEmote or prev.mockeryEmote)then score=score+.34;why[#why+1]="positive wording followed a negative/mockingly framed emote" end
    -- Read the present line and short exchange only. Another player's habitual
    -- accent, slang or profanity is never retained as part of this Persona.
    -- Sarcasm without a literal-positive or strong construction is too easy to
    -- hallucinate. Keep confidence conservative instead of reading every smirk
    -- as mockery.
    if not positive and not contains(low,SARCASM_STRONG) and score<.55 then score=score*.55 end
    return {score=clamp(score,0,1),positiveSurface=positive,strong=(contains(low,SARCASM_STRONG)or(pointedAsIf and not metaphorical)or explicitMarker)and true or false,reasons=why}
end
local function identityNorm(value)
    local s=stripRealm(tostring(value or"")):lower():gsub("’","'")
    -- WoW Lua's %w is ASCII-biased. Remove separators/punctuation while
    -- preserving UTF-8 letter bytes so accented player names remain distinct keys.
    return trim(s):gsub("[%s%p]+","")
end
local function speakerIdentityKey(person)
    person=type(person)=="table"and person or{name=person}
    return identityNorm(person.fullName or person.key or person.name or"")
end
RPAL.GetPersonaSpeakerIdentityKey=speakerIdentityKey
local function ready(requireIC)
    if RPAL.IsPersonaSystemEnabled and not RPAL.IsPersonaSystemEnabled() then return false end
    if RPAL.IsPersonalityCalibrated and not RPAL.IsPersonalityCalibrated() then return false end
    if requireIC~=false and RPAL.IsTRPInCharacter then local ok,res=pcall(RPAL.IsTRPInCharacter);if not ok or not res then return false end end
    return true
end

local function compactLegacyFragments(rows)
    local chronological={};local merged=0
    for i=#(rows or{}),1,-1 do
        local row=rows[i]
        if type(row)=="table"then
            local previous=chronological[#chronological]
            local same=previous and(tonumber(previous.at)or 0)==(tonumber(row.at)or 0)and tostring(previous.event or previous.eventName or"")==tostring(row.event or row.eventName or"")and tostring(previous.person or"")==tostring(row.person or"")and(previous.mine and true or false)==(row.mine and true or false)
            local left=same and trim(previous.text)or"";local right=trim(row.text)
            if same and(left:find("»%s*$")or right:find("^»"))then
                left=left:gsub("%s*»%s*$","");right=right:gsub("^»%s*","")
                previous.text=trim(left.." "..right):sub(1,520)
                local nl=trim(previous.normalized);local nr=trim(row.normalized);nl=nl:gsub("%s*»%s*$","");nr=nr:gsub("^»%s*","")
                previous.normalized=trim(nl.." "..nr):sub(1,520);previous.directness=math.max(tonumber(previous.directness)or 0,tonumber(row.directness)or 0);previous.slangHits=(tonumber(previous.slangHits)or 0)+(tonumber(row.slangHits)or 0);previous.sarcasm=math.max(tonumber(previous.sarcasm)or 0,tonumber(row.sarcasm)or 0);previous.fragmentMerged=true;merged=merged+1
            else chronological[#chronological+1]=row end
        end
    end
    local out={};for i=#chronological,1,-1 do out[#out+1]=chronological[i]end
    return out,merged
end

local BASE_EMOTIONS={
    sadness=.08,anger=.08,embarrassment=.05,anxiety=.10,warmth=.18,loneliness=.10,
    vigilance=.18,confidence=.55,focus=.46,relief=.08,stress=.12,comfort=.45,
}
local HALF_LIFE={sadness=2400,anger=900,embarrassment=480,anxiety=900,warmth=1500,loneliness=1800,vigilance=720,confidence=2200,focus=1000,relief=780,stress=1200,comfort=1400}

local function ensure()
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData();if type(p)~="table" then return nil,nil end
    p.continuity=type(p.continuity)=="table" and p.continuity or {}
    local c=p.continuity
    c.cognition=type(c.cognition)=="table" and c.cognition or {}
    local q=c.cognition;local oldVersion=tonumber(q.version)or 0
    q.events=type(q.events)=="table" and q.events or {}
    q.concerns=type(q.concerns)=="table" and q.concerns or {}
    q.context=type(q.context)=="table" and q.context or {}
    q.emotions=type(q.emotions)=="table" and q.emotions or {}
    q.whyHistory=type(q.whyHistory)=="table" and q.whyHistory or {}
    q.unresolved=type(q.unresolved)=="table" and q.unresolved or {}
    q.relationshipOverrides=type(q.relationshipOverrides)=="table" and q.relationshipOverrides or {}
    q.speakers=type(q.speakers)=="table" and q.speakers or {}
    if oldVersion<6 then
        -- Pre-v6 stored other players' mannerisms and recent excerpts. Those
        -- observations never belonged to the player's own voice model.
        q.speakers={};q.remoteStyleProfilesRemovedAt=now()
    end
    q.decisions=type(q.decisions)=="table" and q.decisions or {}
    q.unclassified=type(q.unclassified)=="table" and q.unclassified or {}
    q.ownership=type(q.ownership)=="table" and q.ownership or {}
    q.ownership.byPerson=type(q.ownership.byPerson)=="table" and q.ownership.byPerson or {}
    q.ownedPeople=type(q.ownedPeople)=="table" and q.ownedPeople or {sceneID=nil,people={}}
    q.ownedPeople.people=type(q.ownedPeople.people)=="table" and q.ownedPeople.people or {}
    if oldVersion<4 and not q.contextV4Migrated then
        local mergedFragments;q.unclassified,mergedFragments=compactLegacyFragments(q.unclassified)
        local removed={};local kept={}
        for _,ev in ipairs(q.events)do
            local s=clean(ev and ev.evidence or"")
            local literal=contains(s,{"i'm furious","i am furious","i'm angry","i am angry","i'm pissed","i am pissed","i'm livid","i am livid"})
            if type(ev)=="table"and ev.type=="SELF_ANGER"and not literal then removed[tonumber(ev.id)or tostring(ev.id)]=true else kept[#kept+1]=ev end
        end
        q.events=kept
        local function keepEventRow(row)
            local raw=type(row)=="table"and(row.eventID or(type(row.why)=="table"and row.why.eventID))or nil
            local id=tonumber(raw)or tostring(raw or"");return not removed[id]
        end
        local filtered={};for _,row in ipairs(q.concerns)do if keepEventRow(row)then filtered[#filtered+1]=row end end;q.concerns=filtered
        filtered={};for _,row in ipairs(q.whyHistory)do local id=row and row.why and(tonumber(row.why.eventID)or tostring(row.why.eventID or""))or"";if not removed[id]then filtered[#filtered+1]=row end end;q.whyHistory=filtered
        filtered={};for _,row in ipairs(q.decisions)do if keepEventRow(row)then filtered[#filtered+1]=row end end;q.decisions=filtered
        if type(p.history)=="table"then filtered={};for _,row in ipairs(p.history)do if keepEventRow(row)then filtered[#filtered+1]=row end end;p.history=filtered end
        local removedCount=0;for _ in pairs(removed)do removedCount=removedCount+1 end
        if removedCount>0 then
            q.emotions.anger=math.min(tonumber(q.emotions.anger)or BASE_EMOTIONS.anger,.16);q.emotions.stress=math.min(tonumber(q.emotions.stress)or BASE_EMOTIONS.stress,.20);q.emotions.vigilance=math.min(tonumber(q.emotions.vigilance)or BASE_EMOTIONS.vigilance,.24)
            local pid=q.pending and(tonumber(q.pending.eventID)or tostring(q.pending.eventID or""));if pid and removed[pid]then q.pending=nil end
            if removed[tonumber(q.lastThoughtEventID)or tostring(q.lastThoughtEventID or"")]then q.lastThoughtEventID=nil end
            if removed[tonumber(q.attentionLockEventID)or tostring(q.attentionLockEventID or"")]then q.attentionLockEventID=nil;q.attentionLockUntil=0 end
        end
        q.contextV4Migrated={at=now(),removedFalseAnger=removedCount,mergedUnclassifiedFragments=mergedFragments}
    end
    if oldVersion<9 and not q.semanticReliabilityV9 then
        -- v0.5 could destructively rewrite slang phrases (e.g. "wish you good
        -- luck" -> "wish are you okay luck"). Re-normalize the bounded context
        -- from raw evidence and rebuild short-lived ownership with canonical keys.
        for _,row in ipairs(q.context)do
            if type(row)=="table"then
                local raw=trim(row.rawText or row.text);local normalized,slang=normalizeSlang(raw)
                row.normalized=normalized:sub(1,300);row.slang=slang
                if row.person then row.threadPersonKey=speakerIdentityKey(row.person)end
            end
        end
        q.ownership={byPerson={}}
        q.semanticReliabilityV9={at=now(),scrubbedContext=#q.context}
    end
    if oldVersion<10 and not q.canonReliabilityV10 then
        -- Force the new episode audit to revisit every persisted generated
        -- event. The audit itself decides by general evidence/ownership rules.
        q.episodeAuditVersion=0
        q.canonReliabilityV10={at=now()}
    end
    if oldVersion<13 and not q.snortNeutralizedV13 then
        -- 0.6.2 treated Blizzard's standard /snort wording ("snorts
        -- derisively") as moral ridicule. In social RP it is usually a low-key
        -- amusement reaction. Retire only evidence created specifically by that
        -- old rule; explicit insults/hostility remain untouched.
        local learning=type(p.learning)=="table"and p.learning or nil
        local retired,retiredTimes=0,{}
        local function falseSnortAction(row)
            if type(row)~="table"then return false end
            local text=clean(row.text or"");local meta=type(row.meta)=="table"and row.meta or{}
            local ev=tostring(meta.event or"")
            local standard=text:find("you snort",1,true)==1 or text:find("snort derisively at",1,true)~=nil or text:find("snorts derisively at",1,true)~=nil
            return standard and(ev=="SELF_RIDICULE"or ev=="SELF_MOCKERY")
        end
        if learning then
            local kept={}
            for _,row in ipairs(type(learning.moralActions)=="table"and learning.moralActions or{})do
                if falseSnortAction(row)then
                    retired=retired+1;retiredTimes[#retiredTimes+1]=tonumber(row.at)or 0
                    local weight=tonumber(row.weight)or 0;local direction=tonumber(row.direction)or 0
                    if type(learning.alignment)=="table"and weight>0 then
                        local ageDays=math.max(0,(now()-(tonumber(row.at)or now()))/86400);local factor=math.pow(.996,ageDays)
                        learning.alignment.sum=(tonumber(learning.alignment.sum)or 0)-(direction*weight*factor)
                        learning.alignment.mass=math.max(0,(tonumber(learning.alignment.mass)or 0)-(weight*factor))
                    end
                else kept[#kept+1]=row end
            end
            learning.moralActions=kept
            kept={}
            for _,row in ipairs(type(learning.evidence)=="table"and learning.evidence or{})do
                local text=clean(type(row)=="table"and row.text or"")
                local label=clean(type(row)=="table"and row.label or"")
                local falseEvidence=type(row)=="table"and row.kind=="alignment"and row.key=="moral"and(text:find("you snort",1,true)==1 or text:find("snort derisively at",1,true)~=nil or text:find("snorts derisively at",1,true)~=nil)and(label=="direct personal ridicule"or label=="mocking another person"or label=="cruel / personal joke")
                if not falseEvidence then kept[#kept+1]=row end
            end
            learning.evidence=kept
            if #retiredTimes>0 then
                kept={}
                for _,row in ipairs(type(learning.changes)=="table"and learning.changes or{})do
                    local remove=false
                    if type(row)=="table"and row.kind=="alignment"then
                        for _,at in ipairs(retiredTimes)do if at>0 and math.abs((tonumber(row.at)or 0)-at)<=3 then remove=true;break end end
                    end
                    if not remove then kept[#kept+1]=row end
                end
                learning.changes=kept
            end
        end
        q.pendingMoralJoke=nil
        q.snortNeutralizedV13={at=now(),retiredMoralActions=retired}
    end
    q.version=VERSION
    if RPAL.AuditPersonaCognitiveStore then pcall(RPAL.AuditPersonaCognitiveStore,q,p)end
    q.nextEventID=tonumber(q.nextEventID) or 1
    q.nextContextID=tonumber(q.nextContextID) or 1
    q.lastEmotionAt=tonumber(q.lastEmotionAt) or now()
    for k,v in pairs(BASE_EMOTIONS) do if q.emotions[k]==nil then q.emotions[k]=v end end
    return q,p
end

local function decayEmotions(q)
    local t=now();local dt=math.max(0,t-(tonumber(q.lastEmotionAt) or t));if dt<=0 then return end
    for k,base in pairs(BASE_EMOTIONS) do
        local cur=tonumber(q.emotions[k]) or base;local hl=HALF_LIFE[k] or 1200
        local f=math.pow(.5,dt/hl);q.emotions[k]=base+(cur-base)*f
    end
    q.lastEmotionAt=t
end
local function emotion(q,key,delta)
    decayEmotions(q);q.emotions[key]=clamp((tonumber(q.emotions[key]) or BASE_EMOTIONS[key] or 0)+delta,0,1)
end

local function trait(key)
    local v=RPAL.GetEffectivePersonalityTrait and RPAL.GetEffectivePersonalityTrait(key)
    return clamp((tonumber(v) or 50)/100,0,1)
end
local function alignment()
    local a=RPAL.GetEffectivePersonalityAlignment and RPAL.GetEffectivePersonalityAlignment() or {good=.34,neutral=.46,evil=.20}
    return {good=clamp(a.good or .34,0,1),neutral=clamp(a.neutral or .46,0,1),dark=clamp(a.evil or .20,0,1)}
end

local function mentionForm(value)
    local s=clean(shortFirst(value));if s==""then return""end
    -- %w is ASCII-biased in WoW Lua and can split accented or non-ASCII names.
    -- Strip punctuation/spacing only; keep UTF-8 letter bytes intact.
    return trim(s:gsub("%p"," "):gsub("%s+"," "))
end
local function literalNameMention(text,value)
    local needle=mentionForm(value);if #needle<2 then return false end
    local hay=" "..clean(text):gsub("%p"," "):gsub("%s+"," ").." "
    return hay:find(" "..needle.." ",1,true)~=nil
end
local function playerNames()
    local names={};local seen={}
    local function add(v)
        local first=shortFirst(v);local k=mentionForm(first);if k~=""and not seen[k]then seen[k]=true;names[#names+1]=first end
    end
    if RPAL.FullName then local ok,v=pcall(RPAL.FullName);if ok then add(v)end end
    if UnitName then add(UnitName("player"))end
    return names
end
local function mentionsPlayer(text)
    for _,n in ipairs(playerNames())do if literalNameMention(text,n)then return true end end
    return false
end

local REL_WEIGHT={love=1.28,family=1.12,friend=1.02,friendly=.92,business=.74,rival=.86,unfriendly=.86,hate=1.02,neutral=.68,familiar=.68,observed=.72}
local function matchUnit(a,b)
    a=tostring(a or ""):lower():gsub("%s",""):gsub("'","");b=tostring(b or ""):lower():gsub("%s",""):gsub("'","")
    if a=="" or b=="" then return false end
    if a==b then return true end
    return (a:match("^[^-]+") or a)==(b:match("^[^-]+") or b)
end
local function resolvePerson(author)
    author=tostring(author or "");if author=="" then return nil end
    for _,r in ipairs((RPAL.GetPersonaTRPRelationshipCandidates and RPAL.GetPersonaTRPRelationshipCandidates(50)) or {}) do
        if matchUnit(author,r.key) or norm(author)==norm(r.fullName) then
            return {name=shortFirst(r.name),fullName=r.fullName,key=r.key,kind=r.kind or "neutral",score=tonumber(r.score) or 0,recentInteractions=tonumber(r.recentInteractions) or 0,recentSessions=tonumber(r.recentSessions) or 0}
        end
    end
    for _,r in ipairs((RPAL.GetPersonaPeople and RPAL.GetPersonaPeople(20)) or {}) do
        if matchUnit(author,r.key) or norm(author)==norm(r.trpFullName) then
            local name=trim(r.display);if name~="" then return {name=shortFirst(name),fullName=r.trpFullName,key=r.key,kind="observed",score=tonumber(r.score) or 0,recentInteractions=tonumber(r.recentInteractions) or 0,recentSessions=tonumber(r.recentSessions) or 0} end
        end
    end
    -- A stranger with a real TRP roleplay first name is still a valid live
    -- conversational identity. Only the identity and meaning of the current
    -- exchange are used; their mannerisms are not learned.
    if RPAL.ResolvePersonaObservedName then
        local first,full,profileID=RPAL.ResolvePersonaObservedName(author)
        if trim(first)~=""and trim(full)~=""then return{name=shortFirst(first),fullName=full,key=author,profileID=profileID,kind="observed",score=0,recentInteractions=0,recentSessions=0}end
    end
    -- No TRP RP name = no named Persona identity. The event may still affect
    -- state if it was clearly directed at the player, but no WoW unit name leaks.
    return nil
end

local function contextPush(q,row)
    q.context[#q.context+1]=row;while #q.context>MAX_CONTEXT do table.remove(q.context,1) end
end

local function secondPersonCue(text)
    local padded=" "..clean(text):gsub("[^%w']"," "):gsub("%s+"," ").." "
    return padded:find(" you ",1,true)or padded:find(" your ",1,true)or padded:find(" yours ",1,true)or padded:find(" yourself ",1,true)
end

local function currentTargetPerson()
    if not UnitExists or not UnitExists("target")or(UnitIsUnit and UnitIsUnit("target","player"))then return nil end
    if UnitIsPlayer and not UnitIsPlayer("target")then return nil end
    local name,realm=UnitName and UnitName("target")
    if not name or name==""then return nil end
    local author=name
    if realm and realm~=""then author=name.."-"..realm end
    return resolvePerson(author)
end

local liveTargetRuntimeKey=""
local liveTargetRuntimeChangedAt=0
local function targetSnapshot(q)
    local person=currentTargetPerson();local key=speakerIdentityKey(person);local t=now()
    local runtimeKey=key
    if runtimeKey==""and UnitExists and UnitExists("target")and UnitIsPlayer and UnitIsPlayer("target")and not(UnitIsUnit and UnitIsUnit("target","player"))then
        local guid=UnitGUID and UnitGUID("target")
        local name,realm=UnitName and UnitName("target")
        runtimeKey=tostring(guid or((name and name~="")and((realm and realm~="")and(name.."-"..realm)or name))or"")
    end
    if liveTargetRuntimeKey~=runtimeKey then liveTargetRuntimeKey=runtimeKey;liveTargetRuntimeChangedAt=t
    elseif liveTargetRuntimeChangedAt<=0 then liveTargetRuntimeChangedAt=t end
    return{person=person,key=key,name=person and person.name or nil,fullName=person and person.fullName or nil,changedAt=liveTargetRuntimeChangedAt,heldFor=math.max(0,t-liveTargetRuntimeChangedAt),isResolved=person and true or false}
end

local function stableTargetPerson(snap,minHeld)
    if type(snap)~="table"or type(snap.person)~="table"then return nil end
    if (tonumber(snap.heldFor)or 0)<(minHeld or 1.5)then return nil end
    return snap.person
end

local function resolveTokenMentionedPerson(text,excludePerson)
    local found,seen={},{};local excludeKey=speakerIdentityKey(excludePerson)
    for token in tostring(text or""):gmatch("%S+")do
        local raw=token:gsub("^[%p]+",""):gsub("[%p]+$","")
        if #raw>=2 and #raw<=40 then
            local person=resolvePerson(raw);local key=speakerIdentityKey(person)
            if person and key~=""and key~=excludeKey and not seen[key]then seen[key]=true;found[#found+1]=person end
        end
    end
    return #found==1 and found[1]or nil
end

local function personMentioned(text,person)
    if type(person)~="table"then return false end
    for _,candidate in ipairs({person.name,person.fullName})do
        if literalNameMention(text,candidate)then return true end
    end
    return false
end

local function resolveMentionedPerson(text)
    local candidates={}
    local function add(r)
        if type(r)~="table"then return end
        local person={name=shortFirst(r.name or r.display),fullName=r.fullName or r.trpFullName,key=r.key,kind=r.kind or"observed",score=tonumber(r.score)or 0,recentInteractions=tonumber(r.recentInteractions)or 0,recentSessions=tonumber(r.recentSessions)or 0}
        if trim(person.name)~=""and personMentioned(text,person)then candidates[#candidates+1]=person end
    end
    for _,r in ipairs((RPAL.GetPersonaTRPRelationshipCandidates and RPAL.GetPersonaTRPRelationshipCandidates(50))or{})do add(r)end
    for _,r in ipairs((RPAL.GetPersonaPeople and RPAL.GetPersonaPeople(20))or{})do add(r)end
    local unique,seen={},{}
    for _,r in ipairs(candidates)do local k=speakerIdentityKey(r);if k~=""and not seen[k]then seen[k]=true;unique[#unique+1]=r end end
    return #unique==1 and unique[1]or nil
end

local function explicitMentionedPerson(text,excludePerson)
    local person=resolveMentionedPerson(text)
    if person and speakerIdentityKey(person)~=speakerIdentityKey(excludePerson)then return person end
    return resolveTokenMentionedPerson(text,excludePerson)
end

local function recentDirectedIncoming(q,seconds)
    local t=now()
    for i=#q.context,1,-1 do
        local r=q.context[i];if t-(tonumber(r.at)or 0)>(seconds or 35)then break end
        if not r.mine and r.directedAtSelf and r.person then return r.person,r end
    end
end

local function uniqueActiveOwnedPerson(q,seconds)
    local t=now();local found=nil
    for _,state in pairs(type(q.ownership)=="table"and type(q.ownership.byPerson)=="table"and q.ownership.byPerson or{})do
        local last=math.max(tonumber(state.lastDirectedIn)or 0,tonumber(state.lastDirectedOut)or 0)
        if last>0 and t-last<=(seconds or 75)and(tonumber(state.confidence)or 0)>=.78 then
            if found then return nil end
            found=state
        end
    end
    if not found or not found.name then return nil end
    -- Resolve through current TRP data so display-name aliases still converge
    -- on the same canonical identity.
    local candidates=(RPAL.GetPersonaTRPRelationshipCandidates and RPAL.GetPersonaTRPRelationshipCandidates(50))or{}
    for _,r in ipairs(candidates)do if speakerIdentityKey(r)==found.key then return{name=shortFirst(r.name),fullName=r.fullName,key=r.key,kind=r.kind or"neutral",score=tonumber(r.score)or 0,recentInteractions=tonumber(r.recentInteractions)or 0,recentSessions=tonumber(r.recentSessions)or 0}end end
    for i=#q.context,1,-1 do local row=q.context[i];if t-(tonumber(row.at)or 0)>90 then break end;if row.person and speakerIdentityKey(row.person)==found.key then return row.person end end
end
local SHORT_REPLY_PHRASES={"thanks","thank you","thanks a lot","cheers","yeah","yep","yup","right","fair enough","true","exactly","indeed","okay","ok","nice","good point","makes sense","oh","sure"}
local function shortReplyCue(text)
    local s=clean(text):gsub("[%p]+$","");s=trim(s)
    if s==""or#s>44 then return false end
    for _,p in ipairs(SHORT_REPLY_PHRASES)do if s==p or s:find(p.." ",1,true)==1 then return true end end
    return false
end
local function recentNearbyInterjection(q,seconds)
    local t=now();local recent=nil
    for i=#q.context,1,-1 do
        local row=q.context[i];local age=t-(tonumber(row.at)or 0);if age>(seconds or 10)then break end
        if not row.mine then
            if row.person and row.ownershipClass=="nearby"and(tonumber(row.ownershipConfidence)or 0)<=.20 then
                if recent and speakerIdentityKey(recent.person)~=speakerIdentityKey(row.person)then return nil end
                recent=row
            else
                -- Any directed/owned remote turn makes the nearby candidate ambiguous.
                return nil
            end
        end
    end
    return recent
end

local function recentRemoteSpeakerCount(q,seconds)
    local t=now();local seen={};local count=0
    for i=#q.context,1,-1 do
        local row=q.context[i];if t-(tonumber(row.at)or 0)>(seconds or 10)then break end
        if not row.mine and row.person then
            local key=speakerIdentityKey(row.person)
            if key~=""and not seen[key]then seen[key]=true;count=count+1 end
        end
    end
    return count
end

local function resolveOutgoingPerson(q,text,eventName,snap)
    -- An explicit name/standard emote target is stronger than conversation
    -- inertia or the UI target. This is what keeps "You peer at X" attached to X
    -- even while a different conversation thread is active.
    local mentioned=explicitMentionedPerson(text,nil)
    if mentioned then return mentioned,.98,"explicit-mentioned-addressee"end

    local target=stableTargetPerson(snap,1.5)
    local incoming,row=recentDirectedIncoming(q,35)
    local short=shortReplyCue(text)
    local crowded=recentRemoteSpeakerCount(q,10)>=2

    -- Short generic replies are deliberately conservative in a crowd. Current
    -- target can corroborate an already-directed exchange or veto a conflicting
    -- adjacency guess, but target alone never turns "Thanks" into a durable
    -- person-bound event.
    if short then
        if incoming then
            if target then
                if speakerIdentityKey(target)==speakerIdentityKey(incoming)then
                    return incoming,.94,"short-reply-target-corroborated"
                end
                return nil,0,"ambiguous-short-reply-target-conflict"
            end
            if crowded then return nil,0,"ambiguous-short-reply-in-crowd"end
            return incoming,.86,"short-reply-to-recent-directed-turn"
        end
        if recentNearbyInterjection(q,10)then return nil,0,"ambiguous-short-reply-after-nearby"end
    end

    -- For ordinary second-person speech, a stable player target is useful
    -- disambiguation. This remains weaker than an explicit textual addressee.
    if target and secondPersonCue(text)then return target,.86,"stable-target-with-second-person"end
    if incoming then return incoming,.90,"reply-to-directed-turn"end

    -- Natural RP often has pauses longer than 35s. Continue only when exactly
    -- one owned conversation is still alive. In a crowd, a conflicting stable
    -- target blocks thread inertia instead of silently reassigning the line.
    local active=uniqueActiveOwnedPerson(q,75)
    if active then
        if target and crowded and speakerIdentityKey(target)~=speakerIdentityKey(active)then
            return nil,0,"ambiguous-active-thread-target-conflict"
        end
        return active,.74,"continued-unique-owned-exchange"
    end
    return nil,0,"undirected-self-turn"
end

local function ownershipState(q,person)
    local key=speakerIdentityKey(person);if key==""then return nil,key end
    q.ownership=type(q.ownership)=="table"and q.ownership or{byPerson={}}
    q.ownership.byPerson=type(q.ownership.byPerson)=="table"and q.ownership.byPerson or{}
    local row=q.ownership.byPerson[key]
    if type(row)~="table"then row={name=person.name,key=key,confidence=0,turns=0};q.ownership.byPerson[key]=row end
    return row,key
end

local function incomingOwnership(q,text,eventName,person)
    if not person then return{class="nearby",confidence=0,reason="no-resolved-person"}end
    local state,key=ownershipState(q,person);local t=now();local explicit=mentionsPlayer(text);local low=clean(text)
    -- CHAT_MSG_TEXT_EMOTE is Blizzard's standardized targeted-emote stream.
    -- In that stream the literal second-person token "you" refers to this
    -- client/player, so "X nods at you" must never be treated as other-to-other.
    if eventName=="CHAT_MSG_TEXT_EMOTE"and secondPersonCue(low)then return{class="directed",confidence=.99,reason="text-emote-targets-player",personKey=key}end
    if explicit then return{class="directed",confidence=.99,reason="explicit-player-name",personKey=key}end
    if eventName=="CHAT_MSG_EMOTE"or eventName=="CHAT_MSG_TEXT_EMOTE"then
        -- Standard/custom emotes can name a third party without using "at"
        -- (e.g. "A bonks B on the head"). A uniquely resolved non-speaker name
        -- is therefore explicit evidence that this emote is not aimed at self.
        local otherTarget=explicitMentionedPerson(text,person)
        if otherTarget then return{class="other-to-other",confidence=.01,reason="explicit-third-party-emote-target",personKey=key,targetPersonKey=speakerIdentityKey(otherTarget)}end
        local targetedElsewhere=(low:find(" at ",1,true)and not low:find(" at the joke",1,true))or low:find(" toward ",1,true)or low:find(" towards ",1,true)
        if targetedElsewhere then return{class="other-to-other",confidence=.02,reason="emote-targeted-elsewhere",personKey=key}end
    end

    local lastOwn
    for i=#q.context,1,-1 do
        local r=q.context[i];local age=t-(tonumber(r.at)or 0);if age>45 then break end
        if r.mine and tostring(r.threadPersonKey or"")==key and(tonumber(r.outgoingConfidence)or 0)>=.65 then lastOwn=r;break end
    end
    if lastOwn then
        local age=t-(tonumber(lastOwn.at)or 0)
        local conf=age<=12 and .94 or(age<=25 and .88 or .78)
        return{class="directed",confidence=conf,reason="reply-to-my-addressed-turn",personKey=key}
    end
    if state and tonumber(state.lastDirectedOut)and t-state.lastDirectedOut<=35 then
        return{class="directed",confidence=.84,reason="active-owned-exchange",personKey=key}
    end
    if state and(tonumber(state.confidence)or 0)>=.78 and tonumber(state.lastAt)and t-state.lastAt<=45 and state.lastActor=="self"then
        return{class="directed",confidence=.80,reason="continued-bidirectional-thread",personKey=key}
    end
    return{class="nearby",confidence=.08,reason="nearby-unowned-speech",personKey=key}
end

local function noteOwnedPerson(q,person)
    if not person or trim(person.name)==""then return end
    local scene=RPAL.GetActiveScene and RPAL.GetActiveScene()or nil
    local sceneID=scene and scene.id or"no-scene"
    if q.ownedPeople.sceneID~=sceneID then q.ownedPeople={sceneID=sceneID,people={}}end
    local key=speakerIdentityKey(person);local row=q.ownedPeople.people[key]or{name=person.name,firstAt=now(),turns=0}
    row.name=person.name;row.lastAt=now();row.turns=(tonumber(row.turns)or 0)+1;q.ownedPeople.people[key]=row
end

local function noteOwnership(q,mine,person,confidence,reason)
    if not person then return end
    local state,key=ownershipState(q,person);local t=now();confidence=tonumber(confidence)or 0
    if mine then
        if confidence<.65 then return end
        state.lastDirectedOut=t;state.lastActor="self"
    else
        if confidence<.68 then return end
        state.lastDirectedIn=t;state.lastActor="remote"
    end
    state.lastAt=t;state.turns=(tonumber(state.turns)or 0)+1;state.confidence=math.max(confidence,(tonumber(state.confidence)or 0)*.92);state.reason=reason
    noteOwnedPerson(q,person)
end

function RPAL.GetPersonaOwnedConversationPeople(limit,wantedSceneID)
    local q=ensure();local out={};if not q then return out end
    local scene=RPAL.GetActiveScene and RPAL.GetActiveScene()or nil;local sceneID=wantedSceneID or(scene and scene.id)or"no-scene"
    if type(q.ownedPeople)~="table"or q.ownedPeople.sceneID~=sceneID then return out end
    for _,r in pairs(q.ownedPeople.people or{})do if type(r)=="table"and trim(r.name)~=""then out[#out+1]={name=r.name,firstAt=r.firstAt,lastAt=r.lastAt,turns=r.turns}end end
    table.sort(out,function(a,b)return(tonumber(a.firstAt)or 0)<(tonumber(b.firstAt)or 0)end)
    limit=math.max(1,math.min(20,math.floor(tonumber(limit)or 12)));while#out>limit do table.remove(out)end
    return out
end
local THREAD_TOPICS={
    relationship={"love","trust","together","relationship","miss you","care about","feel about us"},
    plans={"going to","want to","would like","should we","let's","lets ","tomorrow","later","plan","meet"},
    danger={"danger","safe","threat","fight","fighting","attack","kill","hurt","weapon","guard","spar","sparring","practice","practise","dodge","block","shield","kick","punch"},
    home={"home","house","room","bed","door","candle","hearth"},
    work={"work","job","coin","gold","contract","business","client","payment"},
    health={"hurt","pain","wound","tired","sick","heal","healing","injured","injury"},
    travel={"stormwind","city","road","travel","leave","arrive","journey","portal"},
    food={"food","hungry","drink","thirst","eat","meal","wine","ale","tea"},
    identity={"remember","my past","your past","family","my name","your name","used to","back then"},
    intimacy={"kiss","touch","close","body","bed","desire","want you","hold me"},
}
local function topicPhraseHit(text,phrase)
    local hay=" "..clean(text):gsub("[^%w']"," "):gsub("%s+"," ").." "
    local needle=" "..clean(phrase):gsub("[^%w']"," "):gsub("%s+"," ").." "
    return hay:find(needle,1,true)~=nil
end
local function topicSet(text)
    local s=clean(text);local out={}
    for key,words in pairs(THREAD_TOPICS)do
        for _,word in ipairs(words)do if topicPhraseHit(s,word)then out[key]=true;break end end
    end
    return out
end
local function topicOverlap(a,b)
    for key in pairs(type(a)=="table"and a or{})do if type(b)=="table"and b[key]then return true end end
    return false
end
local function contextActor(row)
    if row.mine then return "self" end
    return norm(row.person and row.person.name or row.authorKey or row.author or "")
end
local function conversationRead(q,authorKey,mine,person,text,ownership)
    local t=now();local target=person and speakerIdentityKey(person)or norm(authorKey or "")
    local relevant,selfTurns,remoteTurns,outsiders,alternations=0,0,0,0,0
    local lastActor,lastOwn,lastRemote,latestAt,topicMatch=nil,nil,nil,0,false
    local incomingTopics=topicSet(text);local threadTopics={}
    for key in pairs(incomingTopics)do threadTopics[key]=true end
    local first=math.max(1,#q.context-11)
    for i=first,#q.context do
        local row=q.context[i];local age=t-(tonumber(row.at)or 0)
        if age<=THREAD_WINDOW then
            local include=false;local actor=contextActor(row)
            if actor=="self"then
                include=target~=""and tostring(row.threadPersonKey or"")==target and(tonumber(row.outgoingConfidence)or 0)>=.65
            elseif target~=""and actor==target then
                include=row.directedAtSelf==true and(tonumber(row.ownershipConfidence)or 0)>=.68
            end
            if include then
                relevant=relevant+1;latestAt=math.max(latestAt,tonumber(row.at)or 0)
                -- Keep topic memory short. Conversation ownership may live for
                -- longer, but an old drink/food cue must not contaminate a later
                -- sparring exchange with the same person.
                if age<=45 then for key in pairs(topicSet(row.normalized or row.text))do threadTopics[key]=true end end
                if actor=="self"then selfTurns=selfTurns+1;lastOwn=row else remoteTurns=remoteTurns+1;lastRemote=row end
                if lastActor and lastActor~=actor then alternations=alternations+1 end
                lastActor=actor
            elseif actor~="self" then
                outsiders=outsiders+1
            end
        end
    end

    local incomingOwned=mine and person~=nil or(type(ownership)=="table"and ownership.class=="directed"and(tonumber(ownership.confidence)or 0)>=.68)
    if trim(text)~=""and incomingOwned then
        local incomingActor=mine and"self"or target
        if lastActor and incomingActor~=""and lastActor~=incomingActor then alternations=alternations+1 end
        relevant=relevant+1;latestAt=t;lastActor=incomingActor
        if incomingActor=="self"then selfTurns=selfTurns+1 else remoteTurns=remoteTurns+1 end
    end

    local ownAge=lastOwn and t-(tonumber(lastOwn.at)or 0)or 999
    local remoteAge=lastRemote and t-(tonumber(lastRemote.at)or 0)or 999
    if trim(text)~=""and incomingOwned then if mine then ownAge=0 else remoteAge=0 end end
    local counterpart=mine and lastRemote or lastOwn
    local counterpartTopics=counterpart and topicSet(counterpart.normalized or counterpart.text)or{}
    if counterpart and topicOverlap(incomingTopics,counterpartTopics)then topicMatch=true end
    local hasIncomingTopic=next(incomingTopics)~=nil;local hasCounterpartTopic=next(counterpartTopics)~=nil
    local topicShift=counterpart and hasIncomingTopic and hasCounterpartTopic and not topicMatch or false
    -- Thread topics describe the present exchange, not every subject mentioned
    -- in the last two minutes.
    threadTopics={};for key in pairs(incomingTopics)do threadTopics[key]=true end
    if counterpart and t-(tonumber(counterpart.at)or 0)<=45 then for key in pairs(counterpartTopics)do threadTopics[key]=true end end

    local score=0
    if ownAge<=15 then score=score+.34 elseif ownAge<=30 then score=score+.25 elseif ownAge<=60 then score=score+.14 elseif ownAge<=THREAD_WINDOW then score=score+.07 end
    if remoteAge<=30 then score=score+.12 elseif remoteAge<=60 then score=score+.06 end
    if selfTurns>0 and remoteTurns>0 then score=score+.24 end
    if relevant>=3 then score=score+.14 elseif relevant>=2 then score=score+.08 end
    if relevant>=2 then score=score+math.min(.14,(alternations/math.max(1,relevant-1))*.14)end
    if topicMatch then score=score+.12 end
    if outsiders>=3 and outsiders>relevant then score=score-.10 end
    score=clamp(score,0,1)
    local active=incomingOwned and math.min(ownAge,remoteAge)<=60
    return{
        at=t,active=active,coherent=active and score>=.50 and selfTurns>0 and remoteTurns>0,
        score=score,turns=relevant,selfTurns=selfTurns,remoteTurns=remoteTurns,outsiders=outsiders,alternations=alternations,
        person=person and person.name or(lastRemote and lastRemote.person and lastRemote.person.name),personKey=target,
        lastOwnAt=lastOwn and lastOwn.at or 0,lastRemoteAt=lastRemote and lastRemote.at or 0,
        latestAt=math.max(latestAt,lastOwn and lastOwn.at or 0,lastRemote and lastRemote.at or 0),
        topicMatch=topicMatch,topicShift=topicShift,topics=threadTopics,ownershipClass=mine and"self-to-other"or(ownership and ownership.class)or"nearby",
        ownershipConfidence=mine and(person and 1 or 0)or(ownership and ownership.confidence)or 0,
        ownershipReason=mine and(person and"resolved-outgoing-addressee"or"undirected-self-turn")or(ownership and ownership.reason)or"nearby",
    }
end
local function recentContext(q,wantMine,seconds)
    local t=now();for i=#q.context,1,-1 do local r=q.context[i];if t-(tonumber(r.at) or 0)>(seconds or 40) then break end;if r.mine==wantMine then return r end end
end

function RPAL.GetPersonaConversationContext()
    local q=ensure();if not q then return {active=false,coherent=false,score=0,turns=0}end
    local r=type(q.lastThread)=="table"and q.lastThread or{};local age=now()-(tonumber(r.at)or 0)
    local out={};for k,v in pairs(r)do out[k]=v end
    out.age=math.max(0,age);if age>120 then out.active=false;out.coherent=false end
    return out
end
function RPAL.GetPersonaSpeakerProfiles(limit)
    -- Compatibility API: persistent remote style profiles were retired in v6.
    ensure();return {}
end
local function recentHumor(q,seconds,person)
    local t=now();local pk=speakerIdentityKey(person)
    for i=#q.context,1,-1 do
        local r=q.context[i];if t-(tonumber(r.at)or 0)>(seconds or 45)then break end
        if r.mine and r.humor and(pk==""or tostring(r.threadPersonKey or"")==pk)then return r end
    end
end
local function humorAttempt(text,eventName)
    local s=clean(text)
    if contains(s,{"just kidding","i'm kidding","i am kidding","only joking","just joking","jokes ","joking ","teases ","winks","chuckles","grins","smirks"}) then return true end
    if eventName=="CHAT_MSG_EMOTE" or eventName=="CHAT_MSG_TEXT_EMOTE" then
        if contains(s,{"makes a joke","cracks a joke","attempts a joke","jests","teases","winks","grins playfully"}) then return true end
    end
    return false
end

local PAT={
    breakup={"we're done","we are done","it's over between us","it is over between us","we're finished","we are finished","this relationship is over","i'm leaving you","i am leaving you","i can't do this anymore","i cannot do this anymore","we should end this","i don't want to be with you","i do not want to be with you","there is no us","we aren't together anymore","we are not together anymore"},
    reconcile={"i want you back","let's try again","lets try again","give us another chance","give me another chance","we can fix this","i still want us","i want to make this work","i don't want this to end","i do not want this to end","can we start again","can we try again","i want to fix us"},
    apology={"i'm sorry","i am sorry","my apologies","forgive me","i owe you an apology","i was wrong","that was my fault","it was my fault","i shouldn't have done that","i should not have done that","i shouldn't have said that","i should not have said that"},
    affection={"i love you","i'm in love with you","i am in love with you","i've fallen for you","i have fallen for you","i have feelings for you","i've got feelings for you","i care deeply about you","i adore you","i missed you","i miss you","you mean a lot to me","you matter to me","stay with me","i'm glad you're here","i am glad you're here","i'm glad you are here","i am glad you are here","i'm happy about us","i am happy about us","i'm happy with this","i am happy with this","happy about how this has been"},
    threat={"i'll kill you","i will kill you","you'll regret this","you will regret this","watch your back","i'll hurt you","i will hurt you","i'll break you","i will break you","you are dead","you're dead","i'll make you pay","i will make you pay"},
    rejection={"leave me alone","go away","get away from me","stay away from me","don't touch me","do not touch me","i don't want you here","i do not want you here","get lost","don't come back","do not come back","you're not welcome here","you are not welcome here","we don't want you here","we do not want you here","i don't want to see you","i do not want to see you"},
    insult={"idiot","moron","fool","coward","pathetic","disgusting","useless","worthless","stupid","insufferable","annoying bastard","piece of shit"},
    compliment={"you look good","you look lovely","you look beautiful","you look handsome","you did well","well done","good job","pretty good","not bad","impressive work","i'm proud of you","i am proud of you","you're impressive","you are impressive"},
    support={"are you alright","are you okay","i'm here for you","i am here for you","i've got you","i have got you","you can count on me","you don't have to face this alone","you do not have to face this alone","i'm with you","i am with you","lean on me","you can lean on me","i won't leave you alone with this","i will not leave you alone with this","i'm not going anywhere","i am not going anywhere"},
    trustBroken={"you lied to me","you've lied to me","you have lied to me","you betrayed me","you broke my trust","i trusted you","how could you lie to me","you deceived me","you kept that from me","you hid that from me","i lied to you","i've lied to you","i have lied to you","i betrayed you","i deceived you","i kept that from you","i hid that from you","i can't trust you","i cannot trust you","i don't trust you anymore","i do not trust you anymore"},
    trustGiven={"i trust you","you have my trust","i believe you","i know i can trust you","i know you'll keep this","i know you will keep this"},
    forgiveness={"i forgive you","you're forgiven","you are forgiven","we can put this behind us","let's put this behind us","lets put this behind us","i'm not holding it against you","i am not holding it against you"},
    gratitude={"thank you","thanks for","i appreciate that","i appreciate you","i owe you","that means a lot"},
    promise={"i promise","you have my word","i give you my word","i swear to you","you can hold me to that"},
    reassurance={"you're safe","you are safe","it's alright","it is alright","we'll be alright","we will be alright","don't worry","do not worry","i'm not going anywhere","i am not going anywhere","i won't leave you","i will not leave you","we can handle this"},
    vulnerability={"i'm scared","i am scared","i'm afraid","i am afraid","i'm worried","i am worried","i don't know what to do","i do not know what to do","i can't do this alone","i cannot do this alone","i need you","i don't want to lose you","i do not want to lose you"},
    invitation={"come with me","join me","come along","would you like to come","do you want to come","let's go together","lets go together","stay with me","sit with me","walk with me","meet me later"},
    boundary={"i'm not looking for anything","i am not looking for anything","i want to keep this casual","i'd rather keep this casual","i would rather keep this casual","no strings attached","just warn me if","let me know if you start feeling","tell me if you start feeling","used to keeping a distance","used to keeping the distance"},
    hostility={"i hate you","i despise you","i can't stand you","i cannot stand you","i loathe you","i want nothing to do with you"},
    agreement={"i agree","you're right","you are right","exactly","that's true","that is true","fair point"},
    disagreement={"i disagree","you're wrong","you are wrong","that's not true","that is not true","no, you're wrong","no, you are wrong"},
}

local function gratitudeCue(text)
    local s=clean(text)
    if contains(s,PAT.gratitude)then return true end
    local padded=" "..s:gsub("%p"," "):gsub("%s+"," ").." "
    -- Standalone "thanks" is common natural RP and should not require
    -- "thanks for". Keep obvious refusals such as "no thanks" out.
    if padded:find(" thanks ",1,true)and not padded:find(" no thanks ",1,true)then return true end
    return false
end

local POLITE_AFRAID={
    "i am afraid i must","i'm afraid i must","i am afraid i have to","i'm afraid i have to",
    "i am afraid i need to leave","i'm afraid i need to leave","i am afraid i should go","i'm afraid i should go",
    "i am afraid not","i'm afraid not","i am afraid so","i'm afraid so",
    "not really, i am afraid","not really, i'm afraid","no, i am afraid","no, i'm afraid","sorry, i am afraid","sorry, i'm afraid",
    ", i am afraid",", i'm afraid",
}
local LITERAL_FEAR={
    "i'm scared","i am scared","i'm terrified","i am terrified","i'm frightened","i am frightened",
    "afraid of ","afraid that ","afraid for ","afraid to ","fear that ","fear for ","terrifies me","frightens me",
}
local DIRECT_COMPLIMENT={"you look good","you look lovely","you look beautiful","you look handsome","you did well","well done","good job","i'm proud of you","i am proud of you","you're impressive","you are impressive"}
local function targetedCompliment(text)
    local s=clean(text)
    if contains(s,DIRECT_COMPLIMENT)then return true end
    -- Broad phrases such as "pretty good" / "not bad" describe anything in
    -- the room. They only become a personal compliment when their grammar
    -- points back at the addressee or what the addressee just did.
    return contains(s,{
        "you're pretty good","you are pretty good","you're not bad","you are not bad",
        "that was pretty good","that was not bad","that was impressive",
        "your work is impressive","your work was impressive","impressive work from you",
    })and true or false
end

local function explicitFearCue(text)
    -- Bare "I'm afraid" is a discourse marker as often as an emotion. Require
    -- an actual fear object/construction (afraid of/that/to/for) or explicit
    -- scared/frightened/terrified wording before canonizing fear.
    return contains(text,LITERAL_FEAR)and true or false
end
local function vulnerabilityCue(text)
    if explicitFearCue(text)then return true end
    return contains(text,{"i'm worried","i am worried","i don't know what to do","i do not know what to do","i can't do this alone","i cannot do this alone","i need you","i don't want to lose you","i do not want to lose you"})and true or false
end


local function semanticGuardReason(text,slang)
    local s=clean(text)
    if contains(s,POLITE_AFRAID)and not contains(s,LITERAL_FEAR)then return"polite-afraid-discourse-marker"end
    if contains(s,{"good luck","best of luck","wish you luck","wishing you luck","hope it goes well","hope it works out"})then return"well-wish-not-commitment"end
    if slang and slang.tags and slang.tags.support and not slangPhraseMatches(s,"you good","support")then return"ambiguous-support-alias"end
end

local EMOTE={
    negative={"frowns at","glares at","sneers at","scoffs at","grimaces at","recoils from","backs away from","steps away from","turns away from","rolls their eyes at","rolls his eyes at","rolls her eyes at","spits at","snarls at","shoves","pushes away"},
    positive={"smiles at","grins at","hugs","embraces","pats","comforts","leans closer to","sits beside","moves closer to","takes the hand of","holds hands with","kisses","rests against"},
    aggression={"punches","kicks","slaps","strikes","hits","stabs","slashes","shoots","bites","headbutts","throws a punch at"},
    laughter={"laughs","chuckles","snickers","giggles","snorts"},
    mockery={"laughs at","snickers at","mocks","mimics","jeers at","points and laughs at","ridicules","taunts"},
    amusement={"snorts","snort","snorts derisively","snort derisively"},
    sadness={"cries","sobs","tears well","wipes away a tear","voice cracks"},
    pain={"winces","grimaces in pain","hisses in pain","clutches a wound","clutches their wound","clutches his wound","clutches her wound","favors an injury","recoils in pain"},
}

local EVENT_DEF={
    HUMOR_REJECTED={label="A joke was met coldly",base=.48,halfLife=300,valence=-1},
    HUMOR_LANDED={label="A joke landed well",base=.30,halfLife=180,valence=1},
    SOCIAL_DISAPPROVAL={label="A negative social reaction landed",base=.38,halfLife=360,valence=-1},
    SOCIAL_WARMTH={label="A warm social reaction landed",base=.30,halfLife=300,valence=1},
    INSULT={label="A personal insult landed",base=.52,halfLife=720,valence=-1},
    COMPLIMENT={label="A personal compliment landed",base=.34,halfLife=360,valence=1},
    APOLOGY={label="An apology was offered",base=.44,halfLife=600,valence=1},
    AFFECTION={label="Affection was expressed",base=.54,halfLife=900,valence=1},
    INTIMACY={label="A moment of sexual intimacy deepened closeness",base=.58,halfLife=1200,valence=1},
    AFTERCARE={label="Gentle care followed an intimate moment",base=.48,halfLife=1200,valence=1},
    RELATIONSHIP_BREAK={label="A close relationship ended",base=.98,halfLife=2700,valence=-1},
    RECONCILIATION={label="A close relationship moved toward repair",base=.75,halfLife=1500,valence=1},
    REJECTION={label="A direct rejection landed",base=.58,halfLife=900,valence=-1},
    THREAT={label="A threat was directed at the character",base=.70,halfLife=1200,valence=-1},
    AGGRESSION={label="Physical aggression was directed at the character",base=.82,halfLife=1500,valence=-1},
    SUPPORT={label="Support was offered",base=.40,halfLife=600,valence=1},
    OTHER_DISTRESS={label="Someone important looked distressed",base=.38,halfLife=480,valence=-1},
    TRUST_BROKEN={label="The character's trust was damaged",base=.72,halfLife=1800,valence=-1},
    TRUST_ACCUSATION={label="The character was accused of breaking trust",base=.58,halfLife=900,valence=-1},
    TRUST_GIVEN={label="Trust was explicitly offered",base=.42,halfLife=720,valence=1},
    FORGIVENESS={label="A conflict was explicitly forgiven",base=.52,halfLife=900,valence=1},
    GRATITUDE={label="Gratitude was expressed",base=.24,halfLife=300,valence=1},
    PROMISE={label="A meaningful promise was made",base=.34,halfLife=720,valence=1},
    REASSURANCE={label="Reassurance was offered",base=.28,halfLife=300,valence=1},
    VULNERABILITY={label="Someone opened up about a fear",base=.32,halfLife=420,valence=-1},
    INVITATION={label="A direct invitation was offered",base=.22,halfLife=240,valence=1},
    BOUNDARY={label="A relationship boundary was made explicit",base=.27,halfLife=360,valence=0},
    HOSTILITY={label="Direct hostility was expressed",base=.66,halfLife=1500,valence=-1},
    MOCKERY={label="The character was openly mocked",base=.50,halfLife=600,valence=-1},
    AMBIGUOUS_TONE={label="A recent remark sounded possibly insincere",base=.20,halfLife=210,valence=0},
    SELF_PAIN={label="The character visibly reacted to pain",base=.46,halfLife=480,valence=-1},
    AGREEMENT={label="A point was agreed with",base=.18,halfLife=150,valence=1},
    DISAGREEMENT={label="A point was challenged",base=.23,halfLife=210,valence=-1},
    SELF_SADNESS={label="The character openly showed sadness",base=.48,halfLife=720,valence=-1},
    SELF_ANGER={label="The character openly showed anger",base=.46,halfLife=600,valence=-1},
    SELF_FEAR={label="The character openly showed fear",base=.52,halfLife=720,valence=-1},
}

local relationImportance
local function directness(q,text,eventName,mine,person)
    if mine then return person and 1 or 0 end
    local own=type(q.currentOwnership)=="table"and q.currentOwnership or nil
    if own and own.class=="directed"then return clamp(tonumber(own.confidence)or 0,0,1)end
    if mentionsPlayer(text)then return .99 end
    return 0
end

-- Explicit intimacy is a relationship event, not anger/noise. Recognition is
-- intentionally conservative: it needs a known RP person plus unmistakable
-- sexual language, or several sensual cues inside a coherent exchange.
local INTIMACY_EXPLICIT={" cock "," cocks "," dick "," pussy "," cunt "," shaft "," orgasm "," climax "," cum "," semen "," penetration "," penetrates "," penetrated "," thrusts "," thrusting "," blowjob "," handjob "," masturbat"," inside her "," inside him "," inside me "," oral sex "," rides him "," rides her "}
local INTIMACY_SENSUAL={" moans "," moaning "," groans in pleasure"," pants "," panting "," breathless "," aroused "," arousal "," grinds against"," grinding against"," needy "," undresses "," naked "," between her thighs"," between his thighs"," between their thighs"," knot "," pleasure "}
local AFTERCARE_CUES={"cuddles","cuddling","holds close","holds her close","holds him close","holds them close","wraps his arms around","wraps her arms around","wraps their arms around","lies with","lay with","rests against","strokes her hair","strokes his hair","strokes their hair","pets her head","pets his head","pets their head","cleans her","cleans him","cleans them","wipes her","wipes him","wipes them","checks if","are you alright","are you okay","stays close","soft kiss","tenderly","blanket","doesn't mind the mess","does not mind the mess","happy about how this has been"}
local function paddedSignals(text,list)
    local padded=" "..clean(text):gsub("[^%w']"," "):gsub("%s+"," ").." ";local n=0
    for _,cue in ipairs(list or{})do if padded:find(cue,1,true)then n=n+1 end end
    return n
end
local function recentIntimacy(q,person,seconds)
    local pk=speakerIdentityKey(person);if pk==""then return nil end
    for _,ev in ipairs(q.events or{})do
        local age=now()-(tonumber(ev.lastAt)or tonumber(ev.at)or 0);if age>(seconds or 12*60)then break end
        if(ev.type=="INTIMACY"or ev.type=="AFTERCARE")and speakerIdentityKey(ev.person)==pk then return ev end
    end
end
local function intimacyRead(q,text,eventName,mine,person,direct)
    if not person or trim(person.name)==""then return nil end
    local emote=eventName=="CHAT_MSG_EMOTE"or eventName=="CHAT_MSG_TEXT_EMOTE"
    local coherent=q.currentThread and q.currentThread.coherent
    if not emote and not coherent then return nil end
    if not mine and(tonumber(direct)or 0)<.46 then return nil end
    local s=" "..clean(text):gsub("[^%w']"," "):gsub("%s+"," ").." "
    if recentIntimacy(q,person,12*60)and contains(s,AFTERCARE_CUES)then return"AFTERCARE",.88,"gentle aftercare following recent intimacy"end
    local explicit=paddedSignals(s,INTIMACY_EXPLICIT);local sensual=paddedSignals(s,INTIMACY_SENSUAL)
    if explicit>=1 or(sensual>=2 and coherent)then
        local confidence=clamp(.70+math.min(.18,explicit*.10+sensual*.04)+(mine and .04 or(tonumber(direct)or 0)*.08),0,1)
        return"INTIMACY",confidence,explicit>=1 and"explicit sexual intimacy"or"coherent sensual exchange"
    end
end

-- Revalidate persisted events against the current semantic contract. This is
-- deliberately type/evidence based: no character-specific identities
-- are ever encoded here. It lets migrations retire old false positives when a
-- previous classifier was broader than the current one.
function RPAL.PersonaEventEvidenceStillSupportsType(event)
    if type(event)~="table"then return false,"invalid-event"end
    local typ=tostring(event.type or"");local raw=clean(event.evidence or event.normalized or"")
    if raw==""then return false,"missing-event-evidence"end
    if typ=="INTIMACY"then
        local explicit=paddedSignals(raw,INTIMACY_EXPLICIT);local sensual=paddedSignals(raw,INTIMACY_SENSUAL)
        local plain=contains(raw,{"had sex","have sex","having sex","slept together","slept with you","slept with me","made love","making love","sexual intimacy"})
        if not plain and explicit<1 and sensual<2 then return false,"intimacy-without-current-explicit-evidence"end
    elseif typ=="REJECTION"then
        local _,sig=normalizeSlang(raw);local tags=sig and sig.tags or{}
        local explicit=contains(raw,PAT.rejection)
        -- Generic discourse words such as "whatever" / "drop it" are not
        -- durable interpersonal rejection by themselves. Only unmistakable
        -- direct-rejection aliases survive historical audit.
        if not explicit and not(tags["hostile-rejection"]or tags["strong-rejection"])then return false,"rejection-without-direct-rejection-evidence"end
    elseif typ=="SUPPORT"then
        local _,sig=normalizeSlang(raw);local tags=sig and sig.tags or{}
        if contains(raw,{"good luck","best of luck","wish you luck","wishing you luck","hope it goes well","hope it works out"})then return false,"well-wish-not-support"end
        if not contains(raw,PAT.support)and not tags.support then return false,"support-without-support-evidence"end
    elseif typ=="VULNERABILITY"then
        if not vulnerabilityCue(raw)then return false,"vulnerability-without-current-evidence"end
    elseif typ=="COMPLIMENT"then
        if not targetedCompliment(raw)then return false,"compliment-without-personal-target"end
    elseif typ=="GRATITUDE"then
        if not gratitudeCue(raw)then return false,"gratitude-without-gratitude-evidence"end
    end
    return true
end

local function classifyIncoming(q,text,eventName,mine,person,tone,slang)
    local s=clean(text);if s=="" then return nil end
    local direct=directness(q,s,eventName,mine,person)
    local rel=person and person.kind or "neutral"
    if mine then
        local intimateType,intimateConfidence,intimateReason=intimacyRead(q,s,eventName,true,person,direct)
        if intimateType then return intimateType,intimateConfidence,intimateReason end
        -- "I miss/love you" is a relationship act when it is addressed to a
        -- real RP person, not merely generic evidence of private sadness.
        if person and contains(s,PAT.affection)then return "AFFECTION",.86,"expressed affection"end
        if explicitFearCue(s) or ((eventName=="CHAT_MSG_EMOTE" or eventName=="CHAT_MSG_TEXT_EMOTE") and contains(s,{"trembles","shakes with fear","looks frightened"})) then return "SELF_FEAR",.84,"explicit fear" end
        local explicitAnger=contains(s,{"i'm furious","i am furious","i'm angry","i am angry","i'm pissed","i am pissed","i'm livid","i am livid"})
        local intimate=contains(s,{"pleasure","arousal","aroused","moans","groans","panting","breathless","climax","orgasm","hips","thrust","grinds","kiss","desire"})
        local aimedAnger=(eventName=="CHAT_MSG_EMOTE"or eventName=="CHAT_MSG_TEXT_EMOTE")and contains(s,{"clenches their fist","clenches his fist","clenches her fist","snarls at","glares at","growls at","bares their teeth at","bares his teeth at","bares her teeth at"})
        if explicitAnger or(aimedAnger and not intimate)then return "SELF_ANGER",.82,explicitAnger and"explicit anger"or"targeted angry body language"end
        if contains(s,{"i'm sad","i am sad","i'm hurt","i am hurt","i miss you"}) or ((eventName=="CHAT_MSG_EMOTE" or eventName=="CHAT_MSG_TEXT_EMOTE") and contains(s,EMOTE.sadness)) then return "SELF_SADNESS",.82,"explicit sadness" end
        if (eventName=="CHAT_MSG_EMOTE" or eventName=="CHAT_MSG_TEXT_EMOTE") and contains(s,EMOTE.pain) then return "SELF_PAIN",.86,"visible pain reaction" end
        if (rel=="love") and contains(s,PAT.breakup) then return "RELATIONSHIP_BREAK",.90,"relationship ending language" end
        if person then
            if contains(s,PAT.trustBroken) then return "TRUST_BROKEN",.90,"explicit breach of trust" end
            if contains(s,PAT.forgiveness) then return "FORGIVENESS",.90,"explicit forgiveness" end
            if contains(s,PAT.trustGiven) then return "TRUST_GIVEN",.88,"explicit trust" end
            if contains(s,PAT.affection) then return "AFFECTION",.86,"expressed affection" end
            if gratitudeCue(s) then return "GRATITUDE",.84,"expressed gratitude" end
            if contains(s,PAT.promise) then return "PROMISE",.84,"explicit promise" end
            if contains(s,PAT.boundary) then return "BOUNDARY",.84,"made a relationship boundary explicit" end
            if contains(s,PAT.hostility) then return "HOSTILITY",.90,"explicit hostility" end
        end
        return nil
    end

    local intimateType,intimateConfidence,intimateReason=intimacyRead(q,s,eventName,false,person,direct)
    if intimateType then return intimateType,intimateConfidence,intimateReason end

    -- Strong colloquial phrases are normalized semantically before ordinary
    -- phrase matching.  These tags are conservative shortcuts for expressions
    -- whose literal words otherwise point the classifier in the wrong direction.
    if direct>=.46 and slang and type(slang.tags)=="table" then
        local t=slang.tags
        if t["hostile-rejection"] or t["strong-rejection"] then return "REJECTION",clamp(.65+direct*.28,0,1),"colloquial direct rejection" end
        if t.mockery then return "MOCKERY",clamp(.58+direct*.28,0,1),"colloquial mockery" end
        if t.support then return "SUPPORT",clamp(.55+direct*.24,0,1),"colloquial support / check-in" end
        if t.apology then return "APOLOGY",clamp(.58+direct*.24,0,1),"colloquial apology" end
        if t.compliment then return "COMPLIMENT",clamp(.54+direct*.24,0,1),"colloquial approval" end
        if t.agreement then return "AGREEMENT",clamp(.48+direct*.20,0,1),"colloquial agreement" end
        if t.distrust then return "DISAGREEMENT",clamp(.48+direct*.24,0,1),"colloquial disbelief / distrust" end
        if t.confrontation or t.hostility then return "HOSTILITY",clamp(.50+direct*.24,0,1),"colloquial confrontation" end
        if t.dismissal or t.rejection then return "REJECTION",clamp(.46+direct*.22,0,.78),"colloquial dismissal" end
        if t["repair-check"] and rel=="love" then return "RECONCILIATION",clamp(.48+direct*.20,0,.78),"colloquial relationship repair check" end
    end

    -- Sarcasm is a modifier over the literal reading. A superficially positive
    -- line can therefore become MOCKERY instead of a false COMPLIMENT. Medium
    -- confidence stays ambiguous and only creates a short-lived concern.
    if direct>=.46 and tone then
        if (tone.positiveSurface or tone.strong) and tone.score>=.68 then return "MOCKERY",clamp(.54+tone.score*.40,0,1),"sarcastic praise / mockery" end
        if tone.score>=.50 and tone.score<.68 then return "AMBIGUOUS_TONE",clamp(.44+tone.score*.28,0,.66),"possibly sarcastic or insincere tone" end
    end

    -- Strong relationship semantics are only accepted for a known TRP lover
    -- and a line that is very likely directed at the player.
    if rel=="love" and direct>=.52 then
        if contains(s,PAT.breakup) then return "RELATIONSHIP_BREAK",clamp(.72+direct*.24,0,1),"relationship ending language" end
        if contains(s,PAT.reconcile) then return "RECONCILIATION",clamp(.66+direct*.24,0,1),"relationship repair language" end
    end
    if direct>=.46 then
        if contains(s,PAT.trustBroken) then return "TRUST_ACCUSATION",clamp(.64+direct*.28,0,1),"accused of breaking trust" end
        if contains(s,PAT.forgiveness) then return "FORGIVENESS",clamp(.62+direct*.26,0,1),"explicit forgiveness" end
        if contains(s,PAT.trustGiven) then return "TRUST_GIVEN",clamp(.58+direct*.25,0,1),"explicit trust" end
        if contains(s,PAT.hostility) then return "HOSTILITY",clamp(.64+direct*.28,0,1),"explicit hostility" end
        if contains(s,PAT.promise) then return "PROMISE",clamp(.52+direct*.24,0,1),"explicit promise" end
        if gratitudeCue(s) then return "GRATITUDE",clamp(.48+direct*.22,0,1),"expressed gratitude" end
        if contains(s,PAT.threat) then return "THREAT",clamp(.66+direct*.25,0,1),"direct threat" end
        if contains(s,PAT.rejection) then return "REJECTION",clamp(.60+direct*.28,0,1),"direct rejection" end
        if contains(s,PAT.apology) then return "APOLOGY",clamp(.57+direct*.28,0,1),"direct apology" end
        if contains(s,PAT.affection) then return "AFFECTION",clamp(.58+direct*.28,0,1),"expressed affection" end
        if contains(s,PAT.compliment)and targetedCompliment(s)then return "COMPLIMENT",clamp(.54+direct*.28,0,1),"direct compliment" end
        -- A well-wish is warmth, not a promise of presence/help. Keep it as
        -- scene context rather than inflating it into durable SUPPORT.
        if contains(s,{"good luck","best of luck","wish you luck","wishing you luck","hope it goes well","hope it works out"})then return nil end
        if contains(s,PAT.support) then return "SUPPORT",clamp(.54+direct*.26,0,1),"offered support" end
        if contains(s,PAT.reassurance) then return "REASSURANCE",clamp(.52+direct*.24,0,1),"offered reassurance" end
        if vulnerabilityCue(s) then return "VULNERABILITY",clamp(.52+direct*.24,0,1),"opened up about fear or uncertainty" end
        if q.currentThread and q.currentThread.coherent and contains(s,PAT.invitation) then return "INVITATION",clamp(.48+direct*.22,0,.88),"direct invitation in a coherent exchange" end
        if q.currentThread and q.currentThread.coherent and contains(s,PAT.boundary) then return "BOUNDARY",clamp(.54+direct*.22,0,.90),"made a relationship boundary explicit" end
        local insultHit=contains(s,PAT.insult)
        if insultHit then local padded=" "..s.." ";local secondPerson=padded:find(" you ",1,true) or padded:find(" your ",1,true);if mentionsPlayer(s) or secondPerson or direct>=.57 then return "INSULT",clamp(.58+direct*.30,0,1),"direct insult" end end
        if contains(s,PAT.disagreement) then return "DISAGREEMENT",clamp(.46+direct*.25,0,1),"direct disagreement" end
        if contains(s,PAT.agreement) then return "AGREEMENT",clamp(.44+direct*.22,0,1),"direct agreement" end
    end
    if eventName=="CHAT_MSG_EMOTE" or eventName=="CHAT_MSG_TEXT_EMOTE" then
        local explicit=mentionsPlayer(s)or(eventName=="CHAT_MSG_TEXT_EMOTE"and secondPersonCue(s))
        local targetedElsewhere=(not explicit) and ((s:find(" at ",1,true) and not s:find(" at the joke",1,true)) or s:find(" from ",1,true) or s:find(" toward ",1,true) or s:find(" towards ",1,true))
        -- Distress in a close relationship does not need to be aimed at the
        -- player to matter: simply noticing a lover/family/friend crying or in
        -- pain can become a concern. We do not infer blame, only concern.
        if person and (rel=="love" or rel=="family" or rel=="friend") and (contains(s,EMOTE.sadness) or contains(s,EMOTE.pain)) then return "OTHER_DISTRESS",clamp(.56+relationImportance(person)*.10,0,.86),"noticed distress in someone emotionally important" end
        if (not targetedElsewhere) and (explicit or direct>=.52) then
            if contains(s,EMOTE.aggression) then return "AGGRESSION",clamp(.68+direct*.28,0,1),"physical aggression" end
            if contains(s,EMOTE.mockery) and not recentHumor(q,45,person) then return "MOCKERY",clamp(.61+direct*.27,0,1),"mocking social reaction" end
            local neg=contains(s,EMOTE.negative)
            if neg then
                if recentHumor(q,45,person) then return "HUMOR_REJECTED",clamp(.66+direct*.25,0,1),"negative reaction immediately after a humor attempt" end
                return "SOCIAL_DISAPPROVAL",clamp(.54+direct*.30,0,1),"negative social reaction" end
            if contains(s,EMOTE.laughter) and recentHumor(q,45,person) then return "HUMOR_LANDED",clamp(.57+direct*.28,0,1),"positive reaction immediately after a humor attempt" end
            if contains(s,EMOTE.positive) then return "SOCIAL_WARMTH",clamp(.52+direct*.28,0,1),"warm social reaction" end
        end
    end
    return nil
end

relationImportance=function(person)
    local k=person and person.kind or "neutral";local w=REL_WEIGHT[k] or .68
    if person then
        if (person.recentInteractions or 0)>=2 or (person.recentSessions or 0)>=2 then w=w+.12 end
        w=w+math.min(.12,math.abs(tonumber(person.score) or 0)*.10)
    end
    return clamp(w,.55,1.55)
end

local NEGATIVE_EVENT={RELATIONSHIP_BREAK=true,REJECTION=true,INSULT=true,THREAT=true,AGGRESSION=true,TRUST_BROKEN=true,TRUST_ACCUSATION=true,HOSTILITY=true,MOCKERY=true,SOCIAL_DISAPPROVAL=true,HUMOR_REJECTED=true}
local REPAIR_EVENT={RECONCILIATION=true,APOLOGY=true,FORGIVENESS=true,TRUST_GIVEN=true,SUPPORT=true}
local function recentEventContextFactor(q,eventType,person)
    local t=now();local pk=speakerIdentityKey(person);local negative=0;local same=0;local unresolved=0
    for _,ev in ipairs(q.events or {}) do
        local age=t-(tonumber(ev.at) or 0);if age>12*60 then break end
        local samePerson=(pk=="" and not ev.person) or (pk~="" and speakerIdentityKey(ev.person)==pk)
        if samePerson then
            if ev.type==eventType then same=same+1 end
            if NEGATIVE_EVENT[ev.type] then negative=negative+1 end
        end
    end
    if pk~="" then for _,u in pairs(q.unresolved or {}) do if type(u)=="table" and not u.resolved and norm(u.person)==pk then unresolved=unresolved+1 end end end
    local factor=1
    if NEGATIVE_EVENT[eventType] then factor=factor+math.min(.28,negative*.055)+math.min(.12,same*.04)+math.min(.10,unresolved*.04)
    elseif REPAIR_EVENT[eventType] and unresolved>0 then factor=factor+math.min(.24,.08*unresolved) end
    return clamp(factor,.85,1.45),{negative=negative,same=same,unresolved=unresolved}
end

local function appraisalFor(eventType,person,confidence,mine,contextFactor)
    local a=alignment();local social=trait("introvertedExtroverted");local vindictive=trait("forgivingVindictive")
    local impulsive=trait("cautiousImpulsive");local brutal=trait("gentleBrutal");local spineless=trait("valorousSpineless")
    local importance=relationImportance(person);local def=EVENT_DEF[eventType] or {base=.3,valence=0}
    local intensity=clamp(def.base*importance*(.72+.32*confidence)*(tonumber(contextFactor) or 1),.08,1)
    local app={intensity=intensity,importance=importance,confidence=confidence,contextFactor=tonumber(contextFactor) or 1,emotion={},tags={}}
    local e=app.emotion
    local function add(k,v)e[k]=(e[k] or 0)+v*intensity end
    if eventType=="RELATIONSHIP_BREAK" then
        add("sadness",.82);add("stress",.62);add("loneliness",.62);add("focus",-.62);add("comfort",-.58);add("confidence",-.18)
        add("anger",(.10+.36*vindictive+.18*a.dark-.10*a.good));if vindictive<.42 then add("anxiety",.18) end
        app.tags={"loss","attachment","rupture"}
    elseif eventType=="RECONCILIATION" then
        add("relief",.62);add("warmth",.46);add("stress",-.34);add("sadness",-.24);add("comfort",.30);app.tags={"repair","relief","attachment"}
    elseif eventType=="HUMOR_REJECTED" or eventType=="SOCIAL_DISAPPROVAL" then
        add("embarrassment",.34+.32*social);add("confidence",-(.12+.18*social));add("anxiety",.16+.18*(1-impulsive));add("anger",.08+.22*vindictive);add("stress",.18);app.tags={eventType=="HUMOR_REJECTED" and "embarrassment" or "disapproval","social"}
    elseif eventType=="HUMOR_LANDED" then
        add("warmth",.30);add("confidence",.18);add("relief",.12);add("stress",-.08);app.tags={"social","humor","validation"}
    elseif eventType=="INSULT" then
        add("anger",.32+.26*vindictive+.12*brutal+.14*a.dark-.08*a.good);add("embarrassment",.12+.22*social);add("confidence",-.12);add("stress",.18);app.tags={"insult","social","irritation"}
    elseif eventType=="COMPLIMENT" then
        add("warmth",.26);add("confidence",.24);add("embarrassment",social<.38 and .10 or .04);add("comfort",.12);app.tags={"praise","social"}
    elseif eventType=="APOLOGY" then
        add("anger",-(.18+.20*(1-vindictive)));add("stress",-.15);add("warmth",.10+.15*a.good);add("comfort",.10);app.tags={"apology","repair"}
    elseif eventType=="INTIMACY" then
        add("warmth",.38);add("comfort",.24);add("confidence",.12);add("stress",-.14);add("focus",.10);add("loneliness",-.16);app.tags={"intimacy","desire","closeness"}
    elseif eventType=="AFTERCARE" then
        add("warmth",.46);add("comfort",.40);add("relief",.16);add("stress",-.30);add("vigilance",-.12);add("loneliness",-.18);app.tags={"aftercare","tenderness","trust"}
    elseif eventType=="AFFECTION" or eventType=="SOCIAL_WARMTH" or eventType=="SUPPORT" or eventType=="REASSURANCE" then
        add("warmth",.34);add("comfort",.28);add("stress",-.18);add("loneliness",-.20);if eventType=="AFFECTION" then add("confidence",.10) end;app.tags={"warmth","connection"}
    elseif eventType=="VULNERABILITY" then
        local altruistic=1-trait("altruisticSelfish");add("anxiety",.08+.16*altruistic);add("focus",.08+.10*altruistic);add("warmth",.10+.12*altruistic);add("comfort",-.05);app.tags={"vulnerability","trust","concern"}
    elseif eventType=="INVITATION" then
        add("warmth",.12);add("comfort",.08);add("focus",.04);app.tags={"invitation","connection"}
    elseif eventType=="BOUNDARY" then
        add("focus",.12);add("vigilance",.05);add("comfort",-.04);add("stress",.04);app.tags={"boundary","relationship","expectation"}
    elseif eventType=="OTHER_DISTRESS" then
        local altruistic=1-trait("altruisticSelfish");add("anxiety",.12+.22*altruistic);add("stress",.10+.18*altruistic);add("focus",.10+.12*altruistic);add("warmth",.06+.12*altruistic);app.tags={"concern","distress","social"}
    elseif eventType=="REJECTION" then
        add("sadness",.28+.20*social);add("embarrassment",.22+.18*social);add("anger",.08+.24*vindictive+.12*a.dark-.06*a.good);add("loneliness",.24);add("confidence",-.18);add("comfort",-.18);app.tags={"rejection","social"}
    elseif eventType=="TRUST_BROKEN" then
        add("sadness",.30);add("anger",.24+.30*vindictive+.12*a.dark-.08*a.good);add("stress",.32);add("vigilance",.16);add("comfort",-.32);add("confidence",-.10);app.tags={"betrayal","trust","hurt"}
    elseif eventType=="TRUST_ACCUSATION" then
        add("embarrassment",.16+.22*a.good);add("stress",.24);add("anxiety",.12+.12*(1-impulsive));add("anger",.08+.24*vindictive+.14*a.dark);add("confidence",-.08);app.tags={"accusation","trust","social"}
    elseif eventType=="TRUST_GIVEN" then
        add("warmth",.24);add("comfort",.18);add("confidence",.08);add("stress",-.08);app.tags={"trust","connection"}
    elseif eventType=="FORGIVENESS" then
        add("relief",.34);add("stress",-.22);add("anger",-.26);add("warmth",.16);add("comfort",.12);app.tags={"forgiveness","repair","relief"}
    elseif eventType=="GRATITUDE" then
        add("warmth",.16);add("comfort",.08);add("confidence",.05);app.tags={"gratitude","social"}
    elseif eventType=="PROMISE" then
        add("warmth",.12);add("comfort",.10);add("confidence",.05);app.tags={"promise","expectation"}
    elseif eventType=="HOSTILITY" then
        if mine then add("anger",.46);add("stress",.18);add("warmth",-.18);add("comfort",-.16) else add("sadness",.24);add("anger",.28+.26*vindictive);add("stress",.30);add("vigilance",.18);add("comfort",-.25);add("confidence",-.10) end
        app.tags={"hostility","conflict"}
    elseif eventType=="MOCKERY" then
        add("embarrassment",.42+.20*social);add("anger",.10+.24*vindictive);add("confidence",-.16);add("stress",.18);app.tags={"mockery","embarrassment","social"}
    elseif eventType=="AMBIGUOUS_TONE" then
        add("anxiety",.07+.08*(1-impulsive));add("stress",.05);add("vigilance",.04);app.tags={"ambiguity","tone","social"}
    elseif eventType=="SELF_PAIN" then
        add("stress",.24);add("comfort",-.38);add("focus",-.16);add("vigilance",.10);app.tags={"pain","physical"}
    elseif eventType=="THREAT" then
        add("vigilance",.56);add("stress",.45);add("anxiety",.18+.30*spineless);add("anger",.12+.26*(1-spineless)+.14*vindictive);add("focus",.12);app.tags={"threat","danger"}
    elseif eventType=="AGGRESSION" then
        add("vigilance",.68);add("stress",.58);add("anger",.30+.24*(1-spineless));add("anxiety",.18+.26*spineless);add("comfort",-.28);add("focus",.10);app.tags={"aggression","danger"}
    elseif eventType=="DISAGREEMENT" then
        add("stress",.07);add("anger",.05+.10*vindictive);app.tags={"disagreement"}
    elseif eventType=="AGREEMENT" then
        add("warmth",.08);add("confidence",.06);app.tags={"agreement"}
    elseif eventType=="SELF_SADNESS" then
        add("sadness",.48);add("stress",.16);add("focus",-.20);app.tags={"sadness","self-expression"}
    elseif eventType=="SELF_ANGER" then
        add("anger",.48);add("stress",.20);add("vigilance",.10);app.tags={"anger","self-expression"}
    elseif eventType=="SELF_FEAR" then
        add("anxiety",.52);add("vigilance",.35);add("stress",.34);add("confidence",-.14);app.tags={"fear","self-expression"}
    end
    return app
end

local function applyAppraisal(q,app)
    for k,v in pairs(app.emotion or {}) do emotion(q,k,v) end
end

local function decayConcern(row,t)
    local age=math.max(0,(t or now())-(tonumber(row.at) or 0));local hl=math.max(60,tonumber(row.halfLife) or 600)
    local base=tonumber(row.baseSalience) or tonumber(row.salience) or 0
    local value=base*math.pow(.5,age/hl)
    if row.resolved then value=value*.22
    elseif tonumber(row.stickyFloor) and (t or now())-(tonumber(row.at) or 0)<(tonumber(row.stickyFor) or 0) then value=math.max(value,tonumber(row.stickyFloor) or 0) end
    return clamp(value,0,100)
end
local function pruneConcerns(q)
    local t=now();local out={}
    for _,r in ipairs(q.concerns) do local s=decayConcern(r,t);r.salience=s;if s>=4 and t-(tonumber(r.at) or 0)<7*86400 then out[#out+1]=r end end
    q.concerns=out;while #q.concerns>MAX_CONCERNS do table.remove(q.concerns) end
end
local function concernSignature(eventType,person)
    return eventType.."|"..(speakerIdentityKey(person)~=""and speakerIdentityKey(person)or"self")
end
local function upsertConcern(q,event)
    local def=EVENT_DEF[event.type] or {base=.3,halfLife=600}
    local base=clamp((event.appraisal and event.appraisal.intensity or def.base)*100,5,100)
    local sig=concernSignature(event.type,event.person);local t=now();local row
    for _,r in ipairs(q.concerns) do if r.signature==sig and t-(tonumber(r.at) or 0)<math.max(900,(tonumber(r.halfLife) or 600)*2) then row=r break end end
    if row then
        row.baseSalience=clamp(math.max(decayConcern(row,t),base)+base*.18,0,100);row.salience=row.baseSalience;row.at=t;row.eventID=event.id;row.count=(row.count or 1)+1;row.resolved=false;row.evidence=event.evidence;row.reason=event.reason
    else
        local floor,floorFor=0,0
        if event.type=="RELATIONSHIP_BREAK" then floor,floorFor=18,6*3600
        elseif event.type=="TRUST_BROKEN" then floor,floorFor=10,3*3600
        elseif event.type=="THREAT" or event.type=="AGGRESSION" then floor,floorFor=7,90*60
        elseif event.type=="HOSTILITY" or event.type=="REJECTION" then floor,floorFor=5,45*60 end
        row={id="C"..tostring(event.id),signature=sig,type=event.type,person=event.person,baseSalience=base,salience=base,halfLife=def.halfLife or 600,at=t,eventID=event.id,count=1,resolved=false,evidence=event.evidence,reason=event.reason,stickyFloor=floor,stickyFor=floorFor}
        table.insert(q.concerns,1,row)
    end
    while #q.concerns>MAX_CONCERNS do table.remove(q.concerns) end
    return row
end
local function resolveConcernsFor(q,eventType,person)
    local n=speakerIdentityKey(person)
    for _,r in ipairs(q.concerns) do
        local rn=speakerIdentityKey(r.person)
        if n~="" and rn==n then
            if eventType=="RECONCILIATION" and r.type=="RELATIONSHIP_BREAK" then r.resolved=true;r.baseSalience=math.min(decayConcern(r),28);r.at=now()
            elseif eventType=="FORGIVENESS" and (r.type=="INSULT" or r.type=="SOCIAL_DISAPPROVAL" or r.type=="HUMOR_REJECTED" or r.type=="REJECTION" or r.type=="TRUST_BROKEN" or r.type=="TRUST_ACCUSATION" or r.type=="HOSTILITY" or r.type=="MOCKERY") then r.resolved=true;r.baseSalience=math.min(decayConcern(r),14);r.at=now()
            elseif eventType=="TRUST_GIVEN" and r.type=="TRUST_BROKEN" then r.resolved=true;r.baseSalience=math.min(decayConcern(r),20);r.at=now()
            elseif eventType=="APOLOGY" and (r.type=="INSULT" or r.type=="SOCIAL_DISAPPROVAL" or r.type=="HUMOR_REJECTED" or r.type=="REJECTION" or r.type=="MOCKERY") then r.resolved=true;r.baseSalience=math.min(decayConcern(r),18);r.at=now() end
        end
    end
end

local function updateRelationshipDimensions(p,event)
    if not event.person or trim(event.person.name)=="" then return end
    p.continuity=type(p.continuity)=="table" and p.continuity or {};p.continuity.dynamics=type(p.continuity.dynamics)=="table" and p.continuity.dynamics or {}
    local key=event.person.name:lower():gsub("[^%w%s']",""):gsub("%s+"," ")
    local d=p.continuity.dynamics[key];if type(d)~="table" then d={key=key,name=event.person.name,warmth=0,tension=0,familiarity=0,interactions=0,lastAt=0,presentUntil=0};p.continuity.dynamics[key]=d end
    d.name=event.person.name;d.trust=tonumber(d.trust) or .50;d.comfort=tonumber(d.comfort) or .45;d.respect=tonumber(d.respect) or .50;d.resentment=tonumber(d.resentment) or 0;d.fear=tonumber(d.fear) or 0;d.attraction=tonumber(d.attraction) or 0
    d.observedWarmth=tonumber(d.observedWarmth)or tonumber(d.warmth)or 0;d.observedTension=tonumber(d.observedTension)or tonumber(d.tension)or 0
    local i=event.appraisal and event.appraisal.intensity or .3;local typ=event.type
    if typ=="RELATIONSHIP_BREAK" then d.trust=clamp(d.trust-.38*i,0,1);d.comfort=clamp(d.comfort-.68*i,0,1);d.resentment=clamp(d.resentment+.32*i,0,1);d.observedTension=clamp((d.observedTension or 0)+.55*i,0,1);d.observedWarmth=clamp((d.observedWarmth or 0)-.18*i,0,1)
    elseif typ=="RECONCILIATION" then d.trust=clamp(d.trust+.20*i,0,1);d.comfort=clamp(d.comfort+.25*i,0,1);d.resentment=clamp(d.resentment-.22*i,0,1);d.observedWarmth=clamp((d.observedWarmth or 0)+.22*i,0,1);d.observedTension=clamp((d.observedTension or 0)-.18*i,0,1)
    elseif typ=="INSULT" or typ=="REJECTION" or typ=="SOCIAL_DISAPPROVAL" or typ=="HUMOR_REJECTED" then d.trust=clamp(d.trust-.10*i,0,1);d.comfort=clamp(d.comfort-.16*i,0,1);d.resentment=clamp(d.resentment+.16*i,0,1);d.observedTension=clamp((d.observedTension or 0)+.16*i,0,1)
    elseif typ=="THREAT" or typ=="AGGRESSION" then d.trust=clamp(d.trust-.24*i,0,1);d.fear=clamp(d.fear+.26*i,0,1);d.resentment=clamp(d.resentment+.18*i,0,1);d.observedTension=clamp((d.observedTension or 0)+.28*i,0,1)
    elseif typ=="TRUST_BROKEN" then d.trust=clamp(d.trust-.42*i,0,1);d.comfort=clamp(d.comfort-.28*i,0,1);d.respect=clamp(d.respect-.16*i,0,1);d.resentment=clamp(d.resentment+.30*i,0,1);d.observedTension=clamp((d.observedTension or 0)+.32*i,0,1)
    elseif typ=="TRUST_ACCUSATION" then d.trust=clamp(d.trust-.16*i,0,1);d.comfort=clamp(d.comfort-.12*i,0,1);d.observedTension=clamp((d.observedTension or 0)+.18*i,0,1)
    elseif typ=="HOSTILITY" then d.trust=clamp(d.trust-.25*i,0,1);d.comfort=clamp(d.comfort-.22*i,0,1);d.resentment=clamp(d.resentment+.28*i,0,1);d.observedTension=clamp((d.observedTension or 0)+.30*i,0,1);d.observedWarmth=clamp((d.observedWarmth or 0)-.18*i,0,1)
    elseif typ=="MOCKERY" then d.trust=clamp(d.trust-.08*i,0,1);d.comfort=clamp(d.comfort-.12*i,0,1);d.resentment=clamp(d.resentment+.12*i,0,1);d.observedTension=clamp((d.observedTension or 0)+.12*i,0,1)
    elseif typ=="TRUST_GIVEN" then d.trust=clamp(d.trust+.20*i,0,1);d.comfort=clamp(d.comfort+.10*i,0,1);d.observedWarmth=clamp((d.observedWarmth or 0)+.08*i,0,1);d.observedTension=clamp((d.observedTension or 0)-.05*i,0,1)
    elseif typ=="FORGIVENESS" then d.trust=clamp(d.trust+.08*i,0,1);d.resentment=clamp(d.resentment-.28*i,0,1);d.comfort=clamp(d.comfort+.12*i,0,1);d.observedTension=clamp((d.observedTension or 0)-.22*i,0,1)
    elseif typ=="GRATITUDE" then d.observedWarmth=clamp((d.observedWarmth or 0)+.10*i,0,1);d.respect=clamp(d.respect+.06*i,0,1);d.comfort=clamp(d.comfort+.05*i,0,1)
    elseif typ=="PROMISE" then d.trust=clamp(d.trust+.08*i,0,1);d.comfort=clamp(d.comfort+.05*i,0,1)
    elseif typ=="INTIMACY" then d.attraction=clamp(d.attraction+.22*i,0,1);d.trust=clamp(d.trust+.10*i,0,1);d.comfort=clamp(d.comfort+.16*i,0,1);d.observedWarmth=clamp((d.observedWarmth or 0)+.18*i,0,1);d.familiarity=clamp((d.familiarity or 0)+.14*i,0,1);d.observedTension=clamp((d.observedTension or 0)-.08*i,0,1)
    elseif typ=="AFTERCARE" then d.trust=clamp(d.trust+.18*i,0,1);d.comfort=clamp(d.comfort+.24*i,0,1);d.observedWarmth=clamp((d.observedWarmth or 0)+.20*i,0,1);d.familiarity=clamp((d.familiarity or 0)+.10*i,0,1);d.observedTension=clamp((d.observedTension or 0)-.14*i,0,1)
    elseif typ=="AFFECTION" or typ=="SOCIAL_WARMTH" or typ=="SUPPORT" or typ=="COMPLIMENT" or typ=="REASSURANCE" then d.trust=clamp(d.trust+.10*i,0,1);d.comfort=clamp(d.comfort+.15*i,0,1);d.observedWarmth=clamp((d.observedWarmth or 0)+.16*i,0,1);d.observedTension=clamp((d.observedTension or 0)-.08*i,0,1)
    elseif typ=="VULNERABILITY" then d.trust=clamp(d.trust+.08*i,0,1);d.comfort=clamp(d.comfort+.06*i,0,1);d.familiarity=clamp((d.familiarity or 0)+.10*i,0,1)
    elseif typ=="INVITATION" then d.comfort=clamp(d.comfort+.06*i,0,1);d.observedWarmth=clamp((d.observedWarmth or 0)+.07*i,0,1)
    elseif typ=="BOUNDARY" then d.comfort=clamp(d.comfort-.03*i,0,1);d.familiarity=clamp((d.familiarity or 0)+.06*i,0,1)
    elseif typ=="APOLOGY" then d.trust=clamp(d.trust+.06*i,0,1);d.resentment=clamp(d.resentment-.12*i,0,1);d.observedTension=clamp((d.observedTension or 0)-.08*i,0,1) end
    d.interactions=(tonumber(d.interactions) or 0)+1;d.lastAt=now();d.presentUntil=now()+8*60
    d.warmth=d.observedWarmth;d.tension=d.observedTension

end

-- When a schema upgrade proves that old generated events were unreliable,
-- remove their secondary emotional/relationship residue as well. This is
-- generic evidence cleanup; it never checks a specific character identity.
function RPAL.ResetPersonaCognitiveEffectsAfterAudit(q,p,rejectedEvents)
    if type(q)=="table"then
        q.emotions={}
        for k,v in pairs(BASE_EMOTIONS)do q.emotions[k]=v end
        q.lastEmotionAt=now();q.pending=nil;q.attentionLockUntil=0;q.attentionLockEventID=nil
    end
    if type(p)~="table"or type(p.continuity)~="table"or type(p.continuity.dynamics)~="table"then return end
    local touched={}
    for _,event in ipairs(type(rejectedEvents)=="table"and rejectedEvents or{})do
        local name=event and event.person and trim(event.person.name)or""
        local key=name:lower():gsub("[^%w%s']",""):gsub("%s+"," ")
        if key~=""then touched[key]=true end
    end
    for key in pairs(touched)do
        local d=p.continuity.dynamics[key]
        if type(d)=="table"then
            d.trust=.50;d.comfort=.45;d.respect=.50;d.resentment=0;d.fear=0
            -- Preserve only a strong pre-existing attraction seed (e.g. an
            -- explicit relationship seed). Small attraction created by an old
            -- generated intimacy guess is discarded.
            d.attraction=(tonumber(d.attraction)or 0)>=.60 and d.attraction or 0
            d.observedWarmth=0;d.observedTension=0;d.warmth=0;d.tension=0
            d.cognitionAuditResetAt=now()
        end
    end
end

local function eventMemoryText(event)
    local n=event.person and event.person.name
    local t=event.type
    if t=="RELATIONSHIP_BREAK" and n then return "Things with "..n.." ended, and the change still carries weight." end
    if t=="RECONCILIATION" and n then return n.." tried to repair something that had broken between us." end
    if t=="THREAT" and n then return n.." made a threat that felt serious enough to remember." end
    if t=="AGGRESSION" and n then return n.." turned physically aggressive, and my body has not forgotten it." end
    if t=="INSULT" and n then return "Something "..n.." said cut deeper than an ordinary remark." end
    if t=="AFFECTION" and n then return "A moment of affection with "..n.." has stayed with me." end
    if t=="INTIMACY" and n then return "A moment of intimacy with "..n.." changed how close the relationship feels." end
    if t=="AFTERCARE" and n then return "The tenderness from "..n.." after an intimate moment made the closeness feel safer." end
    if t=="SUPPORT" and n then return n.." was there when I needed support." end
    if t=="TRUST_BROKEN" and n then return "Something with "..n.." damaged my trust, and it has not settled yet." end
    if t=="TRUST_ACCUSATION" and n then return n.." accused me of damaging their trust." end
    if t=="TRUST_GIVEN" and n then return n.." explicitly placed trust in me." end
    if t=="FORGIVENESS" and n then return "Something unresolved with "..n.." was explicitly forgiven." end
    if t=="PROMISE" and n then return "A promise involving "..n.." felt important enough to remember." end
    if t=="HOSTILITY" and n then return "Open hostility with "..n.." changed how safe the relationship feels." end
    return nil
end

local function eventSummary(event)
    local n=event.person and event.person.name
    local t=event.type
    local map={
        HUMOR_REJECTED=n and (n.." reacted coldly to a recent attempt at humor") or "A recent attempt at humor was met coldly",
        HUMOR_LANDED=n and (n.." reacted warmly to a recent attempt at humor") or "A recent attempt at humor landed well",
        SOCIAL_DISAPPROVAL=n and (n.." gave a clear negative reaction") or "A clear negative social reaction landed",
        SOCIAL_WARMTH=n and (n.." gave a warm social reaction") or "A warm social reaction landed",
        INSULT=n and (n.." made a personal insult") or "A personal insult landed",
        COMPLIMENT=n and (n.." offered a compliment") or "A compliment landed",
        APOLOGY=n and (n.." apologized") or "An apology was offered",
        AFFECTION=n and (n.." expressed affection") or "Affection was expressed",
        INTIMACY=n and ("A moment of sexual intimacy with "..n.." deepened the relationship") or "A moment of sexual intimacy deepened closeness",
        AFTERCARE=n and (n.." offered gentle care after an intimate moment") or "Gentle care followed an intimate moment",
        RELATIONSHIP_BREAK=n and ("The relationship with "..n.." ended") or "A close relationship ended",
        RECONCILIATION=n and (n.." moved toward repairing the relationship") or "A close relationship moved toward repair",
        REJECTION=n and (n.." directly rejected or pushed the character away") or "A direct rejection landed",
        THREAT=n and (n.." made a direct threat") or "A direct threat landed",
        AGGRESSION=n and (n.." became physically aggressive") or "Physical aggression landed",
        SUPPORT=n and (n.." offered support") or "Support was offered",
        OTHER_DISTRESS=n and (n.." visibly seemed distressed") or "Someone emotionally important seemed distressed",
        TRUST_BROKEN=n and ("Something involving "..n.." damaged the character's trust") or "The character's trust was explicitly damaged",
        TRUST_ACCUSATION=n and (n.." accused the character of breaking their trust") or "The character was accused of breaking trust",
        TRUST_GIVEN=n and ((event.mine and "The character explicitly placed trust in "..n) or (n.." explicitly offered trust")) or "Trust was explicitly offered",
        FORGIVENESS=n and ((event.mine and "The character explicitly forgave "..n) or (n.." explicitly offered forgiveness")) or "A conflict was explicitly forgiven",
        GRATITUDE=n and ((event.mine and "The character expressed gratitude toward "..n) or (n.." expressed gratitude")) or "Gratitude was expressed",
        PROMISE=n and ((event.mine and "The character made a promise involving "..n) or (n.." made a promise")) or "A meaningful promise was made",
        REASSURANCE=n and (n.." offered reassurance") or "Reassurance was offered",
        VULNERABILITY=n and (n.." opened up about a fear or uncertainty") or "Someone opened up about a fear or uncertainty",
        INVITATION=n and (n.." offered a direct invitation") or "A direct invitation was offered",
        BOUNDARY=n and ((event.mine and"The character made a relationship boundary explicit with "..n)or(n.." made a relationship boundary explicit"))or "A relationship boundary was made explicit",
        HOSTILITY=n and ((event.mine and "The character expressed direct hostility toward "..n) or (n.." expressed direct hostility")) or "Direct hostility was expressed",
        MOCKERY=n and ((event.tone and event.tone.score and event.tone.score>=.68) and (n.." used a sarcastic remark that read as mockery") or (n.." openly mocked the character")) or "The character was openly mocked",
        AMBIGUOUS_TONE=n and (n.." said something that may not have been sincere") or "A recent remark sounded possibly insincere",
        SELF_PAIN="The character visibly reacted to physical pain",
        AGREEMENT=n and (n.." agreed with the character") or "A point was agreed with",
        DISAGREEMENT=n and (n.." challenged the character") or "A point was challenged",
        SELF_SADNESS="The character openly expressed sadness",
        SELF_ANGER="The character openly expressed anger",
        SELF_FEAR="The character openly expressed fear",
    }
    return map[t] or (EVENT_DEF[t] and EVENT_DEF[t].label) or t
end

local function refreshUI()
    if RPAL.RefreshCharacterPreview then pcall(RPAL.RefreshCharacterPreview) end
    if RPAL.RefreshPersonalityUI and RPAL.IsPersonalityOpen and RPAL.IsPersonalityOpen() then pcall(RPAL.RefreshPersonalityUI) end
end

-- Small, explicit conversational moments are valuable precisely because they
-- are tied to what was just said. They may surface quickly, but each is
-- exhausted after one thought so Persona never becomes a subtitle feed.
local DIALOGUE_MICRO={GRATITUDE=true,COMPLIMENT=true,APOLOGY=true,AFFECTION=true,INTIMACY=true,AFTERCARE=true,SUPPORT=true,TRUST_GIVEN=true,FORGIVENESS=true,PROMISE=true,REASSURANCE=true,VULNERABILITY=true,INVITATION=true,BOUNDARY=true,AGREEMENT=true,SOCIAL_WARMTH=true,HUMOR_LANDED=true}
local ONE_SHOT_EVENT={GRATITUDE=true,COMPLIMENT=true,APOLOGY=true,AFFECTION=true,INTIMACY=true,AFTERCARE=true,SUPPORT=true,TRUST_GIVEN=true,FORGIVENESS=true,PROMISE=true,REASSURANCE=true,VULNERABILITY=true,INVITATION=true,BOUNDARY=true,AGREEMENT=true,SOCIAL_WARMTH=true,HUMOR_LANDED=true}
local EVENT_EVIDENCE_PATTERNS={
    GRATITUDE=PAT.gratitude,COMPLIMENT=PAT.compliment,APOLOGY=PAT.apology,AFFECTION=PAT.affection,SUPPORT=PAT.support,
    TRUST_GIVEN=PAT.trustGiven,FORGIVENESS=PAT.forgiveness,PROMISE=PAT.promise,REASSURANCE=PAT.reassurance,
    VULNERABILITY=PAT.vulnerability,INVITATION=PAT.invitation,BOUNDARY=PAT.boundary,AGREEMENT=PAT.agreement,
    SOCIAL_WARMTH=EMOTE.positive,HUMOR_LANDED=EMOTE.laughter,INTIMACY=INTIMACY_EXPLICIT,AFTERCARE=AFTERCARE_CUES,
}
local function eventEvidence(text,eventType)
    local raw=trim(text);if#raw<=260 then return raw end
    local low=raw:lower():gsub("’","'");local hit
    for _,pat in ipairs(EVENT_EVIDENCE_PATTERNS[eventType]or{})do local i=low:find(pat,1,true);if i and(not hit or i<hit)then hit=i end end
    if not hit then return raw:sub(1,260)end
    local first=math.max(1,hit-82);local last=math.min(#raw,hit+174)
    while first<=#raw do local b=raw:byte(first);if not b or b<128 or b>=192 then break end;first=first+1 end
    while last>first do local b=raw:byte(last);if not b or b<128 or b>=192 then break end;last=last-1 end
    return(first>1 and"…"or"")..trim(raw:sub(first,last))..(last<#raw and"…"or"")
end

local function recordEvent(eventType,person,confidence,evidence,reason,source,mine)
    local q,p=ensure();if not q then return nil end
    local def=EVENT_DEF[eventType];if not def then return nil end
    if eventType=="INTIMACY"then
        local pk=speakerIdentityKey(person)
        for _,old in ipairs(q.events or{})do
            local age=now()-(tonumber(old.lastAt)or tonumber(old.at)or 0);if age>90 then break end
            if old.type=="INTIMACY"and pk~=""and speakerIdentityKey(old.person)==pk then
                old.lastAt=now();old.coalesced=(tonumber(old.coalesced)or 0)+1;old.confidence=math.max(tonumber(old.confidence)or 0,tonumber(confidence)or 0)
                old.evidence=eventEvidence(evidence,eventType);old.reason=reason or old.reason;old.lastSource=source;return old
            end
        end
    end
    local contextFactor,chain=recentEventContextFactor(q,eventType,person)
    local app=appraisalFor(eventType,person,confidence or .6,mine,contextFactor)
    local id=q.nextEventID;q.nextEventID=id+1
    local thread=type(q.currentThread)=="table"and q.currentThread or nil
    local ownership=type(q.currentOwnership)=="table"and q.currentOwnership or nil
    local ev={id=id,type=eventType,at=now(),person=person,confidence=clamp(confidence or .6,0,1),evidence=eventEvidence(evidence,eventType),reason=reason,source=source or "roleplay",mine=mine and true or false,appraisal=app,chain=chain,ownershipClass=mine and"self-to-other"or(ownership and ownership.class)or"unknown",ownershipConfidence=mine and(person and 1 or 0)or(ownership and ownership.confidence)or 0,ownershipReason=mine and"self-authored"or(ownership and ownership.reason)or"unknown",thread=thread and{coherent=thread.coherent and true or false,score=thread.score,turns=thread.turns,topicMatch=thread.topicMatch,person=thread.person,ownershipClass=thread.ownershipClass,ownershipConfidence=thread.ownershipConfidence}or nil}
    if RPAL.AttachPersonaEpisode then pcall(RPAL.AttachPersonaEpisode,ev,q,p)end
    pushFront(q.events,ev,MAX_EVENTS)
    resolveConcernsFor(q,eventType,person)
    if person and trim(person.name)~="" then
        local pk=speakerIdentityKey(person)
        if eventType=="RELATIONSHIP_BREAK" then
            q.relationshipOverrides[pk]={status="ruptured",at=now(),eventID=id,name=person.name}
            q.unresolved["relationship|"..pk]={type=eventType,person=person.name,eventID=id,at=now(),resolved=false}
        elseif eventType=="RECONCILIATION" then
            q.relationshipOverrides[pk]=nil
            local u=q.unresolved["relationship|"..pk];if u then u.resolved=true;u.resolvedAt=now() end
        elseif eventType=="THREAT" or eventType=="AGGRESSION" or eventType=="INSULT" or eventType=="REJECTION" or eventType=="TRUST_BROKEN" or eventType=="TRUST_ACCUSATION" or eventType=="HOSTILITY" or eventType=="MOCKERY" then
            q.unresolved[string.lower(eventType).."|"..pk]={type=eventType,person=person.name,eventID=id,at=now(),resolved=false}
        elseif eventType=="FORGIVENESS" then
            for uk,u in pairs(q.unresolved) do if type(u)=="table" and norm(u.person)==pk and u.type~="RELATIONSHIP_BREAK" then u.resolved=true;u.resolvedAt=now() end end
        elseif eventType=="TRUST_GIVEN" then
            for uk,u in pairs(q.unresolved) do if type(u)=="table" and norm(u.person)==pk and u.type=="TRUST_BROKEN" then u.resolved=true;u.resolvedAt=now() end end
        elseif eventType=="APOLOGY" then
            for uk,u in pairs(q.unresolved) do if type(u)=="table" and norm(u.person)==pk and (u.type=="INSULT" or u.type=="REJECTION" or u.type=="SOCIAL_DISAPPROVAL" or u.type=="HUMOR_REJECTED" or u.type=="MOCKERY") then u.resolved=true;u.resolvedAt=now() end end
        end
    end
    applyAppraisal(q,app)
    updateRelationshipDimensions(p,ev)
    local concern=upsertConcern(q,ev);ev.concernID=concern and concern.id
    if concern then
        local lock=0
        if concern.salience>=85 then lock=12*60 elseif concern.salience>=68 then lock=6*60 elseif concern.salience>=50 then lock=150 elseif concern.salience>=36 then lock=75 end
        if lock>0 then q.attentionLockUntil=math.max(tonumber(q.attentionLockUntil) or 0,now()+lock);q.attentionLockEventID=id end
        if eventType=="RECONCILIATION" or eventType=="FORGIVENESS" then q.attentionLockUntil=math.min(tonumber(q.attentionLockUntil) or now(),now()+45) end
    end
    -- PersonaEpisodes defers durable storage briefly so the player's own reply
    -- or body language can become part of the remembered event. Keep the old
    -- semantic fallback only when that layer is unavailable.
    if not RPAL.AttachPersonaEpisode then
        local memory=eventMemoryText(ev);if memory and app.intensity>=.62 and RPAL.AddPersonaMemory then pcall(RPAL.AddPersonaMemory,"cognitive-event",memory,clamp(app.intensity,.45,1),{eventID=id,type=eventType,person=person and person.name,confidence=ev.confidence}) end
    end
    -- High-salience events earn a near-term reactive thought. A coherent,
    -- directly-linked dialogue micro-event may also earn one restrained reply.
    local dialogue=DIALOGUE_MICRO[eventType]and not mine and person and thread and thread.coherent
    local dialogueReady=dialogue and now()-(tonumber(q.lastDialogueThoughtAt)or 0)>=45
    if concern and ev.confidence>=.58 and(concern.salience>=34 or(dialogueReady and concern.salience>=12))then
        local delay=dialogueReady and(3.8+math.random()*5.2)or(concern.salience>=80 and(2.8+math.random()*3.8)or(5.0+math.random()*7.0))
        if not q.pending or(not dialogue and(concern.salience or 0)>(tonumber(q.pending.salience)or 0)+8)then q.pending={eventID=id,concernID=concern.id,at=gtime()+delay,salience=concern.salience,dialogue=dialogueReady and true or false}end
    end
    refreshUI();return ev
end
RPAL.RecordPersonaCognitiveEvent=recordEvent

local STATE_DETAIL={
    HURT="A recent emotional loss is still taking up more space than ordinary thoughts.",
    DISTRAUGHT="Strong emotion is crowding out concentration and ordinary background concerns.",
    SELF_CONSCIOUS="A recent social moment is still being replayed with uncomfortable attention.",
    WITHDRAWN="The character is pulling inward while a recent hurt continues to settle.",
    RELIEVED="A recent source of pressure has eased enough to be felt physically and mentally.",
    TENSE="Attention is staying close to possible trouble after a recent threat or conflict.",
    CONCERNED="Someone important seems distressed enough that part of the character's attention keeps returning to them.",
    UNCOMFORTABLE="Physical discomfort is taking enough attention to compete with ordinary thoughts.",
}
local baseGetLiving=RPAL.GetPersonaLivingState
local function cognitiveState()
    local q=ensure();if not q then return nil end;decayEmotions(q);pruneConcerns(q)
    local e=q.emotions;local top=q.concerns[1]
    if #q.concerns>1 then table.sort(q.concerns,function(a,b)return decayConcern(a)>decayConcern(b)end);top=q.concerns[1] end
    local topSal=top and decayConcern(top) or 0
    local label
    -- A strong, recent event can define the visible state even before slower
    -- emotional values have fully accumulated. This is what keeps a cold
    -- reaction to a joke from leaving the character inexplicably FOCUSED.
    if top and topSal>=34 then
        if top.type=="RELATIONSHIP_BREAK" then label=topSal>=72 and "DISTRAUGHT" or "HURT"
        elseif top.type=="HUMOR_REJECTED" or top.type=="SOCIAL_DISAPPROVAL" then label="SELF_CONSCIOUS"
        elseif top.type=="REJECTION" then label=(e.loneliness or 0)>=.36 and "WITHDRAWN" or "HURT"
        elseif top.type=="TRUST_BROKEN" then label=(e.anger or 0)>(e.sadness or 0)*1.15 and "TENSE" or "HURT"
        elseif top.type=="TRUST_ACCUSATION" then label=(e.anger or 0)>.42 and "TENSE" or "SELF_CONSCIOUS"
        elseif top.type=="HOSTILITY" then label=(e.vigilance or 0)>=.42 and "TENSE" or "HURT"
        elseif top.type=="MOCKERY" then label="SELF_CONSCIOUS"
        elseif top.type=="SELF_PAIN" then label="UNCOMFORTABLE"
        elseif top.type=="OTHER_DISTRESS" then label="CONCERNED"
        elseif top.type=="THREAT" or top.type=="AGGRESSION" then label="TENSE"
        elseif (top.type=="RECONCILIATION" or top.type=="FORGIVENESS") and topSal>=48 then label="RELIEVED" end
    end
    if not label and (e.sadness or 0)>=.72 and (e.stress or 0)>=.52 then label="DISTRAUGHT"
    elseif not label and (e.sadness or 0)>=.56 then label="HURT"
    elseif not label and (e.embarrassment or 0)>=.50 then label="SELF_CONSCIOUS"
    elseif not label and (e.loneliness or 0)>=.48 and (e.sadness or 0)>=.30 then label="WITHDRAWN"
    elseif not label and ((e.vigilance or 0)>=.60 or (e.anxiety or 0)>=.62) then label="TENSE"
    elseif not label and (e.relief or 0)>=.55 and (e.stress or 0)<.36 then label="RELIEVED" end
    if not label then return nil end
    return {label=label,detail=STATE_DETAIL[label],score=math.max(e.sadness or 0,e.embarrassment or 0,e.vigilance or 0,e.relief or 0),since=tonumber(q.stateSince) or now(),updatedAt=now(),cognitive=true,topConcern=top}
end
if baseGetLiving then
    RPAL.GetPersonaLivingState=function()
        local s=cognitiveState();if s then
            local q=ensure();if q and q.lastState~=s.label then q.lastState=s.label;q.stateSince=now();s.since=q.stateSince;refreshUI() elseif q then s.since=q.stateSince or s.since end
            return s
        end
        local base=baseGetLiving() or {};local q=ensure();if q and q.lastState~=base.label then q.lastState=base.label;q.stateSince=now() end;base.since=q and q.stateSince or base.since;return base
    end
end

function RPAL.GetPersonaCognitiveEvents(limit)
    local q=ensure();local out={};if not q then return out end;limit=math.max(1,math.min(MAX_EVENTS,math.floor(tonumber(limit) or 10)))
    for i=1,math.min(limit,#q.events) do out[#out+1]=q.events[i] end;return out
end
function RPAL.GetPersonaActiveConcerns(limit)
    local q=ensure();local out={};if not q then return out end;pruneConcerns(q);table.sort(q.concerns,function(a,b)return decayConcern(a)>decayConcern(b)end)
    limit=math.max(1,math.min(MAX_CONCERNS,math.floor(tonumber(limit) or 8)))
    for i=1,math.min(limit,#q.concerns) do local r=q.concerns[i];out[#out+1]={id=r.id,type=r.type,person=r.person,salience=decayConcern(r),at=r.at,eventID=r.eventID,resolved=r.resolved,evidence=r.evidence,reason=r.reason} end
    return out
end
function RPAL.GetPersonaCognitiveEmotions()
    local q=ensure();if not q then return {} end;decayEmotions(q);local out={};for k,v in pairs(q.emotions) do out[k]=v end;return out
end

-- ---------------------------- Thought intents -----------------------------
local BANK={
    HUMOR_REJECTED={
        embarrassed={"That joke sounded better in my head.","That reaction made the whole thing feel awkward fast.","I can still feel that little pause after I said it.","That definitely did not land the way I expected.","I keep remembering the look I got after that joke.","I thought that would get more of a reaction than it did.","That was a rough little silence to sit through.","I can still feel myself wanting to take that joke back.","The joke was fine until I saw the reaction to it.","I did not expect that to feel quite so awkward."},
        irritated={"That reaction is still annoying me a little.","I keep thinking that was a harsher reaction than the joke deserved.","That look was a bit much for what I actually said.","I am still irritated by how badly that was received.","Part of me is still wondering what the problem was.","I did not think that deserved quite that reaction.","That frown is lingering in my head more than the joke itself.","I am still a little bothered by how cold that response felt."},
        uncertain={"I wonder what part of that bothered them.","Maybe I read the mood wrong before I said that.","I keep wondering whether the joke sounded different out loud.","I cannot quite tell whether the problem was the joke or the timing.","Something about that reaction has me second-guessing the moment.","I wonder if I missed something before I opened my mouth.","That reaction still has me trying to work out what I misread.","I keep turning over why that went down so badly."},
    },
    HUMOR_LANDED={
        warm={"I liked hearing %s laugh at that.","There was something easy about how quickly %s caught the joke.","That reaction from %s made the moment feel lighter.","I am still a little pleased that the joke landed with %s.","The way %s laughed was nicer to hear than I expected.","That little bit of shared humor with %s felt good.","I keep smiling at how well that went over with %s.","It was good hearing %s meet the joke exactly where I meant it."},
    },
    SOCIAL_DISAPPROVAL={
        embarrassed={"That look was hard to miss.","I can still feel that reaction sitting on me a little.","I keep replaying the expression I got.","That was an uncomfortable reaction to be on the receiving end of.","I did not expect that look to bother me this much.","I keep noticing that I am still thinking about that reaction.","Something about that expression made me feel suddenly too visible.","That moment got awkward faster than I expected."},
        irritated={"That look is still getting on my nerves.","I am not sure why that reaction annoyed me this much.","I keep thinking that reaction was unnecessary.","Something about being looked at like that still bothers me.","I can feel the irritation come back whenever I remember that expression.","That reaction felt sharper than the moment called for."},
        uncertain={"I wonder what that reaction was really about.","Something about that look still does not make sense to me.","I keep wondering what I did to earn that reaction.","That expression left me with more questions than answers.","I am still trying to read what that look meant."},
    },
    INSULT={
        angry={"What they said is still pissing me off.","That comment is still stuck under my skin.","I keep replaying the way they said that, and it is not helping.","I am still angry about that remark.","That was a cheap thing to say, and I have not stopped being annoyed by it.","I can still feel my temper rise when I remember that comment.","That insult is taking longer to fade than I expected.","I am still carrying more anger from that than I want to."},
        hurt={"That comment landed harder than I wanted it to.","I hate that what they said actually got to me.","I keep telling myself it was only a remark, but it still stings.","That hit a more sensitive spot than they probably realized.","I am still feeling the edge of what they said.","I did not expect that comment to stay with me.","Something about that insult felt more personal than the others."},
        replay={"I keep hearing that comment again in exactly the same tone.","The wording of that insult keeps coming back to me.","I am still turning over why they chose those words.","That one line keeps replaying at inconvenient moments.","I keep remembering the exact pause before they said it."},
    },
    COMPLIMENT={
        warm={"That was nice to hear from %s.","I am still a little pleased by what %s said about me.","I did not expect %s's compliment to stay with me like this.","That was kinder of %s than I expected.","I keep finding myself thinking back to what %s noticed.","That remark from %s put me in a better mood than I expected.","I liked hearing that from %s more than I let on.","There was something genuine in the way %s said that."},
        awkward={"I never quite know what to do with a compliment from %s.","That was nice of %s, even if I felt awkward hearing it.","I am still not sure how I was supposed to react to what %s said.","The way %s complimented me left me a little too aware of myself.","I liked hearing that from %s, and somehow that makes it more awkward.","Part of me is still wondering whether %s noticed how flustered that made me."},
    },
    APOLOGY={
        softening={"I keep turning %s's apology over in my head.","I was not expecting %s to apologize, and that matters a little.","The apology from %s took some of the edge off without erasing everything.","I can feel myself less angry than I was before %s said that.","%s's apology is sitting with me more gently than the argument did.","Part of me is relieved %s actually acknowledged it.","I did not realize how much I wanted to hear %s admit it."},
        guarded={"%s's apology does not make the whole thing disappear.","I heard %s apologize. I am still not sure what I think of it.","I keep wondering how much of %s's apology I actually believe.","The words from %s were right; I am still waiting to see what follows them.","I am less angry with %s, but I am not exactly comfortable either.","That apology from %s helped a little. Only a little."},
    },
    AFFECTION={
        warm={"I keep thinking about how close that moment with %s felt.","%s's affection is still leaving a warm little trace behind.","I liked hearing that from %s more than I expected.","I can still feel some of that closeness with %s lingering.","That moment with %s made everything else feel quieter for a while.","I keep finding myself returning to what %s said.","It is difficult not to feel a little lighter after that from %s.","The affection from %s is still sitting comfortably with me."},
    },
    INTIMACY={warm={"What happened with %s changed how close they feel to me.","My body is still remembering the ease of having %s that close.","The intimacy with %s felt less casual than I expected.","Trust and desire both feel more concrete around %s now."}},
    AFTERCARE={warm={"The gentleness from %s afterward is staying with me.","Being held by %s afterward made the closeness feel real in another way.","The quiet care from %s lowered my guard more than I expected.","The tenderness after everything with %s mattered."}},
    RELATIONSHIP_BREAK={
        loss={"It is strange how quickly everything with %s feels different.","I keep thinking about %s as if nothing changed, and then remembering that it did.","The quiet after things ended with %s feels wrong.","I am still getting used to the idea that %s and I are no longer what we were.","Everything around %s feels like it has shifted a few inches out of place.","I keep reaching for old habits around %s in my head and stopping halfway.","There is a gap where %s used to fit into my day without me thinking about it.","I did not expect the absence of %s to feel this immediate.","It keeps hitting me in small moments that things with %s are actually over.","I am still trying to understand how something familiar with %s became past tense so quickly."},
        missing={"I hate that I still miss %s right now.","Part of me keeps expecting %s to be there like before.","I keep thinking of small things I would normally tell %s.","It is annoying how often my mind still reaches for %s.","I keep remembering ordinary moments with %s more than the dramatic ones.","I miss the familiar parts of %s more than I want to admit.","I keep noticing all the little places where I expected %s to still be part of things.","I am still thinking in habits that included %s."},
        replay={"I keep hearing the way %s said it.","The last few words with %s keep replaying out of order in my head.","I keep going back over the moment things changed with %s.","There are pieces of that conversation with %s I cannot stop turning over.","I keep remembering the exact point where the tone with %s changed.","My head keeps returning to what %s said, even when I am trying to think about something else."},
        anger={"I am still angry at %s. That has not gone anywhere yet.","Part of me is still furious with %s for how that ended.","I keep finding new little reasons to be angry about what happened with %s.","The hurt keeps turning into anger whenever I think about %s too long.","I am not ready to feel neutral about %s after that.","I keep thinking about what %s did wrong, and it is not calming me down."},
        relief={"I feel guilty for how much quieter part of me feels without %s right now.","There is hurt there, but there is relief too, and that is strange to sit with.","I did not expect the end with %s to leave even a little room to breathe.","Part of me feels lighter now that things with %s are finally said out loud.","The relief after %s is not clean enough to feel good, but it is there."},
    },
    RECONCILIATION={
        relief={"I did not realize how tense I was about %s until that softened.","Things with %s still feel fragile, but at least they do not feel completely broken.","I keep thinking about the fact that %s actually came back to the conversation.","There is a little relief in knowing %s did not simply leave it ruined.","I am still cautious, but something with %s feels less final now.","That bit of repair with %s is sitting with me more than I expected."},
    },
    REJECTION={
        hurt={"Being pushed away like that still stings.","I keep feeling the rejection again whenever I think about the moment.","That was harder to hear than I wanted it to be.","I did not expect being pushed away to sit this heavily.","Part of me is still caught on the fact that they did not want me there."},
        angry={"I am still annoyed by how they pushed me away.","That rejection is turning into irritation the longer I sit with it.","I keep thinking they could have said that without making it feel so sharp.","I am not enjoying how much that rejection is still getting to me."},
        withdrawn={"I do not feel much like putting myself in front of anybody else right now.","That made being alone sound easier for a while.","I can feel myself pulling inward after that.","I am quieter in my own head after being pushed away like that."},
    },
    THREAT={
        vigilant={"I keep replaying that threat and measuring how serious it sounded.","My attention keeps drifting back to the way that threat was delivered.","I do not like how alert that made me feel.","Part of me is still listening for trouble after that threat.","My body is acting like the threat has not fully passed yet.","I keep checking the surroundings more than I was before."},
        angry={"That threat is still making me angry.","I do not like being spoken to like that.","I keep feeling my temper rise when I remember that threat.","That was a quick way to get under my skin."},
    },
    AGGRESSION={
        vigilant={"My body still feels ready for another hit.","I can feel myself staying braced after that.","The aggression stopped, but my body has not fully accepted that yet.","I keep noticing how tightly I am holding myself after that.","My attention is still treating every sudden movement like it might matter."},
        angry={"I am still angry about being hit.","The shock of that is wearing off faster than the anger.","I keep thinking about the moment they decided to get physical.","My temper is still close to the surface after that."},
    },
    SUPPORT={
        warm={"%s's support helped more than I expected.","I am still carrying a little of the relief from having %s back me up.","It was good not to feel alone with %s there.","I keep thinking about the fact that %s actually stayed with me through that.","What %s said made things feel a little more manageable for a while.","I noticed how much easier that moment felt once %s was on my side.","I keep coming back to the fact that %s did not leave me to handle that alone.","That bit of support from %s took more weight off me than I showed.","It felt good having %s stay present instead of looking away.","I am still a little calmer because %s chose to help rather than just watch."},
    },
    TRUST_BROKEN={
        hurt={"I keep coming back to the fact that I trusted %s.","Something about %s feels different now that trust has been damaged.","I did not expect doubt about %s to settle in this quickly.","The worst part is how ordinary trusting %s used to feel.","I keep noticing how much harder it is to take %s at face value now.","There is a part of me still trying to square what %s did with what I thought I knew.","I hate how quickly one thing can make everything with %s feel less certain.","Trusting %s used to be automatic. It is not anymore.","I keep thinking about the moment I stopped feeling sure about %s.","It is difficult to look at what happened with %s without feeling a little stupid for trusting it."},
        angry={"I am still angry that %s made trust feel like a mistake.","The more I think about it, the more what %s did irritates me.","I keep getting angry at how casually %s damaged something that took time to build.","What happened with %s keeps turning hurt into anger.","I am not over the fact that %s gave me a reason to doubt them.","Every time I remember it, I get annoyed all over again with %s.","I did not hand %s that trust just to have it treated carelessly.","There is still a lot of anger sitting under the doubt I feel around %s."},
        replay={"I keep replaying the part that made me stop trusting %s.","There are details about what %s said that I keep checking against each other.","My mind keeps going back over what %s told me before this happened.","I keep wondering which parts of what %s said were actually true.","The timeline around %s keeps rearranging itself in my head now.","I keep remembering older conversations with %s differently after this.","Things %s said before are starting to sound different in hindsight.","I keep looking for the point where I might have missed something with %s."},
    },
    TRUST_ACCUSATION={
        guilty={"I keep thinking about the fact that %s feels I broke their trust.","That accusation from %s is uncomfortable because part of it keeps sounding possible.","I keep going back over what I did and wondering what %s saw in it.","Being told I damaged %s's trust is sitting heavier than I expected.","I keep thinking about where %s stopped believing me.","There is an ugly little weight in knowing %s feels betrayed by me.","I keep replaying my own part in whatever made %s say that.","It is difficult to hear that %s trusted me and came away feeling fooled.","I keep wondering which part of this looked worst from %s's side.","The accusation from %s has me looking back at my own choices more closely than I want to."},
        defensive={"I keep bristling at the way %s put all of that on me.","Part of me is still arguing with %s's accusation even though the conversation has moved on.","I do not like being treated as though I deliberately set out to fool %s.","I keep thinking there is more to what happened than the version %s just gave me.","The accusation from %s keeps making me defensive all over again.","I am still irritated by how certain %s sounded about my intentions.","I keep wanting to separate what I actually did from what %s thinks I meant by it.","Something about the way %s framed that makes me want to push back."},
        replay={"I keep going back over the exact point where %s decided I had broken their trust.","I am still trying to work out what %s believes happened.","The accusation from %s keeps rearranging the earlier conversation in my head.","I keep comparing what I remember with what %s just accused me of.","There are pieces of what %s said that I still cannot place cleanly.","I keep wondering whether %s and I are even talking about the same moment.","The more I replay it, the more I notice how differently %s and I seem to have read what happened.","I am still trying to understand what made %s call it betrayal rather than a mistake."},
    },
    TRUST_GIVEN={
        given={"Telling %s I trust them made that trust feel more concrete.","I can still feel the weight of having put my trust in %s out loud.","There is a difference between trusting %s quietly and actually saying it.","I keep thinking about the fact that I chose to be that open with %s.","Saying I trust %s left me feeling a little more exposed than I expected.","I meant it when I told %s I trust them; that makes the words feel heavier now.","I keep noticing how much I have actually started relying on %s.","Putting that trust into words made me realize how far things with %s have moved."},
        warm={"It meant something hearing that kind of trust from %s.","I keep thinking about the fact that %s chose to trust me.","That trust from %s is sitting more heavily with me than an ordinary compliment would.","I liked hearing that %s feels safe putting trust in me.","There is something quietly reassuring about knowing %s trusts me.","I keep coming back to what %s said about trusting me.","Being trusted by %s feels more personal than I expected.","I did not realize how much I wanted to know that %s trusted me.","That little bit of trust from %s made the distance between us feel smaller.","I am still carrying the weight of %s putting that confidence in me."},
    },
    FORGIVENESS={
        given={"I can feel some of the anger around %s loosening after saying I forgive them.","Telling %s I forgive them made the whole thing feel a little less unfinished.","I meant the forgiveness I gave %s, even if the memory of what happened is still there.","Saying it to %s made me realize I am less interested in carrying the anger than I was before.","I can still feel some hurt around %s, but I do not want it doing all the talking anymore.","Forgiving %s did not erase anything; it just changed how tightly I am holding it.","I keep thinking about the fact that I finally let %s hear that I am not holding all of it against them.","There is a little more room in my head after telling %s I forgive them."},
        relief={"I can feel some of the pressure around %s easing now.","Knowing that %s is willing to let some of that go has taken the edge off.","Things with %s do not feel quite as tight after that forgiveness.","I did not realize how much I was waiting to hear that from %s.","There is still history there, but the air around %s feels less heavy now.","That forgiveness from %s has left me a little quieter inside.","I keep noticing how much less defensive I feel when I think about %s now.","Something that had been hanging between me and %s finally feels lighter.","I am still processing it, but being forgiven by %s changed the shape of the whole thing.","I can finally think about %s without immediately returning to the worst part of it."},
        guarded={"I heard the forgiveness from %s, even if the rest of it still needs time to settle.","Things with %s feel softer, but not suddenly simple.","Being forgiven by %s matters. It does not erase everything that happened.","I am relieved about %s, though part of me is still waiting to see what changes afterward.","That helped with %s, but I can still feel some of the old tension underneath it.","I do not feel as cornered by what happened with %s now, even if I am not fully at ease yet."},
    },
    GRATITUDE={
        given={"I am glad I actually thanked %s instead of leaving it unspoken.","Saying thanks to %s made me notice how much that moment had mattered to me.","I keep thinking about what %s did and feeling the gratitude again.","Telling %s I appreciated it felt more important than I expected.","I meant the thanks I gave %s; the feeling is still there.","I keep returning to the fact that %s did something I genuinely valued.","Putting the gratitude into words made the whole thing feel a little more real.","I am still quietly grateful to %s for that."},
        warm={"It was good hearing that what I did actually mattered to %s.","I keep thinking about the way %s thanked me.","That bit of gratitude from %s was nicer to hear than I expected.","I liked knowing %s noticed what I did.","There was something easy about the way %s thanked me.","I am still a little pleased that something I did helped %s.","Hearing that from %s made the effort feel more worthwhile.","That thank-you from %s felt genuine enough to stick with me.","I did not need thanks from %s, but I liked getting it anyway.","It is nice knowing %s did not take that moment for granted."},
    },
    REASSURANCE={warm={
        "%s made that sound easier to believe for a moment.","There was something steady in the way %s reassured me.","I did not realize how much I needed to hear that from %s.","The way %s said that took a little of the pressure out of me.","I keep coming back to how certain %s sounded when they said it.","That reassurance from %s settled more of me than I expected.","It was easier to breathe after hearing that from %s.","Part of me is still leaning on the steadiness in %s's words."
    }},
    VULNERABILITY={concerned={
        "%s sounded more exposed than they probably meant to.","I keep thinking about how openly %s admitted that fear.","There was something in %s's voice that made that hard to dismiss.","I did not expect %s to let me see that much uncertainty.","The way %s said that made me want to listen more carefully.","I keep coming back to the part where %s stopped pretending to be alright.","That admission from %s felt too honest to treat lightly.","Something about %s trusting me with that is still sitting close."
    }},
    INVITATION={warm={
        "I liked how naturally %s included me in that.","There was something easy about the way %s asked me along.","That invitation from %s sounded better than I expected.","I keep thinking about how simple %s made that offer feel.","It was nice hearing %s make room for me without turning it into a whole thing.","Part of me had already said yes before I finished thinking about it.","The way %s asked made the idea feel strangely comfortable.","I did not expect being invited by %s to please me that much."
    }},
    BOUNDARY={replay={
        "The line %s drew there was clearer than I expected.","I keep turning over exactly where %s wants the boundary to sit.","The way %s said that made this feel less casual than the words suggested.","I am glad %s said that plainly instead of leaving me to guess.","There was more weight in %s's boundary than I expected.","I keep thinking about what %s asked me to tell them and when.","At least I know what %s does and does not want from this now.","Something about %s naming that limit made the whole situation feel more real."
    }},
    PROMISE={
        given={"I can still feel the weight of what I promised %s.","Giving %s my word made the commitment feel much more real.","I keep thinking about what I told %s I would do.","That promise to %s is sitting in the back of my mind now.","I meant what I promised %s, and I can feel the responsibility of having said it aloud.","I keep measuring the next steps against the word I gave %s.","Promising that to %s made it harder to treat the matter casually.","I can already feel myself remembering the exact words I gave %s."},
        warm={"I keep thinking about the promise %s made.","Something about %s giving their word made the moment feel more serious.","I am still carrying what %s promised in the back of my mind.","That promise from %s has given me something definite to hold onto.","I keep remembering the way %s said it when they gave their word.","Part of me is already measuring what comes next against what %s promised.","It matters that %s chose to make that promise out loud.","I am not treating what %s promised as just another line in the conversation."},
        guarded={"I remember what %s promised. I am not ready to treat it as settled yet.","The promise from %s sounded sincere; I still want to see what follows it.","I keep the words %s gave me in mind, even if trust takes longer than words.","What %s promised matters. So will what they actually do next.","I heard %s give their word. Part of me is still cautious about leaning on it."},
    },
    HOSTILITY={
        expressed={"I can still feel the anger that made me speak to %s like that.","The hostility I threw at %s has not completely left me yet.","I keep replaying how sharply I spoke to %s.","Whatever I feel about %s, I was not hiding it in that moment.","I can still feel how little patience I had left for %s when I said that.","Saying it to %s did not make the anger disappear.","I keep thinking about how openly I let my dislike of %s show.","There was no softness left in the way I spoke to %s, and I can still feel why."},
        hurt={"I keep feeling the sting of how openly %s turned against me.","Hearing that kind of hostility from %s landed harder than I wanted it to.","Something about the way %s spoke to me is still sitting painfully close.","I did not expect things with %s to feel that hostile that quickly.","I keep thinking about how little warmth there was left in %s's voice.","It is hard not to feel hurt when %s makes their dislike that plain.","That moment with %s made the distance between us feel much more real.","Part of me is still caught on how directly %s wanted me pushed away."},
        angry={"I am still angry about the way %s spoke to me.","The hostility from %s keeps feeding my temper whenever I think about it.","I do not like how comfortable %s seemed making that contempt obvious.","Every time I replay it, I get irritated with %s all over again.","What %s said has left me with very little patience for them right now.","I can still feel my temper tighten when %s crosses my mind.","That kind of open hostility from %s is difficult to simply shrug off.","I am nowhere near neutral about %s after that."},
        replay={"I keep going back over exactly how %s said it.","There was something in %s's tone that keeps replaying in my head.","I keep wondering how long %s had been feeling that way before saying it.","The words from %s keep sounding a little different each time I remember them.","I am still trying to work out whether %s meant every part of what they said.","I keep thinking about what changed with %s before the hostility became that obvious."},
    },
    MOCKERY={
        embarrassed={"I can still feel the embarrassment from being laughed at like that.","That mocking reaction made me suddenly too aware of myself.","I keep replaying the part where the laughter stopped feeling harmless.","I did not expect being mocked to get under my skin that quickly.","That moment keeps coming back with the same uncomfortable little flush.","I keep remembering how exposed I felt while they were laughing at me.","The mockery itself was brief; the awkward feeling has lasted longer.","I am still too aware of how I must have looked in that moment."},
        irritated={"I am still annoyed by how unnecessary that mockery was.","Being laughed at like that is getting on my nerves more the longer I sit with it.","I keep thinking they could have made their point without turning me into the joke.","That mocking reaction was more irritating than clever.","I can feel my temper come back whenever I remember that laughter.","I am still bothered by how comfortable they were making fun of me."},
        uncertain={"I keep wondering what exactly they found so funny about me.","I cannot quite tell whether that was meant to sting or just came out badly.","That reaction has me wondering what I looked like from their side of the moment.","I keep trying to work out when the tone shifted from joking to mocking.","Something about that laughter still has me second-guessing what happened."},
    },
    SELF_PAIN={
        pain={"That pain keeps dragging my attention back to the same spot.","I can feel the ache every time I settle for more than a moment.","The discomfort is making it hard to forget about my body for very long.","That sore spot keeps announcing itself whenever I move the wrong way.","I keep noticing how carefully I am moving around the pain.","The ache has gone quiet, but it has not actually gone away.","Every so often the pain sharpens enough to interrupt whatever I was thinking about.","I can feel myself unconsciously protecting the part that hurts.","The discomfort keeps making small movements feel more deliberate than usual.","I am getting tired of being reminded about that pain every few minutes."},
    },
    OTHER_DISTRESS={concerned={
        "%s did not look alright just then.","I keep thinking about the look on %s's face.","Something about %s seemed off enough to stick with me.","I am still noticing how strained %s looked.","%s looked rougher than they were trying to let on.","I keep replaying that brief moment where %s looked hurt.","There was something in %s's expression I have not quite stopped thinking about.","%s seemed to be holding something together by force.","I cannot quite shake the impression that %s was having a harder time than they showed.","That little change in %s's face keeps coming back to me.","I noticed %s go quiet in a way that did not feel ordinary.","%s looked uncomfortable enough that my attention keeps returning there.","I keep thinking %s was more affected than they wanted anyone to notice.","Something about the way %s carried themselves just then felt wrong.","I am still wondering what put that look on %s's face.","%s did not seem like themselves for a moment there.","I keep remembering how tense %s looked.","There was a brief second where %s looked genuinely shaken.","%s looked like something was weighing on them.","That moment with %s left me more concerned than I expected."
    }},
    SOCIAL_WARMTH={warm={"That little bit of warmth from %s was nice.","I liked how easy that moment with %s felt.","%s's small gesture is still sitting pleasantly with me.","There was something comfortable in the way %s moved closer.","That moment with %s felt more natural than I expected.","I keep thinking about how little explanation that warmth from %s needed."}},
    AMBIGUOUS_TONE={uncertain={
        "I still cannot quite tell whether that was sincere.","Something about the way that was said felt a little off.","I keep wondering whether I took that remark too literally.","That sounded friendly on the surface, but I am not entirely convinced.","I cannot decide whether that was praise or a jab.","There was something in that tone I am still trying to place.","I keep replaying that remark, mostly because I cannot tell what was underneath it.","That line sounded simple enough, yet something about it did not feel simple.","I am not sure whether I was meant to take that at face value.","The words were clear. The intention behind them was less so.","I keep wondering if there was a second meaning tucked into that remark.","Something about that delivery makes me reluctant to take the words literally."
    }},
    DISAGREEMENT={replay={"I keep turning over the point they disagreed with.","That disagreement is still sitting in the back of my head.","I am still thinking about why they saw it differently.","Part of me is still chewing on that disagreement."}},
    AGREEMENT={warm={"It was nice not having to argue that point with %s.","I am still a little pleased that %s and I saw that the same way.","That agreement with %s made the conversation feel easier.","The way %s met me on that point took some friction out of the moment."}},
    SELF_SADNESS={loss={"I can still feel that sadness sitting close to the surface.","The feeling I let out has not actually gone anywhere yet.","I am quieter inside after admitting how much that hurt.","Saying it out loud did not make the sadness disappear."}},
    SELF_ANGER={angry={"I can still feel the anger sitting in my chest.","Saying I was angry did not exactly drain it away.","My temper is still too close to the surface.","I am not done being angry yet."}},
    SELF_FEAR={vigilant={"I can still feel that fear keeping me alert.","My body has not quite stopped expecting something to go wrong.","The fear is quieter now, but it has not left.","I keep noticing how ready I am to react to small things."}},
}

local COG_STATE_BANK={
    HURT={
        "I keep feeling that hurt again in little waves.","The sadness keeps turning up between unrelated thoughts.","Everything ordinary feels slightly heavier than it did before.","I keep noticing how easy it is for my mind to drift back to what hurt.","I am carrying more sadness than I have room for right now.","Even quiet moments keep finding the sore part of this.",
        "The hurt is quieter than before, but it is still close enough to notice.","I keep getting caught by small reminders I was not looking for.","There is a dull weight sitting behind everything else I try to think about.","I can feel myself circling the same sore feeling without meaning to.","Ordinary thoughts keep brushing against the thing that hurt.","I am not thinking about it every second, but it keeps finding its way back in.",
        "Some part of me is still waiting for the hurt to feel less immediate.","I keep noticing how much effort it takes to think around this.","The feeling has settled lower, not disappeared.","I can still feel the shape of what happened even when I am not replaying it directly.","Every so often the sadness rises again for no obvious reason.","I keep having brief moments where I forget, then remember all over again.",
        "There is still a tender edge to everything connected to that moment.","I am a little more fragile around this than I want to be.","The hurt keeps showing up in the spaces where my mind would normally be quiet.","I can feel myself avoiding certain thoughts because I know where they lead.","It is taking longer than I expected for this to stop feeling fresh.","I keep carrying the feeling forward even when the moment itself is over."
    },
    DISTRAUGHT={
        "I cannot keep my thoughts in one place for very long.","Everything keeps getting interrupted by the same heavy feeling.","I keep starting one thought and ending up back at the same problem.","My head feels crowded and strangely empty at the same time.","I am having a hard time giving ordinary things my full attention.","The same feeling keeps drowning out everything smaller.",
        "I keep losing the thread of what I was thinking about.","My attention keeps slipping away from whatever is in front of me.","I cannot seem to keep one clean line of thought going.","Everything in my head feels louder than it needs to be.","I keep trying to settle on something simple and drifting away from it.","My thoughts feel scattered in a way that is getting tiring.",
        "I keep catching myself staring without actually taking anything in.","There is too much going on in my head for the quiet around me to feel quiet.","I am thinking about several things at once and none of them properly.","My mind keeps jumping back before I can get very far forward.","I can feel the emotion interrupting thoughts that have nothing to do with it.","I am having trouble making ordinary details feel important right now.",
        "The smallest distraction keeps knocking my thoughts off course.","I keep forgetting what I meant to focus on a moment ago.","Everything feels slightly out of sequence in my head.","I can tell I am not fully present even when I try to be.","My attention keeps folding back toward the same heavy center.","I would normally be able to think around this. Right now I cannot."
    },
    SELF_CONSCIOUS={
        "I keep replaying how I must have looked in that moment.","I am too aware of myself after that.","That awkward feeling is taking longer to wear off than it deserves.","I keep remembering the moment from the outside, as if I was watching myself.","I can still feel that little flush of embarrassment when I think about it.","I keep wondering whether anybody else noticed how awkward that felt.",
        "I am suddenly noticing everything I do with my hands and face.","That moment has me second-guessing things I would normally not think twice about.","I keep hearing my own words back in my head with too much attention.","I can feel myself becoming quieter just because I do not want another awkward moment.","I am paying far too much attention to how I am coming across right now.","The embarrassment is small, but annoyingly persistent.",
        "I keep wishing I could see that moment from everybody else's side for a second.","I am more aware of being watched than I was a few minutes ago.","That reaction made me notice myself in a way I did not want to.","I keep checking whether I am making the same mistake again.","I am reading more into people's expressions than I usually would.","The awkwardness has faded a little, but I am still carrying it in how I move.",
        "I keep remembering the pause around that moment more than the words themselves.","I can feel myself hesitating before speaking now.","I am a little too conscious of the space I am taking up at the moment.","That social misstep is still making ordinary silence feel loaded.","I keep trying to act normal while noticing that I am trying to act normal.","I would like that moment to stop replaying every time things get quiet."
    },
    WITHDRAWN={
        "Being quiet feels easier than being seen right now.","I do not have much appetite for being around people at the moment.","I keep finding reasons to stay inside my own head.","The idea of keeping to myself sounds unusually comfortable.","I feel like I have less to give other people right now.","I keep letting silence last instead of filling it.",
        "I am not in the mood to explain myself to anybody.","The thought of another complicated conversation feels tiring.","I keep preferring a little distance without having a good way to explain why.","I do not feel hostile. I just do not feel open right now.","I can feel myself giving shorter answers in my head before anybody has even asked anything.","Being unnoticed sounds better than being understood at the moment.",
        "I am more comfortable observing than joining in right now.","I keep feeling the urge to stay on the edge of things.","There is not much in me reaching outward at the moment.","I would rather keep my thoughts to myself for a while.","I can feel myself putting a little extra distance between me and everyone else.","I am not looking for company as much as I normally might.",
        "Even familiar people sound like effort right now.","I keep wanting a little more room around me.","I am listening more than I am imagining myself speaking.","The quiet feels protective rather than empty at the moment.","I do not want another person pulling at this feeling before it settles.","I can tell I am retreating into myself a little."
    },
    RELIEVED={
        "I can finally feel some of the tension leaving me.","Everything feels a little less tight than it did before.","I did not realize how much pressure I was carrying until some of it lifted.","There is finally a little room in my head again.","I can feel myself settling now that the worst of that has eased.","My shoulders feel less tense than they did a few minutes ago.",
        "I keep noticing the absence of the pressure that was there before.","Breathing feels easier now that I am not bracing for the same thing.","I can finally think about something else without forcing it.","There is a quiet little sense of release sitting under everything now.","I am not completely fine, but I am much less wound up than I was.","Things feel more manageable now that the immediate tension has passed.",
        "I did not notice how much I was holding myself together until I could stop.","My thoughts are starting to spread out again instead of bunching around one problem.","The relief is subtle, but my whole body seems to have noticed it.","I keep taking fuller breaths without thinking about it.","There is more space between me and the thing that had me wound up.","I can feel the edge coming off my attention.",
        "The quiet feels normal again instead of loaded.","I am finally not waiting for the next unpleasant turn every second.","Something in me has unclenched a little.","I can feel my focus returning now that the pressure has eased.","It is nice not having every thought pulled back toward the same problem.","I am still processing it, but at least I am no longer braced against it."
    },
    TENSE={
        "I keep listening for trouble even when nothing is happening.","My attention is staying too close to every movement around me.","I can feel myself waiting for the next problem before it arrives.","It is taking effort not to read danger into every small change.","My body is still behaving like something might happen.","I keep checking the space around me without deciding to.",
        "Every sudden movement is getting a little more attention than it deserves.","I can feel how ready I am to react before I have decided there is anything to react to.","My shoulders are staying tighter than they need to be.","I keep noticing exits and distance without consciously looking for them.","I am listening to tone as much as words right now.","The room does not feel dangerous, but my body has not agreed yet.",
        "I keep measuring how close people are without meaning to.","My attention keeps snapping toward small noises.","I am having trouble letting my guard drop all the way.","Part of me is still expecting the situation to turn again.","I can feel the tension sitting in my jaw and hands.","I keep tracking movement at the edge of my vision.",
        "It is hard to fully relax while my attention is this sharp.","I am noticing details I would normally let pass.","My body still feels a little ahead of my thoughts, ready before I am.","I keep waiting for another sign that the trouble is not actually finished.","Nothing is happening right now, but I am still prepared for something to.","I can tell I am treating uncertainty like a warning at the moment."
    },
    CONCERNED={
        "Part of my attention keeps drifting back to whether they are alright.","I keep thinking about that strained look I noticed.","Something about their mood is still sitting in the back of my mind.","I am finding it harder than usual to ignore how unsettled they seemed.","My thoughts keep circling back to the sense that something was wrong.","I keep noticing small signs of distress even after the moment has passed.",
        "There is a quiet concern underneath everything else I am thinking about.","I keep wondering what was behind that change in their expression.","Part of me is still watching for another sign that they are struggling.","I cannot quite put that moment down as nothing.","Their discomfort is taking up more of my attention than I expected.","I keep thinking there was more going on than they said.",
        "I am more aware of their mood than I was before that moment.","Something in me keeps checking back on the same impression.","I keep replaying the second where they looked genuinely shaken.","It is difficult to fully settle while I still think something may be wrong with them.","Their expression keeps returning to me in little flashes.","I can feel a low, persistent worry that was not there before.",
        "I keep wondering whether they were trying to hide how badly that landed.","The concern is not overwhelming, but it is refusing to disappear.","I am still thinking about whether I read that distress correctly.","My attention keeps treating their mood as unfinished business.","I keep noticing how much that brief moment changed the way I am reading them.","There is still a small part of me waiting to see if they are actually alright.",
        "I keep catching myself thinking back to how drained they looked.","Their mood left a small knot of concern that has not loosened yet.","I am still noticing the contrast between what they said and how they looked.","Something about their silence felt heavier than ordinary quiet.","I keep thinking there was more strain there than they wanted to show.","That brief glimpse of distress has stayed clearer than the rest of the moment.",
        "I can feel my attention returning to them whenever things around me go quiet.","The way they held themselves made it difficult to dismiss the moment as nothing.","I keep wondering whether that was pain, worry, or something else entirely.","I am still a little unsettled by how quickly their expression changed.","Their discomfort keeps surfacing in my thoughts even when I am thinking about something else.","I keep remembering the way they seemed to fold inward for a second.",
        "There was enough strain in that moment to keep bothering me afterward.","I am still wondering what they were trying not to show.","Their mood has left me more watchful of them than I was before.","I keep thinking about the little signs that something was not sitting right with them.","That moment made their well-being feel harder to ignore.","I can tell I am waiting for some sign that the tension around them has eased.",
        "I keep thinking about how quickly they tried to cover that reaction.","There was a tiredness in them that I keep noticing in hindsight.","Their unease has become one of those small details my mind refuses to drop.","I am still carrying the impression that they needed more steadiness than they had in that moment.","Something about their reaction made the rest of the room feel less important for a while.","I keep wondering whether the next time I see them they will still have that same strain around them."
    },
    UNCOMFORTABLE={
        "I keep noticing the discomfort whenever I shift my weight.","My body keeps interrupting otherwise ordinary thoughts.","There is a persistent ache making it hard to forget how I am sitting or standing.","I keep adjusting without finding a position that feels quite right.","The discomfort is not overwhelming, just impossible to fully ignore.","I can feel myself moving more carefully than usual.",
        "Every so often the ache sharpens enough to pull my attention away.","I keep favoring one movement over another without thinking about it.","My body is making small decisions for me before I consciously notice them.","The soreness keeps turning up whenever I stop paying attention to it.","I can feel tension building around the part that hurts.","I keep testing whether the discomfort has eased and getting roughly the same answer.",
        "Even staying still has started to feel like its own kind of discomfort.","I am more aware of every little shift in posture than I want to be.","The ache keeps making ordinary movement feel slightly deliberate.","I can feel myself protecting the sore area without meaning to.","The discomfort is dull enough to live with and sharp enough to stay annoying.","I keep noticing how tired I am of noticing the pain.",
        "My body is taking up more of my attention than the room around me.","I keep making tiny adjustments and none of them last very long.","The soreness has settled into the background, but it is a loud background.","I can feel the discomfort changing the way I hold myself.","I am getting impatient with how often the ache interrupts me.","The pain is manageable. The constant reminder of it is the irritating part."
    },
}

function RPAL.GetPersonaCognitiveStateThoughtPool(label)
    return COG_STATE_BANK[tostring(label or ""):upper()]
end

local function eventByID(q,id) for _,e in ipairs(q.events) do if tonumber(e.id)==tonumber(id) then return e end end end
local function concernByID(q,id) for _,c in ipairs(q.concerns) do if tostring(c.id)==tostring(id) then return c end end end
local function topConcern(q)
    pruneConcerns(q);local best
    for _,r in ipairs(q.concerns) do if not best or decayConcern(r)>decayConcern(best) then best=r end end
    return best
end
local function latestDialogueConcern(q,seconds)
    local t=now()
    for _,ev in ipairs(q.events or{})do
        if t-(tonumber(ev.at)or 0)>(seconds or 100)then break end
        if DIALOGUE_MICRO[ev.type]and ev.person and not ev.mine and not ev.thoughtAt and ev.thread and ev.thread.coherent then
            local c=concernByID(q,ev.concernID);if c and not c.resolved then return c end
        end
    end
end
local function chooseIntent(ev,q)
    local e=q.emotions or {};local t=ev and ev.type or ""
    if t=="RELATIONSHIP_BREAK" then
        if (e.relief or 0)>.48 and math.random()<.30 then return "relief" end
        if (e.anger or 0)>(e.sadness or 0)*.82 and math.random()<.58 then return "anger" end
        local r=math.random();if r<.30 then return "missing" elseif r<.58 then return "replay" else return "loss" end
    elseif t=="RECONCILIATION" then return "relief"
    elseif t=="HUMOR_REJECTED" or t=="SOCIAL_DISAPPROVAL" then if (e.anger or 0)>.36 and math.random()<.42 then return "irritated" elseif (e.embarrassment or 0)>.35 then return "embarrassed" else return "uncertain" end
    elseif t=="HUMOR_LANDED" or t=="SOCIAL_WARMTH" or t=="AFFECTION" or t=="INTIMACY" or t=="AFTERCARE" or t=="SUPPORT" or t=="REASSURANCE" or t=="INVITATION" or t=="AGREEMENT" then return "warm"
    elseif t=="VULNERABILITY" then return "concerned"
    elseif t=="BOUNDARY" then return "replay"
    elseif t=="OTHER_DISTRESS" then return "concerned"
    elseif t=="INSULT" then if (e.anger or 0)>.42 then return "angry" elseif (e.sadness or 0)>.34 then return "hurt" else return "replay" end
    elseif t=="COMPLIMENT" then return trait("introvertedExtroverted")<.38 and "awkward" or "warm"
    elseif t=="APOLOGY" then return trait("forgivingVindictive")>.62 and "guarded" or "softening"
    elseif t=="REJECTION" then if (e.anger or 0)>.38 then return "angry" elseif (e.loneliness or 0)>.42 then return "withdrawn" else return "hurt" end
    elseif t=="TRUST_BROKEN" then if (e.anger or 0)>.46 then return "angry" elseif math.random()<.45 then return "replay" else return "hurt" end
    elseif t=="TRUST_GIVEN" or t=="GRATITUDE" then return "warm"
    elseif t=="FORGIVENESS" then return trait("forgivingVindictive")>.64 and "guarded" or "relief"
    elseif t=="PROMISE" then return trait("cautiousImpulsive")<.40 and "guarded" or "warm"
    elseif t=="HOSTILITY" then if (e.anger or 0)>.44 then return "angry" elseif math.random()<.38 then return "replay" else return "hurt" end
    elseif t=="MOCKERY" then if (e.anger or 0)>.40 then return "irritated" elseif (e.embarrassment or 0)>.38 then return "embarrassed" else return "uncertain" end
    elseif t=="AMBIGUOUS_TONE" then return "uncertain"
    elseif t=="SELF_PAIN" then return "pain"
    elseif t=="THREAT" or t=="AGGRESSION" or t=="SELF_FEAR" then return (e.anger or 0)>.48 and "angry" or "vigilant"
    elseif t=="DISAGREEMENT" then return "replay"
    elseif t=="SELF_ANGER" then return "angry"
    elseif t=="SELF_SADNESS" then return "loss" end
    return "warm"
end
local function recentRaw(p,line)
    local h=type(p and p.history)=="table" and p.history or {};for i=math.max(1,#h-110),#h do local r=h[i];if type(r)=="table" and (tostring(r.rawText or "")==line or tostring(r.text or "")==line) then return true end end
    return false
end
local function pickFresh(pool,p,name)
    if type(pool)~="table" or #pool==0 then return nil end;local fresh,valid={},{}
    for _,fmt in ipairs(pool)do
        local line=tostring(fmt);local needsName=line:find("%%s")~=nil
        if not needsName or trim(name)~=""then
            if needsName then line=string.format(line,name)end
            valid[#valid+1]=line;if not recentRaw(p,line)then fresh[#fresh+1]=line end
        end
    end
    local use=#fresh>0 and fresh or valid;if#use==0 then return nil end;return use[math.random(1,#use)]
end
local function whyFor(ev,concern,intent,state)
    local appraisal=ev and ev.appraisal or {};local tags=appraisal.tags or {}
    return {
        trigger=ev and eventSummary(ev) or "A recent concern remained emotionally active",
        evidence=ev and trim(ev.evidence or "") or "",
        appraisal=#tags>0 and table.concat(tags," / ") or "ongoing concern",
        state=state and state.label or "",
        concern=concern and (EVENT_DEF[concern.type] and EVENT_DEF[concern.type].label or concern.type) or "",
        salience=concern and math.floor(decayConcern(concern)+.5) or 0,
        confidence=ev and ev.confidence or 0,
        relation=ev and ev.person and tostring(ev.person.kind or "") or "",
        person=ev and ev.person and ev.person.name or nil,
        intent=intent,
        eventID=ev and ev.id or nil,
        sarcasm=ev and ev.tone and tonumber(ev.tone.score) or nil,
        slangHits=ev and ev.slang and tonumber(ev.slang.hits) or 0,
    }
end
local function generateConcernThought(q,p,concern)
    local ev=eventByID(q,concern.eventID);if not ev then return nil end
    if RPAL.PersonaCognitiveEventPassesEpisodeAudit then
        local ok,reason=RPAL.PersonaCognitiveEventPassesEpisodeAudit(ev)
        if not ok then concern.resolved=true;concern.baseSalience=0;concern.salience=0;return nil,nil,reason or"event-context-audit"end
    end
    local intent=chooseIntent(ev,q);local raw,mindIntent,mind
    if RPAL.GeneratePersonaCognitiveThought then raw,mindIntent,mind=RPAL.GeneratePersonaCognitiveThought(ev,concern,intent,q,p)end
    if mindIntent then intent=mindIntent end
    if not raw then
        local bank=BANK[ev.type];local pool=bank and (bank[intent] or bank.warm or bank.replay or bank.loss)
        if not pool then return nil end
        raw=pickFresh(pool,p,ev.person and ev.person.name)
    end
    if not raw then return nil end
    if RPAL.PersonaThoughtPassesNaturalness and not RPAL.PersonaThoughtPassesNaturalness(raw) then return nil end
    if RPAL.PersonaThoughtFitsWorldContext then
        local probe={rawText=raw,text=raw,source="cognitive-"..string.lower(ev.type),why={eventID=ev.id,salience=decayConcern(concern),evidence=ev.evidence,person=ev.person and ev.person.name}}
        local ok,reason=RPAL.PersonaThoughtFitsWorldContext(raw,probe);if not ok then return nil,nil,reason or"context-blocked"end
    end
    local styled=RPAL.StylePersonaThought and RPAL.StylePersonaThought(raw) or raw
    if RPAL.PersonaThoughtPassesNaturalness and not RPAL.PersonaThoughtPassesNaturalness(styled) then styled=raw end
    local state=RPAL.GetPersonaLivingState and RPAL.GetPersonaLivingState() or {}
    local why=whyFor(ev,concern,intent,state)
    if mind then
        why.persona=mind.profile;why.personaLens=mind.lens;why.personaVersion=mind.version
        why.temporalPhase=mind.temporalPhase or intent
        why.eventAge=tonumber(mind.eventAge)or math.max(0,now()-(tonumber(ev.at)or now()))
    else
        why.temporalPhase=intent;why.eventAge=math.max(0,now()-(tonumber(ev.at)or now()))
    end
    local row={text=styled,rawText=raw,category="cognitive",source="cognitive-"..string.lower(ev.type),at=now(),context="event",why=why,eventID=ev.id,concernID=concern.id,intent=intent,mind=mind}
    p.history=type(p.history)=="table" and p.history or {};p.history[#p.history+1]=row;while #p.history>120 do table.remove(p.history,1) end
    pushFront(q.whyHistory,{at=row.at,text=styled,why=why},MAX_WHY);q.lastWhy=why;q.lastThoughtAt=now();q.lastThoughtEventID=ev.id
    concern.thoughtCount=(tonumber(concern.thoughtCount)or 0)+1;concern.lastThoughtAt=now();ev.thoughtAt=now();ev.thoughtText=styled
    if DIALOGUE_MICRO[ev.type]then q.lastDialogueThoughtAt=now()end
    if ONE_SHOT_EVENT[ev.type]then
        concern.resolved=true;concern.baseSalience=math.min(3,decayConcern(concern));concern.salience=concern.baseSalience
    else
        concern.baseSalience=math.max(4,decayConcern(concern)*.72);concern.salience=concern.baseSalience;concern.at=now()
    end
    return styled,row
end
local function generateCognitiveStateThought(q,p)
    local state=RPAL.GetPersonaLivingState and RPAL.GetPersonaLivingState() or {};local raw,mind
    if RPAL.GeneratePersonaCognitiveStateThought then raw,mind=RPAL.GeneratePersonaCognitiveStateThought(state,p,"cognitive")end
    if not raw then local pool=COG_STATE_BANK[state.label];if not pool then return nil end;raw=pickFresh(pool,p)end
    if not raw then return nil end;local styled=RPAL.StylePersonaThought and RPAL.StylePersonaThought(raw) or raw
    local top=topConcern(q);local ev=top and eventByID(q,top.eventID);local why=whyFor(ev,top,"state",state)
    if not ev then why.trigger="The current emotional momentum has not fully settled yet";why.appraisal="emotional momentum" end
    if mind then why.persona=mind.profile;why.personaLens=mind.lens;why.personaVersion=mind.version end
    local row={text=styled,rawText=raw,category="state",source="cognitive-state",at=now(),context="state",why=why,intent="state",mind=mind}
    p.history=type(p.history)=="table" and p.history or {};p.history[#p.history+1]=row;while #p.history>120 do table.remove(p.history,1) end
    pushFront(q.whyHistory,{at=row.at,text=styled,why=why},MAX_WHY);q.lastWhy=why;q.lastThoughtAt=now();return styled,row
end

local function genericWhy(row)
    local state=RPAL.GetPersonaLivingState and RPAL.GetPersonaLivingState() or {};local src=tostring(row and row.source or "persona")
    local episode=type(row and row.mind)=="table"and row.mind.episode or nil
    if type(episode)=="table"and trim(episode.anchor)~=""then
        return{trigger="A concrete event resurfaced: "..trim(episode.anchor),evidence="",appraisal="episodic memory / "..tostring(episode.reaction or"remembered reaction"),state=tostring(state.label or""),concern="stored event memory",salience=math.floor(clamp(tonumber(episode.intensity)or tonumber(episode.weight)or .5,0,1)*100+.5),confidence=tonumber(episode.confidence)or 0,relation="",person=episode.person,intent="memory",eventID=episode.eventID}
    end
    local trigger="No recent event was strong enough to dominate the character's attention"
    local appraisal="background thought shaped by calibration and Persona learning"
    if src:find("state",1,true) then trigger="The current Living State had enough weight to surface";appraisal="current emotional / physical state"
    elseif src:find("trp",1,true) or src:find("social",1,true) then trigger="A familiar relationship remained salient in the character's social model";appraisal="relationship familiarity and recent interaction"
    elseif src:find("memory",1,true) then trigger="A stored Character Memory resurfaced";appraisal="long-term memory"
    elseif src:find("moral",1,true) then trigger="The character's learned Moral Core colored an otherwise quiet moment";appraisal="moral interpretation"
    elseif src:find("association",1,true) or src:find("topic",1,true) then trigger="A learned Like or Dislike surfaced during a quiet moment";appraisal="learned preference" end
    return {trigger=trigger,evidence="",appraisal=appraisal,state=tostring(state.label or ""),concern="none strong enough to suppress background thought",salience=0,confidence=1,relation="",intent="background"}
end

local function decisionLog(q,row)
    q.decisions=type(q.decisions)=="table" and q.decisions or {}
    table.insert(q.decisions,1,row);while #q.decisions>MAX_DECISIONS do table.remove(q.decisions) end
end
local function noteDecision(q,lane,ctx,top,sal,state,row)
    decisionLog(q,{at=now(),lane=lane,context=ctx,concernType=top and top.type or nil,person=top and top.person and top.person.name or nil,salience=math.floor((sal or 0)+.5),state=state and state.label or nil,source=row and row.source or nil,eventID=top and top.eventID or nil,attentionLocked=(tonumber(q.attentionLockUntil) or 0)>now()})
end

local baseGenerate=RPAL.GeneratePersonalityThought
if baseGenerate then
    RPAL.GeneratePersonalityThought=function(context)
        if not ready(false) then return nil end
        local q,p=ensure();if not q or not p then return baseGenerate(context) end;decayEmotions(q);pruneConcerns(q)
        local ctx=tostring(context or "ambient")
        local forcedConcern=nil
        local reactiveID=tonumber(ctx:match("^reactive:(%d+)$"))
        if reactiveID then local ev=eventByID(q,reactiveID);if ev then forcedConcern=concernByID(q,ev.concernID)end end
        if not forcedConcern and q.pending and q.pending.concernID then forcedConcern=concernByID(q,q.pending.concernID) end
        local top=forcedConcern or topConcern(q)
        local dialogueConcern=q.lastThread and q.lastThread.coherent and latestDialogueConcern(q,100)or nil
        if dialogueConcern and(not top or decayConcern(top)<55)then top=dialogueConcern end
        local sal=top and decayConcern(top) or 0
        local cs=cognitiveState()
        local locked=(tonumber(q.attentionLockUntil) or 0)>now()
        local dialoguePriority=top and dialogueConcern==top

        -- Cognitive engine is the source of truth whenever something meaningful
        -- is emotionally active. Strong events never compete with hobbies or a
        -- random acquaintance merely because the legacy generator rolled first.
        if top and (ctx:find("reactive",1,true) or locked or sal>=26 or dialoguePriority) then
            local line,row,blocked=generateConcernThought(q,p,top)
            if line then noteDecision(q,"active-concern",ctx,top,sal,cs,row);return line,row end
            if blocked then return nil,nil end
            if cs and COG_STATE_BANK[cs.label] then
                line,row=generateCognitiveStateThought(q,p);if line then noteDecision(q,"cognitive-state-fallback",ctx,top,sal,cs,row);return line,row end
            end
        elseif top and sal>=16 then
            local concernChance=clamp(.48+(sal-16)*.022,.48,.82)
            if math.random()<concernChance then local line,row,blocked=generateConcernThought(q,p,top);if line then noteDecision(q,"weighted-concern",ctx,top,sal,cs,row);return line,row elseif blocked then return nil,nil end end
        end

        -- A long, genuinely owned live exchange is itself valid immediate
        -- experience. Let the continuity layer express that before a generic
        -- CALM/RESTLESS state roll; otherwise context filtering can reject the state
        -- line and the character appears completely silent while talking.
        local liveConversation=RPAL.GetPersonaConversationContext and RPAL.GetPersonaConversationContext()or{}
        if liveConversation.active and liveConversation.coherent and(not top or sal<16)then
            local line,row=baseGenerate(context)
            if line and row then
                row.why=row.why or genericWhy(row);q.lastWhy=row.why;pushFront(q.whyHistory,{at=row.at or now(),text=line,why=row.why},MAX_WHY)
                noteDecision(q,"live-conversation",ctx,top,sal,cs,row);return line,row
            end
        end

        if cs and COG_STATE_BANK[cs.label] then
            local cognitiveChance=top and sal>=12 and .70 or ((cs.label=="RELIEVED") and .46 or .62)
            if math.random()<cognitiveChance then local line,row=generateCognitiveStateThought(q,p);if line then noteDecision(q,"cognitive-state",ctx,top,sal,cs,row);return line,row end end
        end

        -- Background flavor is permitted only after cognition found no current
        -- event/state that deserves the front of the mind.
        local line,row=baseGenerate(context)
        if line and row then
            row.why=row.why or genericWhy(row);q.lastWhy=row.why;pushFront(q.whyHistory,{at=row.at or now(),text=line,why=row.why},MAX_WHY)
            noteDecision(q,"background",ctx,top,sal,cs,row)
        end
        return line,row
    end
end

function RPAL.GetPersonaThoughtWhy(row)
    if row and type(row.why)=="table" then return row.why end
    local q=ensure();return q and q.lastWhy or nil
end
function RPAL.FormatPersonaThoughtWhy(row)
    local w=RPAL.GetPersonaThoughtWhy(row);if type(w)~="table" then return "No explanation was recorded for this thought." end
    local out={}
    if trim(w.trigger)~="" then out[#out+1]="Trigger: "..w.trigger end
    if trim(w.appraisal)~="" then out[#out+1]="Read as: "..w.appraisal end
    if trim(w.state)~="" then out[#out+1]="Living State: "..w.state end
    if trim(w.persona)~="" then out[#out+1]="Persona: "..w.persona end
    if trim(w.personaLens)~="" then out[#out+1]="Persona lens: "..w.personaLens end
    if tonumber(w.salience) and tonumber(w.salience)>0 then out[#out+1]="Concern weight: "..tostring(math.floor(tonumber(w.salience)+.5)).."/100" end
    if tonumber(w.confidence) and tonumber(w.confidence)<.999 then out[#out+1]="Event confidence: "..tostring(math.floor(tonumber(w.confidence)*100+.5)).."%" end
    if tonumber(w.sarcasm) and tonumber(w.sarcasm)>=.35 then out[#out+1]="Sarcasm read: "..tostring(math.floor(tonumber(w.sarcasm)*100+.5)).."%" end
    if tonumber(w.slangHits) and tonumber(w.slangHits)>0 then out[#out+1]="Slang cues: "..tostring(math.floor(tonumber(w.slangHits))).." signal(s)" end
    if trim(w.evidence)~="" then out[#out+1]="Evidence: “"..trim(w.evidence):sub(1,180).."”" end
    return table.concat(out,"\n")
end

-- --------------------------- Input / context ------------------------------
local function isOOC(text)
    if RPAL.GetPersonaInputClassification then local ok,res=pcall(RPAL.GetPersonaInputClassification,text);if ok and type(res)=="table" and res.ignored then return true end end
    if RPAL.IsPersonalityLearningOOCMessage then local ok,res=pcall(RPAL.IsPersonalityLearningOOCMessage,text);if ok and res then return true end end
    local s=trim(text);return s=="" or (s:sub(1,2)=="((" and s:sub(-2)=="))")
end
local function playerAuthor(author,guid)
    if guid and UnitGUID and guid==UnitGUID("player") then return true end
    local n=UnitName and UnitName("player") or "";return norm(author)==norm(n)
end

local function cruelHumorCue(text,eventName)
    local s=clean(text)
    local insult=contains(s,PAT.insult)
    local explicit=contains(s,{"at your expense","laughing at you","laughs at you","makes fun of","mocks ","ridicules ","cruel joke","mean joke","nasty joke"})
    local emote=(eventName=="CHAT_MSG_EMOTE"or eventName=="CHAT_MSG_TEXT_EMOTE")and contains(s,EMOTE.mockery)
    return insult or explicit or emote
end

local function appraiseMoralAction(q,text,eventName,mine,person,typ,row)
    if not RPAL.AddPersonaMoralEvidence then return end
    local t=now();local pk=speakerIdentityKey(person)
    if mine then
        local meanCue=cruelHumorCue(text,eventName)and true or false
        if row and person and(row.humor or meanCue)then
            q.pendingMoralJoke={at=t,person=person.name,personKey=pk,text=trim(text):sub(1,220),cruel=meanCue}
            if q.pendingMoralJoke.cruel then
                RPAL.AddPersonaMoralEvidence(-1,.92,text,row.humor and"cruel / personal joke"or"direct personal ridicule",{person=person.name,event=row.humor and"SELF_CRUEL_JOKE"or"SELF_RIDICULE"})
                q.pendingMoralJoke.moralApplied=true
            end
        elseif person and(eventName=="CHAT_MSG_EMOTE"or eventName=="CHAT_MSG_TEXT_EMOTE")and contains(clean(text),EMOTE.mockery)then
            RPAL.AddPersonaMoralEvidence(-1,.78,text,"mocking another person",{person=person.name,event="SELF_MOCKERY"})
        end
        if typ=="HOSTILITY"and person then RPAL.AddPersonaMoralEvidence(-1,.58,text,"direct hostility toward another person",{person=person.name,event=typ})
        elseif typ=="TRUST_BROKEN"and person then RPAL.AddPersonaMoralEvidence(-1,.52,text,"admitted breach of trust",{person=person.name,event=typ})
        elseif typ=="SUPPORT"and person then RPAL.AddPersonaMoralEvidence(1,.38,text,"offered meaningful support",{person=person.name,event=typ})end
    else
        local pending=type(q.pendingMoralJoke)=="table"and q.pendingMoralJoke or nil
        if pending and t-(tonumber(pending.at)or 0)<=50 and pk~=""and pending.personKey==pk then
            if typ=="HUMOR_REJECTED"or typ=="SOCIAL_DISAPPROVAL"then
                if not pending.moralApplied then
                    RPAL.AddPersonaMoralEvidence(-1,pending.cruel and .72 or .20,pending.text,"joke caused visible discomfort",{person=pending.person,event="JOKE_HARM_REACTION"})
                end
                pending.closed=true;pending.closedAt=t
            elseif typ=="HUMOR_LANDED"or typ=="SOCIAL_WARMTH"then
                pending.closed=true;pending.closedAt=t
            end
        end
    end
end
local function removeUnclassifiedContext(q,contextID)
    if not contextID then return end
    local kept={}
    for _,u in ipairs(q.unclassified or{})do if u.contextID~=contextID then kept[#kept+1]=u end end
    q.unclassified=kept
end

local function markPendingShortReply(q,row,text,eventName,normalized,slang,tone)
    if not row or row.ownershipReason~="ambiguous-short-reply-after-nearby" then return end
    local candidate=recentNearbyInterjection(q,10)
    if not candidate or not candidate.person then return end
    q.pendingShortReply={
        contextID=row.contextID,at=row.at,candidateKey=speakerIdentityKey(candidate.person),candidatePerson=candidate.person,
        text=trim(text),eventName=eventName,normalized=normalized,slang=slang,tone=tone,
    }
end

local function confirmPendingShortReply(q,person,outgoingReason)
    local pending=type(q.pendingShortReply)=="table"and q.pendingShortReply or nil
    if not pending then return false end
    if now()-(tonumber(pending.at)or 0)>8 then q.pendingShortReply=nil;return false end
    -- Only the immediately following player turn may confirm the ambiguous reply.
    local row=q.context[#q.context]
    if not row or row.contextID~=pending.contextID then q.pendingShortReply=nil;return false end
    -- Explicit name/address evidence only. Current target or conversation inertia is
    -- intentionally insufficient because it can be stale in crowded RP.
    if outgoingReason~="explicit-mentioned-addressee"or not person then q.pendingShortReply=nil;return false end
    if speakerIdentityKey(person)~=tostring(pending.candidateKey or"")then q.pendingShortReply=nil;return false end

    row.person=person;row.author="self";row.ownershipClass="self-to-other";row.ownershipConfidence=.94
    row.ownershipReason="retro-confirmed-by-explicit-followup";row.threadPersonKey=speakerIdentityKey(person);row.outgoingConfidence=.94
    noteOwnership(q,true,person,.94,row.ownershipReason)
    removeUnclassifiedContext(q,row.contextID)

    -- Re-run only the already-seen short reply now that its addressee is proven.
    -- This can recover a safe event such as GRATITUDE without ever guessing who
    -- the reply was for before evidence arrived.
    local prevOwnership,prevThread=q.currentOwnership,q.currentThread
    q.currentOwnership={class="self-to-other",confidence=.94,reason=row.ownershipReason,personKey=speakerIdentityKey(person)}
    q.currentThread=type(row.thread)=="table"and row.thread or nil
    local typ,confidence,reason=classifyIncoming(q,pending.normalized or pending.text,pending.eventName,true,person,pending.tone,pending.slang)
    if typ and not row.eventID then
        local ev=recordEvent(typ,person,confidence,pending.text,reason,pending.eventName,true)
        if ev then row.eventID=ev.id;ev.tone=pending.tone;ev.slang=pending.slang;ev.normalized=pending.normalized;ev.retroConfirmed=true end
    end
    q.currentOwnership,q.currentThread=prevOwnership,prevThread
    q.pendingShortReply=nil
    return true
end

local function processRoleplay(text,author,eventName,guid)
    if not ready(true) or isOOC(text) then return end
    local q,p=ensure();if not q then return end
    local originalText=trim(text);local mine=playerAuthor(author,guid);local person=mine and nil or resolvePerson(author)
    local snap=targetSnapshot(q)
    local authorKey=mine and "self" or tostring(author or "")
    local previous=q.context[#q.context];local mergedFragment=false
    if type(previous)=="table"and not previous.eventID and previous.authorKey==authorKey and previous.eventName==eventName and now()-(tonumber(previous.at)or 0)<=1 then
        local left=trim(previous.rawText or previous.text);local right=originalText
        local hasMarker=left:find("»%s*$")~=nil or right:find("^»")~=nil
        if hasMarker then
            mergedFragment=true;table.remove(q.context)
            if q.unclassified[1]and previous.contextID and q.unclassified[1].contextID==previous.contextID then table.remove(q.unclassified,1)end
            left=left:gsub("%s*»%s*$","");right=right:gsub("^»%s*","")
            text=trim(left.." "..right)
        end
    end
    local ownership,outgoingConfidence,outgoingReason
    if mine then
        person,outgoingConfidence,outgoingReason=resolveOutgoingPerson(q,text,eventName,snap)
        confirmPendingShortReply(q,person,outgoingReason)
        ownership={class=person and"self-to-other"or"self-general",confidence=outgoingConfidence or 0,reason=outgoingReason or"undirected-self-turn",personKey=speakerIdentityKey(person)}
    else
        ownership=incomingOwnership(q,text,eventName,person)
    end
    q.currentOwnership=ownership
    local normalized,slang=normalizeSlang(text)
    local thread=conversationRead(q,authorKey,mine,person,normalized,ownership);q.currentThread=thread
    local tone=sarcasmRead(q,text,normalized,eventName,mine,person,authorKey)
    if mine and RPAL.LearnPersonaVoiceFromICText then pcall(RPAL.LearnPersonaVoiceFromICText,originalText,eventName,true) end
    local typ,confidence,reason=classifyIncoming(q,normalized,eventName,mine,person,tone,slang)
    local semanticGuard=not typ and semanticGuardReason(text,slang)or nil
    local cleaned=clean(text)
    local contextID=mergedFragment and previous.contextID or q.nextContextID
    if not mergedFragment or not previous.contextID then q.nextContextID=q.nextContextID+1 end
    local targetMeta=nil
    if type(snap)=="table"and tostring(snap.key or"")~=""then targetMeta={name=snap.name,key=snap.key,fullName=snap.fullName,changedAt=snap.changedAt,heldFor=snap.heldFor,resolved=snap.isResolved and true or false}end
    local row={contextID=contextID,at=now(),mine=mine,author=mine and "self" or (person and person.name or nil),authorKey=authorKey,person=person,text=cleaned:sub(1,300),rawText=trim(text):sub(1,600),normalized=normalized:sub(1,300),slang=slang,tone=tone,thread=thread,eventName=eventName,fragmentMerged=mergedFragment and true or nil,humor=mine and humorAttempt(text,eventName) or false,amusementEmote=contains(cleaned,EMOTE.amusement)and true or false,negativeEmote=contains(cleaned,EMOTE.negative)and true or false,mockeryEmote=contains(cleaned,EMOTE.mockery)and true or false,target=targetMeta,ownershipClass=ownership and ownership.class or"nearby",ownershipConfidence=ownership and ownership.confidence or 0,ownershipReason=ownership and ownership.reason or"unknown",directedAtSelf=(not mine)and ownership and ownership.class=="directed"and(tonumber(ownership.confidence)or 0)>=.68 or false,threadPersonKey=speakerIdentityKey(person),outgoingConfidence=mine and(outgoingConfidence or 0)or nil}
    contextPush(q,row)
    if mine then markPendingShortReply(q,row,text,eventName,normalized,slang,tone)end
    noteOwnership(q,mine,person,mine and(outgoingConfidence or 0)or(ownership and ownership.confidence or 0),ownership and ownership.reason or outgoingReason)
    thread.at=now();thread.latestAt=thread.at;thread.active=thread.active and true or false
    q.lastThread=thread
    if mine and RPAL.CapturePersonaEpisodeReaction then pcall(RPAL.CapturePersonaEpisodeReaction,q,p,text,eventName,person,thread,typ)end
    appraiseMoralAction(q,text,eventName,mine,person,typ,row)
    if typ then local ev=recordEvent(typ,person,confidence,text,reason,eventName,mine);if ev then row.eventID=ev.id;ev.tone=tone;ev.slang=slang;ev.normalized=normalized;ev.fragmentMerged=mergedFragment and true or nil end;return ev end
    -- Keep a small explainable miss-buffer for Beta tuning. These are not
    -- learning evidence and never affect the character; they simply show which
    -- IC lines the event classifier saw but could not confidently classify.
    local d=directness(q,normalized,eventName,mine,person)
    local meaningful=(slang and (tonumber(slang.hits) or 0)>0) or (tone and (tonumber(tone.score) or 0)>=.35) or d>=.50 or eventName=="CHAT_MSG_EMOTE" or eventName=="CHAT_MSG_TEXT_EMOTE"
    if meaningful then
        table.insert(q.unclassified,1,{contextID=contextID,at=now(),mine=mine,person=person and person.name or nil,event=eventName,text=trim(text):sub(1,260),normalized=normalized:sub(1,260),directness=d,slangHits=slang and slang.hits or 0,sarcasm=tone and tone.score or 0,fragmentMerged=mergedFragment and true or nil,ownershipClass=ownership and ownership.class or"nearby",ownershipConfidence=ownership and ownership.confidence or 0,ownershipReason=ownership and ownership.reason or"unknown",semanticGuard=semanticGuard})
        while #q.unclassified>MAX_UNCLASSIFIED do table.remove(q.unclassified) end
    end
end
RPAL.ProcessPersonaCognitiveRoleplayMessage=processRoleplay

-- Schema 12 retires persisted remote voice/mannerism profiles independently of
-- whether Persona is opened during this login. Semantic events, relationship
-- state and the short conversation context are deliberately preserved.
if RPAL.RegisterDataMigration then
    RPAL.RegisterDataMigration(12,function(_,characterDB,scope)
        if type(scope)=="table"and not scope.needsCharacter then return end
        local seen={}
        local function visit(value,depth)
            if type(value)~="table"or seen[value]or depth>10 then return end
            seen[value]=true
            local personality=type(value.personality)=="table"and value.personality or nil
            local cognition=personality and personality.continuity and personality.continuity.cognition
            if type(cognition)=="table"then
                cognition.speakers={};cognition.remoteStyleProfilesRemovedAt=tonumber(cognition.remoteStyleProfilesRemovedAt)or now()
            end
            for _,child in pairs(value)do if type(child)=="table"then visit(child,depth+1)end end
        end
        visit(characterDB,0)
    end)
end

local frame=CreateFrame("Frame")
frame:RegisterEvent("PLAYER_LOGIN")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
local PERSONA_COGNITION_EVENTS={"CHAT_MSG_SAY","CHAT_MSG_YELL","CHAT_MSG_EMOTE","CHAT_MSG_TEXT_EMOTE","PLAYER_TARGET_CHANGED"}
function RPAL.SetPersonaCognitionEventsEnabled(enabled)
    for _,eventName in ipairs(PERSONA_COGNITION_EVENTS) do
        if enabled then frame:RegisterEvent(eventName) else frame:UnregisterEvent(eventName) end
    end
    RPAL._personaCognitionEventsEnabled=enabled and true or false
end
RPAL.SetPersonaCognitionEventsEnabled(RPAL.IsPersonaSystemEnabled and RPAL.IsPersonaSystemEnabled() or false)
frame:SetScript("OnEvent",function(_,ev,...)
    if ev=="PLAYER_LOGIN" or ev=="PLAYER_ENTERING_WORLD" then RPAL._personaEpisodeReactionLink=nil;local q=ensure();if q then q.pending=nil;q.pendingShortReply=nil;liveTargetRuntimeKey="";liveTargetRuntimeChangedAt=0;targetSnapshot(q);decayEmotions(q);pruneConcerns(q) end;refreshUI();return end
    if ev=="PLAYER_TARGET_CHANGED"then local q=ensure();if q then targetSnapshot(q)end;return end
    local msg,author=...;local guid=select(12,...)
    if RPAL.QueuePersonaWork then
        RPAL.QueuePersonaWork("cognition",function()processRoleplay(msg,author,ev,guid)end)
    else
        processRoleplay(msg,author,ev,guid)
    end
end)

local nextEpisodeFinalizeAt=0
local function PersonaCognitionTick()
    if not ready(true) then return end
    local q,p=ensure();if not q then return end
    local runtimeNow=gtime()
    if runtimeNow>=nextEpisodeFinalizeAt then
        nextEpisodeFinalizeAt=runtimeNow+5
        if RPAL.FinalizePersonaEpisodes then pcall(RPAL.FinalizePersonaEpisodes,q,p,false)end
    end
    if not q.pending then return end
    if runtimeNow<(tonumber(q.pending.at) or 0) then return end
    local since=now()-(tonumber(q.lastThoughtAt) or 0)
    local urgent=(tonumber(q.pending.salience) or 0)>=82
    if since<18 and not urgent then q.pending.at=gtime()+(18-since)+2;return end
    local pending=q.pending;q.pending=nil
    local thought,row=RPAL.GeneratePersonalityThought and RPAL.GeneratePersonalityThought("reactive:"..tostring(pending.eventID))
    if thought and RPAL.ShowSceneSpark then RPAL.ShowSceneSpark(thought,row) end
end
if C_Timer and C_Timer.NewTicker then
    C_Timer.NewTicker(1.25,PersonaCognitionTick)
else
    local tick=0
    frame:SetScript("OnUpdate",function(_,elapsed)
        tick=tick+(elapsed or 0);if tick<1.25 then return end;tick=0
        PersonaCognitionTick()
    end)
end

local baseDynamics=RPAL.GetPersonaRelationshipDynamics
if baseDynamics then
    RPAL.GetPersonaRelationshipDynamics=function(limit,onlyPresent)
        local rows=baseDynamics(limit,onlyPresent) or {};local q,p=ensure();if not q then return rows end
        for _,r in ipairs(rows) do
            local ov=q.relationshipOverrides[norm(r.name or "")]
            if ov and ov.status=="ruptured" then
                r.warmth=math.min(tonumber(r.warmth) or 0,.24);r.tension=math.max(tonumber(r.tension) or 0,.68);r.cognitiveStatus="ruptured"
            end
            if p and p.continuity and p.continuity.dynamics then
                local key=tostring(r.name or ""):lower():gsub("[^%w%s']",""):gsub("%s+"," ");local d=p.continuity.dynamics[key]
                if type(d)=="table" then r.trust=d.trust;r.comfort=d.comfort;r.respect=d.respect;r.resentment=d.resentment;r.fear=d.fear;r.attraction=clamp((tonumber(d.attraction)or 0)+(tonumber(d.priorAttraction)or 0),0,1);r.observedAttraction=tonumber(d.attraction)or 0;r.priorAttraction=tonumber(d.priorAttraction)or 0 end
            end
        end
        return rows
    end
end

-- If a lover relationship has just broken in RP, do not let the older TRP
-- Lover seed keep feeding generic romantic flavor until the rupture is repaired
-- or the TRP relation itself changes. This is an internal Persona override only.
local baseRelationshipCandidates=RPAL.GetPersonaTRPRelationshipCandidates
if baseRelationshipCandidates then
    RPAL.GetPersonaTRPRelationshipCandidates=function(limit)
        local rows=baseRelationshipCandidates(limit or 50) or {};local q=ensure();if not q then return rows end
        local out={}
        for _,r in ipairs(rows) do
            local ov=q.relationshipOverrides[norm(r.name or "")]
            if not (ov and ov.status=="ruptured" and r.kind=="love") then out[#out+1]=r end
        end
        return out
    end
end
