local ADDON_NAME, RPAL = ...

-- ============================================================
-- ODYN Persona Beta — Evidence-led Episodic Memory v2
--
-- Persona does not begin from a pretty sentence. It begins from evidence:
--   observed event -> observed player response -> cautious interpretation
--   -> residual emotional trace -> present-day recall cue
--
-- Observations and interpretations are stored separately so a plausible read
-- can never silently become character canon. Low-evidence events are allowed to
-- disappear without producing a memory or thought.
-- ============================================================

local VERSION = 5
local REACTION_WINDOW = 240
local REACTION_LINK_WINDOW = 90 -- immediate player response; older same-person events must not steal a new reply
local FINALIZE_DELAY = REACTION_WINDOW
local MAX_EPISODE_AGE = 45*86400
local MAX_REJECTED = 30
local MAX_CLARIFICATIONS = 6

local function trim(value)
    return tostring(value or ""):gsub("^%s+",""):gsub("%s+$","")
end

local function clamp(value,low,high)
    value=tonumber(value)or low
    if value<low then return low elseif value>high then return high end
    return value
end

local function now()
    return time and time()or 0
end

local function clean(value)
    local text=tostring(value or "")
    if RPAL.StripPersonaLanguageMarkers then text=RPAL.StripPersonaLanguageMarkers(text)end
    text=text:gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
    text=text:gsub("|H.-|h(.-)|h","%1"):gsub("|T.-|t",""):gsub("|A.-|a","")
    text=text:gsub("’","'"):gsub("‘","'"):gsub("“",'"'):gsub("”",'"')
    text=text:gsub("—"," - "):gsub("–"," - "):gsub("[\r\n]+"," "):gsub("%s+"," ")
    return trim(text):lower()
end

local function contains(text,patterns)
    for _,pattern in ipairs(patterns or{})do
        if text:find(pattern,1,true)then return pattern end
    end
end

local function shortName(value)
    local text=trim(value):gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
    return text:match("^([^%s%-]+)")or text:match("^[^-]+")or text
end

local function norm(value)
    return shortName(value):lower():gsub("[^%w']","")
end

local function upperFirst(value)
    value=trim(value)
    return value:gsub("^%l",string.upper)
end

local function copyList(rows)
    local out={}
    for _,value in ipairs(type(rows)=="table"and rows or{})do out[#out+1]=value end
    return out
end

local function listHas(rows,value)
    value=tostring(value or""):lower()
    for _,row in ipairs(type(rows)=="table"and rows or{})do if tostring(row or""):lower()==value then return true end end
    return false
end

local POLITE_FEAR={
    "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 i must leave","i'm afraid i must leave","i am afraid i must continue","i'm afraid i must continue",
    "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 TRUE_FEAR={
    "i am scared","i'm scared","i am terrified","i'm terrified","i am frightened","i'm frightened",
    "afraid of ","afraid that ","afraid for ","afraid to ","fear that ","fear for ","terrifies me","frightens me",
    "do not want to lose","don't want to lose","cannot do this alone","can't do this alone",
}

local DIRECT_OWNERSHIP_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,DISAGREEMENT=true,HUMOR_LANDED=true,HUMOR_REJECTED=true,SOCIAL_WARMTH=true,
    SOCIAL_DISAPPROVAL=true,RELATIONSHIP_BREAK=true,RECONCILIATION=true,REJECTION=true,INSULT=true,MOCKERY=true,
    HOSTILITY=true,THREAT=true,AGGRESSION=true,TRUST_ACCUSATION=true,AMBIGUOUS_TONE=true,
}

local OVERT_MOCKERY={
    "yeah right","yeah, right","what a joke","points and laughs","laughs at","snickers at","mocks ","mimics ","jeers at",
    "as if you ","as if he ","as if she ","as if they ","as if i would","as if that would","/sarcasm","(sarcasm)","[sarcasm]",
}

local METAPHORICAL_AS_IF={
    "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",
}

function RPAL.PersonaCognitiveEventPassesEpisodeAudit(event)
    if type(event)~="table"then return false,"invalid-event"end
    if not event.mine and DIRECT_OWNERSHIP_EVENT[event.type]then
        -- Persona 0.4/early 0.5 personal events did not persist enough
        -- conversation-ownership evidence to prove that a nearby line was
        -- actually addressed to the character.  Do not grandfather those
        -- guesses into durable canon: unverifiable generated memory is safer
        -- to forget than to keep asserting as fact.
        if event.ownershipClass==nil then return false,"legacy-unverified-conversation-ownership"end
        if event.ownershipClass~="directed"or(tonumber(event.ownershipConfidence)or 0)<.68 then return false,"event-not-directed-at-character"end
    end
    if RPAL.PersonaEventEvidenceStillSupportsType then
        local ok,reason=RPAL.PersonaEventEvidenceStillSupportsType(event)
        if ok==false then return false,reason or"event-evidence-no-longer-supports-type"end
    end
    local evidence=clean(tostring(event.normalized or"").." "..tostring(event.evidence or""))
    if event.type=="VULNERABILITY"and contains(evidence,POLITE_FEAR)and not contains(evidence,TRUE_FEAR)then
        return false,"polite-afraid-formula"
    end
    if event.type=="VULNERABILITY"and contains(evidence,{"i'm afraid","i am afraid"})and not contains(evidence,TRUE_FEAR)then
        return false,"bare-afraid-without-fear-object"
    end
    if event.type=="SUPPORT"and contains(evidence,{"good luck","best of luck","wish you luck","wishing you luck"})then
        return false,"well-wish-not-support"
    end
    if event.type=="MOCKERY"and contains(evidence,METAPHORICAL_AS_IF)and not contains(evidence,OVERT_MOCKERY)then
        return false,"metaphorical-as-if"
    end
    return true
end

local function rowEventID(row)
    if type(row)~="table"then return nil end
    if row.eventID~=nil then return row.eventID end
    if type(row.why)=="table"and row.why.eventID~=nil then return row.why.eventID end
    if type(row.mind)=="table"and type(row.mind.episode)=="table"and row.mind.episode.eventID~=nil then return row.mind.episode.eventID end
    if type(row.meta)=="table"and row.meta.eventID~=nil then return row.meta.eventID end
end
local function filterRows(rows,rejected)
    local out={}
    for _,row in ipairs(type(rows)=="table"and rows or{})do
        local raw=rowEventID(row)
        local key=tonumber(raw)or tostring(raw or"")
        if not rejected[key]then out[#out+1]=row end
    end
    return out
end

function RPAL.AuditPersonaCognitiveStore(q,p)
    if type(q)~="table"then return 0 end
    if(tonumber(q.episodeAuditVersion)or 0)>=VERSION then return 0 end
    local rejected={};local rejectedEvents={};local kept={};local removed=0
    q.rejectedEvents=type(q.rejectedEvents)=="table"and q.rejectedEvents or{}
    for _,event in ipairs(type(q.events)=="table"and q.events or{})do
        local ok,reason=RPAL.PersonaCognitiveEventPassesEpisodeAudit(event)
        if ok then
            kept[#kept+1]=event
        else
            local key=tonumber(event.id)or tostring(event.id or"")
            rejected[key]=true;rejectedEvents[#rejectedEvents+1]=event;removed=removed+1
            table.insert(q.rejectedEvents,1,{at=event.at,type=event.type,person=event.person and event.person.name,evidence=event.evidence,reason=reason})
        end
    end
    while#q.rejectedEvents>MAX_REJECTED do table.remove(q.rejectedEvents)end
    q.events=kept
    q.concerns=filterRows(q.concerns,rejected)
    q.whyHistory=filterRows(q.whyHistory,rejected)
    q.decisions=filterRows(q.decisions,rejected)
    local unresolved={}
    for key,row in pairs(type(q.unresolved)=="table"and q.unresolved or{})do
        local raw=type(row)=="table"and row.eventID or nil
        local id=tonumber(raw)or tostring(raw or"")
        if not rejected[id]then unresolved[key]=row end
    end
    q.unresolved=unresolved
    if type(p)=="table"then
        p.history=filterRows(p.history,rejected)
        local memories=p.continuity and p.continuity.memories
        if type(memories)=="table"then
            local cleanMemories,removedText={} ,{}
            for _,memory in ipairs(memories)do
                local meta=type(memory.meta)=="table"and memory.meta or{}
                local key=tonumber(meta.eventID)or tostring(meta.eventID or"")
                if not rejected[key]then cleanMemories[#cleanMemories+1]=memory
                else removedText[clean(memory.text)]=true end
            end
            p.continuity.memories=cleanMemories
            -- Session Reflection is a historical view, not a second memory
            -- authority.  If its "memories formed" list referenced a rejected
            -- generated Episode, remove that label as well.
            for _,reflection in ipairs(type(p.continuity.reflections)=="table"and p.continuity.reflections or{})do
                local rows={}
                for _,text in ipairs(type(reflection.memoriesFormed)=="table"and reflection.memoriesFormed or{})do
                    if not removedText[clean(text)]then rows[#rows+1]=text end
                end
                reflection.memoriesFormed=rows
            end
        end
    end
    local pending=q.pending and(tonumber(q.pending.eventID)or tostring(q.pending.eventID or""))
    if pending and rejected[pending]then q.pending=nil end
    local lastWhy=q.lastWhy and(tonumber(q.lastWhy.eventID)or tostring(q.lastWhy.eventID or""))
    if lastWhy and rejected[lastWhy]then q.lastWhy=nil end
    local lastThought=tonumber(q.lastThoughtEventID)or tostring(q.lastThoughtEventID or"")
    if rejected[lastThought]then q.lastThoughtEventID=nil end
    local locked=tonumber(q.attentionLockEventID)or tostring(q.attentionLockEventID or"")
    if rejected[locked]then q.attentionLockEventID=nil;q.attentionLockUntil=0 end
    if removed>0 and RPAL.ResetPersonaCognitiveEffectsAfterAudit then pcall(RPAL.ResetPersonaCognitiveEffectsAfterAudit,q,p,rejectedEvents)end
    q.episodeAuditVersion=VERSION;q.episodeAuditAt=now();q.episodeAuditRemoved=removed
    return removed
end

local DURABLE={
    AFFECTION=true,INTIMACY=true,AFTERCARE=true,RELATIONSHIP_BREAK=true,RECONCILIATION=true,REJECTION=true,
    TRUST_BROKEN=true,TRUST_ACCUSATION=true,TRUST_GIVEN=true,FORGIVENESS=true,PROMISE=true,BOUNDARY=true,
    HOSTILITY=true,THREAT=true,AGGRESSION=true,SUPPORT=true,APOLOGY=true,VULNERABILITY=true,OTHER_DISTRESS=true,
}

local CLOSE_MICRO={GRATITUDE=true,COMPLIMENT=true,SOCIAL_WARMTH=true,HUMOR_LANDED=true}

local UNRESOLVED_TYPES={
    RELATIONSHIP_BREAK=true,REJECTION=true,TRUST_BROKEN=true,TRUST_ACCUSATION=true,BOUNDARY=true,
    HOSTILITY=true,THREAT=true,AGGRESSION=true,INSULT=true,MOCKERY=true,SOCIAL_DISAPPROVAL=true,HUMOR_REJECTED=true,
}

local TURNING_TYPES={
    AFFECTION=true,INTIMACY=true,RELATIONSHIP_BREAK=true,RECONCILIATION=true,REJECTION=true,TRUST_BROKEN=true,
    TRUST_GIVEN=true,FORGIVENESS=true,PROMISE=true,BOUNDARY=true,AGGRESSION=true,VULNERABILITY=true,
}

local EVENT_THEMES={
    AFFECTION={"affection","feelings","closeness","relationship"},INTIMACY={"intimacy","closeness","trust"},AFTERCARE={"care","closeness","safety"},
    RELATIONSHIP_BREAK={"relationship","loss","distance"},RECONCILIATION={"relationship","repair","forgiveness"},REJECTION={"rejection","distance","closeness"},
    TRUST_BROKEN={"relationship","trust","betrayal"},TRUST_ACCUSATION={"relationship","trust","accusation"},TRUST_GIVEN={"relationship","trust","safety"},FORGIVENESS={"relationship","forgiveness","repair"},PROMISE={"plans","relationship","promise","trust","duty"},
    BOUNDARY={"relationship","boundary","distance"},HOSTILITY={"conflict","hostility"},THREAT={"threat","danger"},AGGRESSION={"danger","violence"},SUPPORT={"health","relationship","support","care"},
    APOLOGY={"relationship","apology","repair"},VULNERABILITY={"relationship","identity","vulnerability","fear","trust"},OTHER_DISTRESS={"health","distress","care"},GRATITUDE={"gratitude","care"},COMPLIMENT={"praise","attention"},
    HUMOR_LANDED={"humor","connection"},HUMOR_REJECTED={"humor","rejection"},SOCIAL_WARMTH={"warmth","connection"},SOCIAL_DISAPPROVAL={"disapproval","social"},
    INSULT={"insult","conflict"},MOCKERY={"mockery","conflict"},REASSURANCE={"relationship","health","reassurance","safety"},INVITATION={"plans","invitation","connection"},AGREEMENT={"agreement"},DISAGREEMENT={"disagreement"},
}

local function affectionFact(text,name,mine)
    if contains(text,{"i am in love with you","i'm in love with you","i love you","i've fallen for you","i have fallen for you"})then
        return mine and("I told "..name.." I loved them")or(name.." told me they loved me"),"love-confession"
    end
    if contains(text,{"i have feelings for you","i've got feelings for you","i care deeply about you","i adore you"})then
        return mine and("I admitted my feelings to "..name)or(name.." admitted their feelings for me"),"feelings-confession"
    end
    if contains(text,{"i missed you","i miss you"})then
        return mine and("I told "..name.." I missed them")or(name.." said they missed me"),"missing"
    end
    if contains(text,{"you mean a lot to me","you matter to me"})then
        return mine and("I told "..name.." how much they mattered to me")or(name.." told me I mattered to them"),"importance-confession"
    end
    return mine and("I showed "..name.." how I felt")or(name.." showed me affection"),"affection"
end

local function factFor(event)
    local person=event.person and shortName(event.person.name)or""
    if person==""then return nil end
    local text=clean(tostring(event.normalized or"").." "..tostring(event.evidence or""));local mine=event.mine and true or false;local kind=event.type
    if kind=="AFFECTION"then return affectionFact(text,person,mine)end
    local incoming={
        GRATITUDE={person.." thanked me","gratitude"},COMPLIMENT={person.." complimented me","compliment"},
        APOLOGY={person.." apologized to me","apology"},INTIMACY={person.." and I shared an intimate moment","intimacy"},
        AFTERCARE={person.." stayed gentle with me afterward","aftercare"},SUPPORT={person.." offered to stand by me","support"},
        REASSURANCE={person.." tried to reassure me","reassurance"},VULNERABILITY={person.." admitted they were afraid or uncertain","vulnerability"},
        TRUST_GIVEN={person.." said they trusted me","trust-given"},FORGIVENESS={person.." forgave me","forgiveness"},
        PROMISE={person.." gave me their word","promise"},INVITATION={person.." asked me to come with them","invitation"},
        BOUNDARY={person.." made their boundary clear to me","boundary"},AGREEMENT={person.." agreed with me","agreement"},
        DISAGREEMENT={person.." challenged what I said","disagreement"},HUMOR_LANDED={person.." laughed at my joke","humor-landed"},
        HUMOR_REJECTED={person.." reacted coldly to my joke","humor-rejected"},SOCIAL_WARMTH={person.." showed me a small warmth","social-warmth"},
        SOCIAL_DISAPPROVAL={person.." reacted coldly to me","social-disapproval"},OTHER_DISTRESS={person.." let me see that they were hurting","distress"},
        RELATIONSHIP_BREAK={person.." ended what was between us","relationship-break"},RECONCILIATION={person.." asked to repair what had broken between us","reconciliation"},
        REJECTION={person.." pushed me away","rejection"},INSULT={person.." insulted me directly","insult"},
        MOCKERY={person.." made me the target of their mockery","mockery"},HOSTILITY={person.." made their hostility toward me plain","hostility"},
        THREAT={person.." threatened me directly","threat"},AGGRESSION={person.." turned physically aggressive toward me","aggression"},
        TRUST_BROKEN={person.." broke my trust","trust-broken"},TRUST_ACCUSATION={person.." said I had broken their trust","trust-accusation"},
        AMBIGUOUS_TONE={person.." said something I could not read cleanly","ambiguous-tone"},
    }
    local outgoing={
        GRATITUDE={"I thanked "..person,"gratitude"},COMPLIMENT={"I complimented "..person,"compliment"},
        APOLOGY={"I apologized to "..person,"apology"},INTIMACY={person.." and I shared an intimate moment","intimacy"},
        AFTERCARE={"I stayed gentle with "..person.." afterward","aftercare"},SUPPORT={"I chose to stand by "..person,"support"},
        REASSURANCE={"I tried to reassure "..person,"reassurance"},VULNERABILITY={"I let "..person.." see my fear or uncertainty","vulnerability"},
        TRUST_GIVEN={"I told "..person.." I trusted them","trust-given"},FORGIVENESS={"I forgave "..person,"forgiveness"},
        PROMISE={"I gave "..person.." my word","promise"},INVITATION={"I asked "..person.." to come with me","invitation"},
        BOUNDARY={"I made my boundary clear to "..person,"boundary"},HOSTILITY={"I made my hostility toward "..person.." plain","hostility"},
        TRUST_BROKEN={"I admitted that I had damaged "..person.."'s trust","trust-broken"},
        REJECTION={"I rejected "..person.."'s advance","rejection"},
    }
    local row=(mine and outgoing or incoming)[kind]
    if row then return row[1],row[2]end
end

local function worldCue()
    if type(RPAL.GetPersonaWorldContext)~="function"then return nil end
    local ok,w=pcall(RPAL.GetPersonaWorldContext)
    if not ok or type(w)~="table"then return nil end
    return{signature=w.signature,mapID=w.mapID,zone=trim(w.zone),subZone=trim(w.subZone),insideHouse=w.insideHouse and true or false,isCity=w.isCity and true or false}
end

local function stateCue()
    if type(RPAL.GetPersonaLivingState)~="function"then return nil end
    local ok,s=pcall(RPAL.GetPersonaLivingState)
    if not ok or type(s)~="table"or trim(s.label)==""then return nil end
    return{label=tostring(s.label),score=tonumber(s.score)or 0}
end

local function baseStatusFor(event)
    if UNRESOLVED_TYPES[event.type]then return"unresolved"end
    return"settled"
end

local function attachEpisode(event,q)
    if type(event)~="table"then return nil end
    if type(event.episode)=="table"and(tonumber(event.episode.version)or 0)>=VERSION then return event.episode end
    local legacyEpisode=type(event.episode)=="table"and event.episode or nil
    local ok,reason=RPAL.PersonaCognitiveEventPassesEpisodeAudit(event)
    if not ok then event.episodeSuppressed=reason;return nil end
    local anchor,factKind=factFor(event);if not anchor then return nil end
    local appraisal=type(event.appraisal)=="table"and event.appraisal or{}
    local intensity=clamp(appraisal.intensity or .25,.05,1);local confidence=clamp(event.confidence or .5,0,1)
    local relation=event.person and tostring(event.person.kind or"neutral")or"neutral"
    local close=(relation=="love"or relation=="family"or relation=="friend")
    local durable=DURABLE[event.type]or(intensity>=.50)or(CLOSE_MICRO[event.type]and close and intensity>=.28)
    local reactionLink=RPAL._personaEpisodeReactionLink
    local reactionAge=type(reactionLink)=="table"and now()-(tonumber(reactionLink.at)or 0)or 999
    local linkedReply=event.mine and type(reactionLink)=="table"and reactionAge>=0 and reactionAge<=4
        and norm(reactionLink.person)==norm(event.person and event.person.name or"")and reactionLink or nil
    local reciprocal
    if not event.mine and event.type=="AFFECTION"and event.thread and event.thread.coherent and type(q)=="table"then
        for _,old in ipairs(type(q.events)=="table"and q.events or{})do
            local age=now()-(tonumber(old.at)or 0);if age>REACTION_WINDOW then break end
            if old.mine and old.type=="AFFECTION"and norm(old.person and old.person.name or"")==norm(event.person and event.person.name or"")then
                reciprocal=old
                if type(old.episode)=="table"then old.episode.durable=false;old.episode.foldedIntoEventID=event.id end
                break
            end
        end
    end
    if linkedReply then durable=false;RPAL._personaEpisodeReactionLink=nil end
    local personName=event.person and shortName(event.person.name)or nil
    local scene=RPAL.GetActiveScene and RPAL.GetActiveScene()or nil
    local episode={
        version=VERSION,eventID=event.id,eventType=event.type,at=event.at or now(),side=event.mine and"mine"or"incoming",
        sceneID=event.sceneID or(scene and scene.id)or(legacyEpisode and legacyEpisode.sceneID),
        person=personName,relation=relation,anchor=upperFirst(anchor),factKind=factKind,confidence=confidence,intensity=intensity,
        weight=clamp(intensity*.72+confidence*.28,.18,1),durable=durable and true or false,status=baseStatusFor(event),
        evidence=trim(event.evidence or event.normalized or""),source=event.source,
        ownershipClass=event.ownershipClass,ownershipConfidence=event.ownershipConfidence,ownershipReason=event.ownershipReason,
        observation={event=upperFirst(anchor),person=personName,side=event.mine and"mine"or"incoming",eventType=event.type,confidence=confidence,at=event.at or now()},
        interpretation={status="unknown",label="",confidence=0,basis="No player response has been observed yet."},
        residual={label=UNRESOLVED_TYPES[event.type]and"unsettled"or"",score=UNRESOLVED_TYPES[event.type]and clamp(intensity*.7,.15,1)or 0},
        cues={person=norm(personName or""),themes=copyList(EVENT_THEMES[event.type]or{}),location=worldCue(),state=stateCue()},
        recallCount=0,lastRecalledAt=0,
    }
    -- Upgrading an Episode must never rewrite when/where it happened merely
    -- because the addon was loaded in a new scene. Preserve old evidence-side
    -- context when it exists; only the schema around it is upgraded.
    if legacyEpisode then
        episode.at=tonumber(legacyEpisode.at)or episode.at
        episode.sceneID=legacyEpisode.sceneID or episode.sceneID
        if type(legacyEpisode.cues)=="table"then episode.cues=legacyEpisode.cues end
        if type(legacyEpisode.observation)=="table"then episode.observation=legacyEpisode.observation end
        if type(legacyEpisode.reaction)=="table"then episode.reaction=legacyEpisode.reaction end
        if type(legacyEpisode.interpretation)=="table"then episode.interpretation=legacyEpisode.interpretation end
        if type(legacyEpisode.residual)=="table"then episode.residual=legacyEpisode.residual end
        episode.status=legacyEpisode.status or episode.status
        episode.resolvedAt=legacyEpisode.resolvedAt
        episode.recallCount=tonumber(legacyEpisode.recallCount)or 0
        episode.lastRecalledAt=tonumber(legacyEpisode.lastRecalledAt)or 0
        episode.lastRecalledSceneID=legacyEpisode.lastRecalledSceneID
        episode.memoryStored=legacyEpisode.memoryStored
        episode.memoryStoredAt=legacyEpisode.memoryStoredAt
        episode.memorySignature=legacyEpisode.memorySignature
    end
    event.episode=episode
    if linkedReply then episode.reactionOfEventID=linkedReply.eventID end
    if reciprocal then
        episode.reciprocalEventID=reciprocal.id
        episode.reaction={kind="warm",behavior="reciprocation",strength=.92,cue="affection was explicitly returned",at=now(),context="reciprocal-affection",certainty="confirmed",summary="I returned the affection."}
        episode.interpretation={status="confirmed",label="reciprocal warmth",confidence=.92,basis="The character explicitly returned the affection."}
        episode.residual={label="warmth",score=clamp(intensity*.82,.2,1)}
    end
    return episode
end
RPAL.AttachPersonaEpisode=attachEpisode

local ANGRY_TEXT={"i am angry","i'm angry","i am furious","i'm furious","fuck you","piss off","how dare you","i hate you","do not test me","don't test me"}
local HURT_TEXT={"that hurt","you hurt me","i am hurt","i'm hurt","that was cruel","why would you say that","i cannot believe you did that","i can't believe you did that"}
local AVOID_TEXT={"i need space","give me space","i need time","i cannot do this","i can't do this","i am not ready","i'm not ready","this is too much","leave me alone","go away","stay away","i need to leave","i should go","i want to run","i wanted to run","i just wanted to run","i want a way out","i wanted a way out"}
local REJECT_TEXT={"i do not feel the same","i don't feel the same","i cannot return those feelings","i can't return those feelings","i do not love you","i don't love you","not like that","i cannot give you that","i can't give you that"}
local WARM_TEXT={"i love you too","i feel the same","i missed you too","i trust you too","i forgive you","that means a lot to me","i am glad you told me","i'm glad you told me"}
local GUARDED_TEXT={"i do not know","i don't know","i am not sure","i'm not sure","i need to think","let me think","this is complicated","it is complicated","i need a moment"}
local FEAR_TEXT={"i am scared","i'm scared","i am frightened","i'm frightened","i am terrified","i'm terrified","this scares me","that scares me"}
local OVERWHELMED_TEXT={"i am overwhelmed","i'm overwhelmed","this is overwhelming","this is too much"}
local DEFLECT_TEXT={"anyway","never mind","forget i said anything","forget i asked","can we talk about something else","let us talk about something else","let's talk about something else","change the subject","moving on"}
local FINE_TEXT={"i am fine","i'm fine","nothing is wrong","nothing's wrong","it is fine","it's fine"}
local DOUBT_TEXT={"i do not believe you","i don't believe you","are you sure","prove it","that does not add up","that doesn't add up","i am not convinced","i'm not convinced","i doubt it","i doubt that"}
local COMFORT_TEXT={"i am here","i'm here","you are not alone","you're not alone","it is alright","it's alright","it is okay","it's okay","come here","you can tell me","i have you","i've got you"}
local JOKE_DEFLECT={"just kidding","i'm kidding","i am kidding","only joking","just joking","forget it, i was joking"}

local ANGRY_EMOTE={"glares at","snarls at","growls at","clenches his fist","clenches her fist","clenches their fist","bares his teeth","bares her teeth","bares their teeth"}
local HURT_EMOTE={"cries","sobs","voice cracks","wipes away a tear","flinches as if struck"}
local WITHDRAW_EMOTE={"backs away","steps back","pulls away","recoils","turns away","moves away","looks for the door","heads for the door","runs away","retreats from"}
local APPROACH_EMOTE={"steps closer","moves closer","leans closer","leans into","reaches for","takes his hand","takes her hand","takes their hand","holds his hand","holds her hand","holds their hand"}
local TOUCH_EMOTE={"hugs","embraces","kisses","holds close","rests against","nuzzles","touches his cheek","touches her cheek","touches their cheek"}
local FREEZE_EMOTE={"freezes","goes still","falls silent","stares in silence","stops dead","does not answer","doesn't answer"}
local GUARDED_EMOTE={"hesitates","pauses","stiffens","tenses","looks away","averts his gaze","averts her gaze","averts their gaze","avoids his gaze","avoids her gaze","avoids their gaze"}
local NERVOUS_EMOTE={"fidgets","wrings his hands","wrings her hands","wrings their hands","swallows hard","forces a smile","nervous smile","uneasy smile"}

local function reaction(kind,behavior,strength,cue,summary,certainty,meaning,meaningConfidence)
    return{kind=kind,behavior=behavior,strength=strength,cue=cue,summary=summary,certainty=certainty or"observed",meaning=meaning,meaningConfidence=meaningConfidence}
end

local function readReaction(text,eventName,event,thread,responseEventType)
    local value=clean(text);if value==""then return nil end
    local emote=eventName=="CHAT_MSG_EMOTE"or eventName=="CHAT_MSG_TEXT_EMOTE"
    if contains(value,REJECT_TEXT)then return reaction("avoidant","explicit-rejection",.96,"closeness was explicitly rejected","I rejected the closeness directly.","confirmed","rejection",.96)end
    if contains(value,OVERWHELMED_TEXT)then return reaction("avoidant","overwhelmed-withdrawal",.95,"the character explicitly said it was too much","I said the moment was too much for me.","confirmed","overwhelmed",.95)end
    if contains(value,FEAR_TEXT)then return reaction("guarded","explicit-fear",.94,"fear was explicitly named","I admitted that I was afraid.","confirmed","fear",.94)end
    if contains(value,ANGRY_TEXT)or(emote and contains(value,ANGRY_EMOTE))then return reaction("angry","anger",.90,"anger was expressed","I answered with visible anger.",contains(value,ANGRY_TEXT)and"confirmed"or"observed","anger",contains(value,ANGRY_TEXT)and .90 or .68)end
    if contains(value,HURT_TEXT)or(emote and contains(value,HURT_EMOTE))then return reaction("hurt","hurt-expression",.88,"hurt was expressed","I let some of the hurt show.",contains(value,HURT_TEXT)and"confirmed"or"observed","hurt",contains(value,HURT_TEXT)and .90 or .68)end
    if contains(value,AVOID_TEXT)or(emote and contains(value,WITHDRAW_EMOTE))then return reaction("avoidant","withdrawal",.90,"distance was created","I created distance instead of moving closer.",contains(value,AVOID_TEXT)and"confirmed"or"observed",contains(value,AVOID_TEXT)and"need for distance"or"possible overwhelm or avoidance",contains(value,AVOID_TEXT)and .90 or .60)end
    if event and event.type=="AFFECTION"and contains(value,{"me too","i love you","i care about you too"})then return reaction("warm","reciprocation",.94,"affection was explicitly returned","I returned the affection.","confirmed","reciprocal warmth",.94)end
    if contains(value,WARM_TEXT)then return reaction("warm","verbal-warmth",.90,"warmth was explicitly returned","I answered with warmth.","confirmed","warmth",.90)end
    if emote and(contains(value,TOUCH_EMOTE)or contains(value,APPROACH_EMOTE))then return reaction("warm",contains(value,TOUCH_EMOTE)and"affectionate-touch"or"approach",.86,"the character moved toward closeness",contains(value,TOUCH_EMOTE)and"I answered with touch instead of distance."or"I moved closer instead of away.","observed","possible warmth or acceptance",.64)end
    if emote and contains(value,FREEZE_EMOTE)then return reaction("uncertain","freeze",.84,"the character froze or went silent","I froze or went quiet before answering.","observed","caught off guard or overwhelmed",.58)end
    if contains(value,GUARDED_TEXT)then return reaction("uncertain","verbal-uncertainty",.80,"uncertainty was explicitly stated","I said I was not sure how to answer.","confirmed","uncertainty",.86)end
    if contains(value,FINE_TEXT)and emote and(contains(value,GUARDED_EMOTE)or contains(value,NERVOUS_EMOTE))then return reaction("guarded","concealment",.84,"the words said fine while body language stayed guarded","I said I was fine while my body language stayed guarded.","observed","possible emotion concealment",.68)end
    if contains(value,DOUBT_TEXT)then return reaction("guarded","doubt",.78,"the character openly questioned what they were hearing","I answered with doubt instead of taking it at face value.","confirmed","doubt",.84)end
    if contains(value,COMFORT_TEXT)then return reaction("warm","comforting",.76,"the character answered by offering comfort","I tried to make the moment safer for them.","observed","care or protectiveness",.62)end
    if contains(value,JOKE_DEFLECT)then return reaction("guarded","joke-deflection",.72,"humor was used where a direct answer could have been given","I turned the moment into a joke instead of answering it straight.","observed","possible emotional deflection",.54)end
    if emote and contains(value,GUARDED_EMOTE)then return reaction("guarded","guarded-body-language",.78,"body language became guarded","I held my reaction back and became guarded.","observed","possible discomfort or caution",.56)end
    if contains(value,DEFLECT_TEXT)then return reaction("guarded","deflection",.74,"the response deliberately changed or closed the subject","I steered away from answering it directly.","observed","possible avoidance or emotional deflection",.56)end
    if event and event.type=="REJECTION"and contains(value,{"alright","understood","i understand","fair enough"})then return reaction("guarded","acceptance-with-distance",.70,"the rejection was accepted without pursuit","I accepted the rejection without pressing closer.","observed","guarded acceptance",.62)end
    if responseEventType=="BOUNDARY"then return reaction("guarded","boundary-response",.78,"the character answered by setting a boundary","I answered by setting a boundary of my own.","observed","need for distance or control",.62)end
    local emotionallyLoaded=event and({AFFECTION=true,INTIMACY=true,VULNERABILITY=true,REJECTION=true,APOLOGY=true,TRUST_GIVEN=true,TRUST_BROKEN=true,TRUST_ACCUSATION=true,BOUNDARY=true})[event.type]
    if emotionallyLoaded and thread and thread.active and thread.coherent and thread.topicShift==true and#value>=18 and contains(value,{"so ","well ","right,"})then
        return reaction("guarded","soft-deflection",.56,"the reply introduced a clearly different topic after an emotionally loaded turn","I shifted onto another subject instead of staying with that moment.","observed","possible deflection",.42)
    end
end

local function interpretationFor(reactionRow,event)
    if type(reactionRow)~="table"then return{status="unknown",label="",confidence=0,basis="No player response has been observed yet."}end
    local label=trim(reactionRow.meaning);local confidence=clamp(reactionRow.meaningConfidence or reactionRow.strength*.72,0,1)
    local status=reactionRow.certainty=="confirmed"and"confirmed"or"inferred"
    if label==""then
        local map={warm="warmth",avoidant="need for distance",guarded="caution",uncertain="uncertainty",angry="anger",hurt="hurt",vigilant="vigilance"}
        label=map[reactionRow.kind]or"uncertain response"
    end
    local basis=status=="confirmed"and("The character explicitly expressed "..label..".")or("Observed behaviour suggests "..label..", but Persona is keeping that interpretation tentative.")
    return{status=status,label=label,confidence=confidence,basis=basis}
end

local RESIDUAL_LABEL={warm="warmth",avoidant="tension",guarded="guarded tension",uncertain="uncertainty",angry="anger",hurt="hurt",vigilant="vigilance"}
local function applyReactionToEpisode(episode,reactionRow)
    if type(episode)~="table"or type(reactionRow)~="table"then return end
    episode.reaction=reactionRow
    episode.interpretation=interpretationFor(reactionRow,episode)
    local label=RESIDUAL_LABEL[reactionRow.kind]or""
    episode.residual={label=label,score=clamp((tonumber(episode.intensity)or .4)*.58+(tonumber(reactionRow.strength)or .5)*.42,.10,1)}
    if reactionRow.kind=="warm"and not UNRESOLVED_TYPES[episode.eventType]then episode.status="settled"end
end

local function memoryText(episode)
    local text=upperFirst(episode.anchor).."."
    local reactionRow=episode.reaction
    if type(reactionRow)=="table"and trim(reactionRow.summary)~=""then text=text.." "..trim(reactionRow.summary)end
    return text
end

local WARM_MISMATCH={RELATIONSHIP_BREAK=true,REJECTION=true,TRUST_BROKEN=true,TRUST_ACCUSATION=true,INSULT=true,MOCKERY=true,HOSTILITY=true,THREAT=true,AGGRESSION=true,SOCIAL_DISAPPROVAL=true,HUMOR_REJECTED=true}
local function reactionFits(event,reactionRow)
    if not event or not reactionRow then return false end
    if reactionRow.kind=="warm"and WARM_MISMATCH[event.type]then return false end
    return true
end

local function episodeSnapshot(episode)
    if type(episode)~="table"then return nil end
    local reactionRow=type(episode.reaction)=="table"and{
        kind=episode.reaction.kind,behavior=episode.reaction.behavior,strength=episode.reaction.strength,cue=episode.reaction.cue,
        summary=episode.reaction.summary,certainty=episode.reaction.certainty,at=episode.reaction.at,context=episode.reaction.context,channel=episode.reaction.channel,components=episode.reaction.components,
    }or nil
    local interpretation=type(episode.interpretation)=="table"and{status=episode.interpretation.status,label=episode.interpretation.label,confidence=episode.interpretation.confidence,basis=episode.interpretation.basis}or nil
    local residual=type(episode.residual)=="table"and{label=episode.residual.label,score=episode.residual.score}or nil
    local cues=type(episode.cues)=="table"and{person=episode.cues.person,themes=copyList(episode.cues.themes),location=episode.cues.location,state=episode.cues.state}or nil
    return{
        version=VERSION,eventID=episode.eventID,eventType=episode.eventType,at=episode.at,side=episode.side,person=episode.person,relation=episode.relation,
        anchor=episode.anchor,factKind=episode.factKind,confidence=episode.confidence,intensity=episode.intensity,weight=episode.weight,status=episode.status,
        sceneID=episode.sceneID,evidence=episode.evidence,source=episode.source,ownershipClass=episode.ownershipClass,ownershipConfidence=episode.ownershipConfidence,ownershipReason=episode.ownershipReason,
        observation=episode.observation,reaction=reactionRow,interpretation=interpretation,residual=residual,cues=cues,resolvedAt=episode.resolvedAt,
        recallCount=tonumber(episode.recallCount)or 0,lastRecalledAt=tonumber(episode.lastRecalledAt)or 0,lastRecalledSceneID=episode.lastRecalledSceneID,
    }
end

local function storeEpisode(event,q)
    local episode=attachEpisode(event,q)
    if not episode or not episode.durable or episode.memoryStored or not RPAL.AddPersonaMemory then return nil end
    if(tonumber(episode.confidence)or 0)<.56 then episode.memorySuppressed="low-event-confidence";return nil end
    local stored=episodeSnapshot(episode)
    local row=RPAL.AddPersonaMemory("episode",memoryText(stored),episode.weight,{eventID=episode.eventID,type=episode.eventType,person=episode.person,confidence=episode.confidence,episode=stored,concrete=true})
    if row then episode.memoryStored=true;episode.memoryStoredAt=now();episode.memorySignature=row.signature end
    return row
end
RPAL.StorePersonaEpisodeMemory=storeEpisode

local function responseKindsConflict(a,b)
    if a==b then return false end
    local warm={warm=true};local closed={avoidant=true,guarded=true,uncertain=true,hurt=true,angry=true}
    return(warm[a]and closed[b])or(warm[b]and closed[a])
end

function RPAL.CapturePersonaEpisodeReaction(q,p,text,eventName,person,thread,responseEventType)
    if type(q)~="table"then return nil end
    local keyFn=RPAL.GetPersonaSpeakerIdentityKey
    local target=type(keyFn)=="function"and keyFn(person or{fullName=thread and thread.person})or norm(person and person.name or(thread and thread.person)or"");local t=now();local value=clean(text)
    local emote=eventName=="CHAT_MSG_EMOTE"or eventName=="CHAT_MSG_TEXT_EMOTE"
    for _,event in ipairs(type(q.events)=="table"and q.events or{})do
        local age=t-(tonumber(event.at)or 0);if age>REACTION_WINDOW then break end
        local episode=attachEpisode(event,q);local eventKey=type(keyFn)=="function"and keyFn(event.person)or norm(event.person and event.person.name or"");local same=target~=""and target==eventKey
        -- Ordinary replies bind to the nearest live exchange, not any event
        -- involving the same person during the last four minutes.
        local linkable=age<=REACTION_LINK_WINDOW or((thread and thread.coherent)and age<=120)
        if episode and not event.mine and same and linkable then
            episode.responseTrace=type(episode.responseTrace)=="table"and episode.responseTrace or{}
            if not emote and contains(value,FINE_TEXT)then episode.responseTrace.fineAt=t end
            local reactionRow=readReaction(text,eventName,event,thread,responseEventType)
            if emote and tonumber(episode.responseTrace.fineAt)and t-episode.responseTrace.fineAt<=60 and(contains(value,GUARDED_EMOTE)or contains(value,NERVOUS_EMOTE))then
                reactionRow=reaction("guarded","concealment",.88,"the character said they were fine, then showed guarded or nervous body language","What I said and what I showed did not quite match.","observed","possible emotion concealment",.70)
            end
            if reactionFits(event,reactionRow)then
                reactionRow.at=t;reactionRow.context="observed-player-response";reactionRow.channel=emote and"emote"or"say"
                local existing=type(episode.reaction)=="table"and episode.reaction or nil
                local conflict=existing and existing.channel and existing.channel~=reactionRow.channel and t-(tonumber(existing.at)or t)<=75 and responseKindsConflict(existing.kind,reactionRow.kind)
                if conflict then
                    local mixed=reaction("guarded","mixed-signals",math.max(tonumber(existing.strength)or .5,tonumber(reactionRow.strength)or .5)+.03,"the character's words and body language pulled in different directions","What I said and what I showed did not quite match.","observed","possible emotion concealment or mixed feelings",.62)
                    mixed.at=t;mixed.context="observed-player-response-contradiction";mixed.channel="mixed";mixed.components={{kind=existing.kind,behavior=existing.behavior,channel=existing.channel},{kind=reactionRow.kind,behavior=reactionRow.behavior,channel=reactionRow.channel}}
                    applyReactionToEpisode(episode,mixed)
                    if trim(responseEventType)~=""then RPAL._personaEpisodeReactionLink={at=t,eventID=event.id,person=episode.person,reaction=mixed.kind,behavior=mixed.behavior}end
                    return episode
                end
                if not existing or reactionRow.strength>(tonumber(existing.strength)or 0)then
                    if(thread and thread.coherent)or reactionRow.strength>=.84 then
                        applyReactionToEpisode(episode,reactionRow)
                        if trim(responseEventType)~=""then RPAL._personaEpisodeReactionLink={at=t,eventID=event.id,person=episode.person,reaction=reactionRow.kind,behavior=reactionRow.behavior}end
                        return episode
                    end
                end
            end
        end
    end
end

local function unresolvedRowFor(q,eventID)
    if type(q)~="table"then return nil end
    for _,row in pairs(type(q.unresolved)=="table"and q.unresolved or{})do
        if type(row)=="table"and tonumber(row.eventID)==tonumber(eventID)then return row end
    end
end

local function syncEpisodeResolution(q,p)
    local byEvent={}
    for _,event in ipairs(type(q and q.events)=="table"and q.events or{})do
        if type(event.episode)=="table"then byEvent[tonumber(event.id)or event.id]=event.episode end
    end
    local memories=type(p and p.continuity and p.continuity.memories)=="table"and p.continuity.memories or{}
    for _,memory in ipairs(memories)do
        local meta=type(memory.meta)=="table"and memory.meta or{}
        local episode=type(meta.episode)=="table"and meta.episode or nil
        if episode then byEvent[tonumber(episode.eventID)or episode.eventID]=episode end
    end
    for eventID,episode in pairs(byEvent)do
        local unresolved=unresolvedRowFor(q,eventID)
        if unresolved then
            if unresolved.resolved then episode.status="resolved";episode.resolvedAt=tonumber(unresolved.resolvedAt)or now()
            else episode.status="unresolved"end
        elseif UNRESOLVED_TYPES[episode.eventType]and episode.status~="resolved"then episode.status="unresolved"end
    end
end

function RPAL.FinalizePersonaEpisodes(q,p,force)
    if type(q)~="table"then return 0 end
    RPAL.AuditPersonaCognitiveStore(q,p);syncEpisodeResolution(q,p)
    local t=now();local stored=0;local checked=0
    for _,event in ipairs(type(q.events)=="table"and q.events or{})do
        checked=checked+1;if checked>36 then break end
        local episode=attachEpisode(event,q)
        if episode and episode.durable and not episode.memoryStored then
            local age=t-(tonumber(event.at)or t)
            if age<=MAX_EPISODE_AGE and(force or age>=FINALIZE_DELAY)then if storeEpisode(event,q)then stored=stored+1 end end
        end
    end
    return stored
end

function RPAL.GetPersonaEpisodeFromMemory(memory)
    if type(memory)~="table"then return nil end
    local meta=type(memory.meta)=="table"and memory.meta or{}
    if type(meta.episode)=="table"then return meta.episode end
    if memory.kind=="cognitive-event"and trim(meta.type)~=""and trim(meta.person)~=""then
        return attachEpisode({id=meta.eventID,type=meta.type,at=memory.at,person={name=meta.person,kind=meta.relation or"neutral"},confidence=meta.confidence or .62,appraisal={intensity=memory.weight or .5},mine=false,evidence=memory.text})
    end
end

local function currentContext()
    local conversation=type(RPAL.GetPersonaConversationContext)=="function"and RPAL.GetPersonaConversationContext()or{}
    local world=type(RPAL.GetPersonaWorldContext)=="function"and RPAL.GetPersonaWorldContext()or{}
    local state=type(RPAL.GetPersonaLivingState)=="function"and RPAL.GetPersonaLivingState()or{}
    local scene=RPAL.GetActiveScene and RPAL.GetActiveScene()or nil
    return{conversation=type(conversation)=="table"and conversation or{},world=type(world)=="table"and world or{},state=type(state)=="table"and state or{},sceneID=scene and scene.id or nil,at=now()}
end

local function themeOverlap(a,b)
    if type(a)~="table"or type(b)~="table"then return false end
    local terms={}
    for key,value in pairs(b)do
        local raw=type(key)=="number"and value or((value==true or value==1)and key or value)
        local term=tostring(raw or""):lower();if term~=""then terms[#terms+1]=term end
    end
    for _,left in ipairs(a)do for _,right in ipairs(terms)do
        local l=tostring(left or""):lower();local r=tostring(right or""):lower()
        if l~=""and r~=""and(l==r or l:find(r,1,true)or r:find(l,1,true))then return true end
    end end
    return false
end

function RPAL.ScorePersonaEpisodeRecall(episode,context)
    if type(episode)~="table"or trim(episode.anchor)==""or trim(episode.person)==""then return -999,"missing-anchor"end
    local pass,reason=RPAL.PersonaEpisodePassesThoughtAudit and RPAL.PersonaEpisodePassesThoughtAudit(episode)
    if pass==false then return -999,reason or"episode-audit"end
    local age=math.max(0,now()-(tonumber(episode.at)or now()))
    if age>MAX_EPISODE_AGE then return -999,"too-old"end
    if(tonumber(episode.confidence)or 0)<.56 then return -999,"low-confidence"end
    context=type(context)=="table"and context or currentContext()
    if trim(context.sceneID)~=""and tostring(episode.lastRecalledSceneID or"")==tostring(context.sceneID)then return -999,"already-recalled-this-scene"end
    local score=(tonumber(episode.weight)or .4)*34
    if age<=86400 then score=score+18 elseif age<=7*86400 then score=score+13 elseif age<=21*86400 then score=score+7 elseif age<=30*86400 then score=score+2 else score=score-8 end
    if episode.status=="unresolved"then score=score+15 elseif episode.status=="resolved"then score=score+4 end
    local cues=type(episode.cues)=="table"and episode.cues or{}
    local conv=context.conversation or{}
    local personMatch=trim(conv.person)~=""and norm(conv.person)==norm(episode.person)
    local themeMatch=themeOverlap(cues.themes,conv.topics)
    if personMatch then score=score+36 end
    if themeMatch then score=score+15 end
    local loc=type(cues.location)=="table"and cues.location or{};local world=context.world or{}
    local exactLocation=trim(loc.signature)~=""and trim(world.signature)~=""and loc.signature==world.signature
    -- A broad place match must never summon a random old social memory.
    -- Location-only recall is reserved for genuinely weighty, confirmed or
    -- unresolved turning points; ordinary memories require the person or topic.
    local interpretation=type(episode.interpretation)=="table"and tostring(episode.interpretation.status or"")or""
    local strongPlaceCue=exactLocation and(tonumber(episode.weight)or 0)>=.68 and(interpretation=="confirmed"or episode.status=="unresolved")
    if not personMatch and not themeMatch and not strongPlaceCue then return -999,"no-present-social-cue"end
    if exactLocation and(personMatch or themeMatch)then score=score+6 elseif strongPlaceCue then score=score+8 end
    local cueState=type(cues.state)=="table"and tostring(cues.state.label or"")or"";local state=tostring((context.state or{}).label or"")
    if cueState~=""and state~=""and cueState==state and(personMatch or themeMatch)then score=score+4 end
    local recalled=tonumber(episode.lastRecalledAt)or 0
    if recalled>0 then
        local since=math.max(0,now()-recalled)
        if since<3*3600 then score=score-60 elseif since<12*3600 then score=score-34 elseif since<2*86400 then score=score-16 end
    end
    score=score-math.min(18,(tonumber(episode.recallCount)or 0)*3)
    return score,"ok"
end

function RPAL.GetPersonaBestEpisodeRecall(memories,p)
    memories=type(memories)=="table"and memories or(type(RPAL.GetPersonaMemories)=="function"and RPAL.GetPersonaMemories(36)or{})
    p=type(p)=="table"and p or(RPAL.GetPersonalityData and RPAL.GetPersonalityData())
    local context=currentContext();local best,bestScore
    local sceneState=type(p and p.continuity and p.continuity.episodeRecallScene)=="table"and p.continuity.episodeRecallScene or{}
    if tostring(sceneState.sceneID or"")~=tostring(context.sceneID or"")then sceneState={sceneID=context.sceneID,people={},count=0} end
    if(tonumber(sceneState.count)or 0)>=2 then return nil end
    for _,memory in ipairs(memories)do
        local episode=RPAL.GetPersonaEpisodeFromMemory(memory)
        local personKey=episode and norm(episode.person or"")or""
        if episode and not(type(sceneState.people)=="table"and sceneState.people[personKey])then
            local score=RPAL.ScorePersonaEpisodeRecall(episode,context)
            if score and score>=42 and(not bestScore or score>bestScore)then best,bestScore={memory=memory,episode=episode,context=context},score end
        end
    end
    if not best then return nil end
    best.score=bestScore
    return best
end

function RPAL.MarkPersonaEpisodeRecalled(episode,memory)
    if type(episode)~="table"then return end
    local t=now();local scene=RPAL.GetActiveScene and RPAL.GetActiveScene()or nil;local sceneID=scene and scene.id or nil
    episode.recallCount=(tonumber(episode.recallCount)or 0)+1;episode.lastRecalledAt=t;episode.lastRecalledSceneID=sceneID
    if type(memory)=="table"and type(memory.meta)=="table"and type(memory.meta.episode)=="table"then
        memory.meta.episode.recallCount=episode.recallCount;memory.meta.episode.lastRecalledAt=t;memory.meta.episode.lastRecalledSceneID=sceneID
    end
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData()
    if type(p)=="table"then
        p.continuity=type(p.continuity)=="table"and p.continuity or{}
        local rs=type(p.continuity.episodeRecallScene)=="table"and p.continuity.episodeRecallScene or{}
        if tostring(rs.sceneID or"")~=tostring(sceneID or"")then rs={sceneID=sceneID,people={},count=0}end
        rs.people=type(rs.people)=="table"and rs.people or{};local pk=norm(episode.person or"")
        if pk~=""and not rs.people[pk]then rs.people[pk]=true;rs.count=(tonumber(rs.count)or 0)+1 end
        p.continuity.episodeRecallScene=rs
    end
end

function RPAL.PersonaEpisodePassesThoughtAudit(episode)
    if type(episode)~="table"then return false,"missing-episode"end
    if trim(episode.person)==""or trim(episode.anchor)==""then return false,"missing-concrete-referent"end
    if(tonumber(episode.confidence)or 0)<.56 then return false,"low-confidence"end
    if now()-(tonumber(episode.at)or now())>MAX_EPISODE_AGE then return false,"stale-episode"end
    if episode.side=="incoming"then
        if episode.ownershipClass==nil then return false,"legacy-episode-without-ownership-proof"end
        if episode.ownershipClass~="directed"or(tonumber(episode.ownershipConfidence)or 0)<.68 then return false,"episode-not-directed-at-character"end
    end
    if episode.eventType=="INTIMACY"and trim(episode.evidence)==""and(tonumber(episode.version)or 0)<VERSION then
        return false,"legacy-intimacy-without-verifiable-evidence"
    end
    return true
end

local function collectEpisodes()
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData();local out={};local seen={}
    local memories=type(p and p.continuity and p.continuity.memories)=="table"and p.continuity.memories or{}
    for _,memory in ipairs(memories)do
        local episode=RPAL.GetPersonaEpisodeFromMemory(memory);local id=episode and(tonumber(episode.eventID)or tostring(episode.eventID or""))
        if episode and id and not seen[id]then seen[id]=true;out[#out+1]={episode=episode,memory=memory}end
    end
    return out,p
end

function RPAL.GetPersonaTurningPoints(limit)
    local rows=collectEpisodes();local out={}
    for _,wrap in ipairs(rows)do
        local e=wrap.episode
        if TURNING_TYPES[e.eventType]and(tonumber(e.weight)or 0)>=.58 then out[#out+1]={at=e.at,person=e.person,eventType=e.eventType,text=e.anchor,status=e.status,weight=e.weight,reaction=e.reaction,interpretation=e.interpretation,eventID=e.eventID}end
    end
    table.sort(out,function(a,b)if(a.weight or 0)==(b.weight or 0)then return(a.at or 0)>(b.at or 0)end;return(a.weight or 0)>(b.weight or 0)end)
    limit=math.max(1,math.min(12,math.floor(tonumber(limit)or 6)));while#out>limit do table.remove(out)end
    return out
end

function RPAL.GetPersonaUnresolvedThreads(limit)
    local rows=collectEpisodes();local out={}
    for _,wrap in ipairs(rows)do
        local e=wrap.episode
        if e.status=="unresolved"and(tonumber(e.weight)or 0)>=.42 then
            local residual=type(e.residual)=="table"and e.residual or{}
            out[#out+1]={at=e.at,person=e.person,eventType=e.eventType,text=e.anchor,residual=residual.label,score=(tonumber(e.weight)or .4)*.65+(tonumber(residual.score)or .3)*.35,eventID=e.eventID,interpretation=e.interpretation}
        end
    end
    table.sort(out,function(a,b)if(a.score or 0)==(b.score or 0)then return(a.at or 0)>(b.at or 0)end;return(a.score or 0)>(b.score or 0)end)
    limit=math.max(1,math.min(12,math.floor(tonumber(limit)or 6)));while#out>limit do table.remove(out)end
    return out
end

local function addInsightEvidence(bucket,key,episode,label)
    bucket[key]=bucket[key]or{key=key,label=label,count=0,totalConfidence=0,eventIDs={},examples={}}
    local row=bucket[key];local id=tonumber(episode.eventID)or tostring(episode.eventID or"")
    if row.eventIDs[id]then return end
    row.eventIDs[id]=true;row.count=row.count+1
    local conf=type(episode.interpretation)=="table"and tonumber(episode.interpretation.confidence)or 0
    row.totalConfidence=row.totalConfidence+math.max(.45,conf or .45)
    if#row.examples<3 then row.examples[#row.examples+1]=episode.anchor end
end

function RPAL.GetPersonaCharacterInsights(limit)
    local rows=collectEpisodes();local buckets={}
    for _,wrap in ipairs(rows)do
        local e=wrap.episode;local r=type(e.reaction)=="table"and e.reaction or{};local behavior=tostring(r.behavior or"")
        if behavior=="withdrawal"or behavior=="overwhelmed-withdrawal"or behavior=="explicit-rejection"then addInsightEvidence(buckets,"pressure-distance",e,"Under emotional pressure, the character often creates distance before answering.")end
        if behavior=="deflection"or behavior=="soft-deflection"or behavior=="joke-deflection"then addInsightEvidence(buckets,"deflection",e,"The character sometimes redirects emotionally loaded moments instead of answering them head-on.")end
        if behavior=="freeze"or behavior=="guarded-body-language"or behavior=="concealment"or behavior=="mixed-signals"then addInsightEvidence(buckets,"guarded-body",e,"Uncertainty often appears in the character's body language before it appears in their words.")end
        if behavior=="approach"or behavior=="affectionate-touch"or behavior=="reciprocation"then addInsightEvidence(buckets,"approach-warmth",e,"When the character accepts closeness, they tend to show it through approach or touch rather than explanation.")end
        if behavior=="comforting"or(e.side=="mine"and(e.eventType=="SUPPORT"or e.eventType=="AFTERCARE"or e.eventType=="REASSURANCE"))then addInsightEvidence(buckets,"care-action",e,"The character tends to express care by staying useful or present rather than making the moment about themselves.")end
        if e.eventType=="TRUST_GIVEN"and(r.kind=="guarded"or r.kind=="uncertain")then addInsightEvidence(buckets,"trust-caution",e,"Being trusted does not automatically relax the character; it often makes them more careful about what comes next.")end
        if e.eventType=="COMPLIMENT"and(r.kind=="guarded"or r.kind=="uncertain")then addInsightEvidence(buckets,"praise-discomfort",e,"Direct praise tends to make the character more guarded than ordinary gratitude does.")end
    end
    local out={}
    for _,row in pairs(buckets)do
        if row.count>=3 then
            local confidence=clamp((row.totalConfidence/row.count)*(.68+math.min(.24,(row.count-3)*.06)),.45,.94)
            out[#out+1]={key=row.key,text=row.label,count=row.count,confidence=confidence,examples=row.examples}
        end
    end
    table.sort(out,function(a,b)if a.count==b.count then return(a.confidence or 0)>(b.confidence or 0)end;return a.count>b.count end)
    limit=math.max(1,math.min(8,math.floor(tonumber(limit)or 4)));while#out>limit do table.remove(out)end
    return out
end

local CLARIFY_CHOICES={
    {id="fear",label="Fear",reaction="guarded"},{id="discomfort",label="Discomfort",reaction="guarded"},{id="overwhelmed",label="Overwhelmed",reaction="avoidant"},
    {id="hidden-warmth",label="Hiding warmth",reaction="warm"},{id="wrong",label="That is not what happened",reaction=nil},
}

local function ensureClarifications(p)
    if type(p)~="table"then return nil end
    p.continuity=type(p.continuity)=="table"and p.continuity or{}
    p.continuity.episodeClarifications=type(p.continuity.episodeClarifications)=="table"and p.continuity.episodeClarifications or{}
    return p.continuity.episodeClarifications
end

local function queueClarification(episode,p)
    if type(episode)~="table"or type(p)~="table"then return end
    local interpretation=type(episode.interpretation)=="table"and episode.interpretation or{}
    if episode.side~="incoming"or(tonumber(episode.weight)or 0)<.67 or episode.status~="unresolved"then return end
    if interpretation.status=="confirmed"or(tonumber(interpretation.confidence)or 0)>=.72 then return end
    local reactionRow=type(episode.reaction)=="table"and episode.reaction or{}
    if not(reactionRow.kind=="avoidant"or reactionRow.kind=="guarded"or reactionRow.kind=="uncertain")then return end
    local rows=ensureClarifications(p);if not rows then return end
    for _,row in ipairs(rows)do if tonumber(row.eventID)==tonumber(episode.eventID)then return end end
    table.insert(rows,1,{eventID=episode.eventID,at=now(),person=episode.person,anchor=episode.anchor,reaction=reactionRow.kind,behavior=reactionRow.behavior,answered=false})
    while#rows>MAX_CLARIFICATIONS do table.remove(rows)end
end

function RPAL.GetPersonaInterpretationCheck()
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData();local rows=ensureClarifications(p)or{}
    for _,row in ipairs(rows)do if not row.answered and now()-(tonumber(row.at)or now())<=14*86400 then return row end end
end

function RPAL.ResolvePersonaInterpretationCheck(eventID,choiceID)
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData();if type(p)~="table"then return false end
    local rows=ensureClarifications(p)or{};local target
    for _,row in ipairs(rows)do if tonumber(row.eventID)==tonumber(eventID)and not row.answered then target=row;break end end
    if not target then return false end
    local chosen
    for _,choice in ipairs(CLARIFY_CHOICES)do if choice.id==choiceID then chosen=choice;break end end
    if not chosen then return false end
    target.answered=true;target.answeredAt=now();target.answer=choiceID
    local memories=type(p.continuity and p.continuity.memories)=="table"and p.continuity.memories or{}
    for _,memory in ipairs(memories)do
        local episode=RPAL.GetPersonaEpisodeFromMemory(memory)
        if episode and tonumber(episode.eventID)==tonumber(eventID)then
            if choiceID=="wrong"then
                episode.interpretation={status="rejected",label="",confidence=0,basis="The player rejected Persona's interpretation of this event."}
                episode.reaction=nil;episode.status="settled";episode.residual={label="",score=0};memory.weight=math.min(tonumber(memory.weight)or .5,.30)
            else
                episode.interpretation={status="confirmed",label=chosen.label:lower(),confidence=.98,basis="The player confirmed this interpretation after the scene."}
                episode.residual=episode.residual or{};episode.residual.label=chosen.label:lower();episode.residual.score=math.max(tonumber(episode.residual.score)or 0,.72)
                if chosen.reaction and type(episode.reaction)=="table"then episode.reaction.kind=chosen.reaction;episode.reaction.certainty="confirmed"end
            end
            memory.text=memoryText(episode);memory.signature=("episode|"..memory.text:lower():gsub("%s+"," ")):sub(1,220);return true
        end
    end
    return true
end

local previousSceneEnded=RPAL.OnSceneEnded
RPAL.OnSceneEnded=function(scene)
    if previousSceneEnded then pcall(previousSceneEnded,scene)end
    local p=RPAL.GetPersonalityData and RPAL.GetPersonalityData();if type(p)~="table"then return end
    local cognition=p.continuity and p.continuity.cognition
    if type(cognition)=="table"then
        syncEpisodeResolution(cognition,p)
        local startedAt=tonumber(scene and scene.startedAt)or tonumber(p.continuity and p.continuity.session and p.continuity.session.startedAt)or(now()-math.max(60,tonumber(scene and(scene.duration or scene.activeSeconds))or 0))
        for _,event in ipairs(type(cognition.events)=="table"and cognition.events or{})do
            if(tonumber(event.at)or 0)>=startedAt then local episode=attachEpisode(event,cognition);if episode then queueClarification(episode,p)end end
        end
    end
end

-- Schema 15 upgrades stored episode cards to the observation / inference /
-- resolution shape without inventing missing reactions. Existing concrete
-- anchors are preserved; old generic memories stay retired by schema 14.
if RPAL.RegisterDataMigration then
    RPAL.RegisterDataMigration(15,function(_,characterDB,scope)
        if type(scope)=="table"and not scope.needsCharacter then return end
        local seen={}
        local function upgradeEpisode(episode)
            if type(episode)~="table"then return end
            episode.version=VERSION
            episode.status=episode.status or(baseStatusFor({type=episode.eventType}))
            episode.observation=type(episode.observation)=="table"and episode.observation or{event=episode.anchor,person=episode.person,side=episode.side,eventType=episode.eventType,confidence=episode.confidence,at=episode.at}
            if type(episode.reaction)=="table"then
                episode.reaction.behavior=episode.reaction.behavior or episode.reaction.kind
                episode.reaction.summary=episode.reaction.summary or({warm="I returned some of the warmth.",avoidant="I created distance in response.",guarded="I kept my reaction guarded.",uncertain="I did not give a clear answer.",angry="I answered with anger.",hurt="I let some of the hurt show.",vigilant="I stayed on guard."})[episode.reaction.kind]
                episode.interpretation=type(episode.interpretation)=="table"and episode.interpretation or interpretationFor(episode.reaction,episode)
                episode.residual=type(episode.residual)=="table"and episode.residual or{label=RESIDUAL_LABEL[episode.reaction.kind]or"",score=clamp((tonumber(episode.intensity)or .4)*.7,.1,1)}
            else
                episode.interpretation=type(episode.interpretation)=="table"and episode.interpretation or{status="unknown",label="",confidence=0,basis="No player response was stored for this event."}
                episode.residual=type(episode.residual)=="table"and episode.residual or{label=UNRESOLVED_TYPES[episode.eventType]and"unsettled"or"",score=UNRESOLVED_TYPES[episode.eventType]and clamp((tonumber(episode.intensity)or .4)*.7,.1,1)or 0}
            end
            episode.cues=type(episode.cues)=="table"and episode.cues or{person=norm(episode.person or""),themes=copyList(EVENT_THEMES[episode.eventType]or{})}
            episode.recallCount=tonumber(episode.recallCount)or 0;episode.lastRecalledAt=tonumber(episode.lastRecalledAt)or 0
        end
        local function visit(value,depth)
            if type(value)~="table"or seen[value]or depth>10 then return end
            seen[value]=true
            if value.kind=="episode"and type(value.meta)=="table"and type(value.meta.episode)=="table"then upgradeEpisode(value.meta.episode)end
            if value.eventType and value.anchor and value.person then upgradeEpisode(value)end
            for _,child in pairs(value)do if type(child)=="table"then visit(child,depth+1)end end
        end
        visit(characterDB,0)
    end)
end

