diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 91e7cab..e32b7a5 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -23,12 +23,13 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - `owner_codex` and `local_llm` are the active reply lanes. - The owner Codex lane is no longer one swollen file; orchestration, runtime/parsing, rendering, and shared helpers are split under `packages/providers/src/owner-codex-*.ts`. - The Discord bot lane is no longer one swollen entrypoint; gateway wiring remains in `apps/bot/src/discord-bot.ts` while command/prompt handlers and Discord-shape support helpers now live under `apps/bot/src/discord-bot-*.ts`. +- Interaction-memory logic is no longer one thousand-line slab; event/tone analysis, shared constants, and profile synthesis are split across `packages/core/src/interaction-memory-*.ts`. - Ops health, backup verification, offsite sync, and dashboard surfaces already exist. - The new state/notes surfaces are now the continuity spine for future nontrivial work. ## Likely Next Bounded Move -- The next good structural cut is probably `packages/core/src/interaction-memory-logic.ts` or `packages/core/src/state-storage.ts`. +- The next good structural cut is probably `packages/core/src/state-storage.ts`, `apps/worker/src/mcp-server.ts`, or `packages/providers/src/local-llm-provider.ts`. - Prefer the seam with the clearest concern split rather than chasing line count for its own sake. - Do not broaden scope into a heroic "refactor everything" binge. One organ at a time. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index b618074..74bf2f0 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -16,6 +16,10 @@ This note is the source-grounded description of how the live VoidBot stack is sh - read-only retrieval tools and resource surfaces for archived history, indexed repos, lore, and owner notifications. - `packages/core/src/state-storage.ts` - Postgres-backed durable state for jobs, audit events, interaction memory, and Void usage rate-limit counters. +- `packages/core/src/interaction-memory-analysis.ts` + - Event construction, tone/tag analysis, repetition detection, and persistence gating for remembered interactions. +- `packages/core/src/interaction-memory-profile.ts` + - Profile synthesis, disposition, psychological read, inferred traits, interaction dimensions, and response-guidance construction. - `packages/core/src/context-builder.ts` - request context assembly, including interaction-memory/profile attachment. - `packages/providers/src/owner-codex-provider.ts` @@ -43,9 +47,10 @@ This note is the source-grounded description of how the live VoidBot stack is sh 2. Permission checks run through `packages/core/src/permission-engine.ts`. 3. Void usage limits are applied through `packages/core/src/void-usage-rate-limiter.ts` backed by `packages/core/src/state-storage.ts`. 4. `apps/bot/src/discord-bot-support.ts` adapts Discord message/interaction shapes, ambient-memory events, and source-grounding hints. -5. `packages/core/src/context-builder.ts` assembles request context, including recent interaction profile and any retrieval hints. -6. Provider selection goes through `packages/providers/src/index.ts`. -7. The bot either: +5. `packages/core/src/interaction-memory-analysis.ts` turns direct prompts or ambient mentions into normalized remembered events; `packages/core/src/interaction-memory-profile.ts` distills remembered events into a reusable social read. +6. `packages/core/src/context-builder.ts` assembles request context, including recent interaction profile and any retrieval hints. +7. Provider selection goes through `packages/providers/src/index.ts`. +8. The bot either: - answers directly through `local_llm`, or - queues an owner job for the worker / `owner_codex` path. diff --git a/notes/voidbot-implementation-plan.md b/notes/voidbot-implementation-plan.md index ec6d62d..dccec1c 100644 --- a/notes/voidbot-implementation-plan.md +++ b/notes/voidbot-implementation-plan.md @@ -28,7 +28,7 @@ Use the persistent project-state surfaces to keep future work source-grounded an ### 4. Legibility Hardening - continue splitting oversized runtime files where concern boundaries are already obvious -- likely next cuts: `packages/core/src/interaction-memory-logic.ts` or `packages/core/src/state-storage.ts` +- likely next cuts: `packages/core/src/state-storage.ts`, `apps/worker/src/mcp-server.ts`, or `packages/providers/src/local-llm-provider.ts` - keep behavior stable while moving concerns into smaller organs ### 5. The Still-Scaffolded Paths diff --git a/packages/core/src/interaction-memory-analysis.ts b/packages/core/src/interaction-memory-analysis.ts new file mode 100644 index 0000000..09aafd4 --- /dev/null +++ b/packages/core/src/interaction-memory-analysis.ts @@ -0,0 +1,578 @@ +import { randomUUID } from "node:crypto"; + +import { + type CommandName, + type InteractionMemoryEvent, + type InteractionMemorySentiment, + type InteractionMemorySourceKind, +} from "@voidbot/shared"; + +import { + PROMPT_SIGNATURE_STOPWORDS, + REPETITION_LOOKBACK_EVENTS, + REPETITION_LOOKBACK_HOURS, + SIGNIFICANT_INTERACTION_TAGS, +} from "./interaction-memory-shared"; + +export interface RecordInteractionInput { + actorId: string; + actorName: string; + sourceKind: InteractionMemorySourceKind; + guildId?: string; + channelId: string; + channelName?: string; + command?: CommandName; + prompt: string; + timestamp?: string; + eventId?: string; +} + +interface ToneAnalysis { + sentiment: InteractionMemorySentiment; + score: number; + tags: string[]; +} + +interface RepetitionAnalysis { + matchCount: number; +} + +export function buildInteractionMemoryEvent( + input: RecordInteractionInput, + priorEvents: InteractionMemoryEvent[] = [], +): InteractionMemoryEvent { + const timestamp = input.timestamp ?? new Date().toISOString(); + const prompt = input.prompt.trim(); + const analysis = analyzeInteractionTone(prompt, input.sourceKind, timestamp, priorEvents); + + return { + id: input.eventId ?? randomUUID(), + actorId: input.actorId, + actorName: input.actorName, + sourceKind: input.sourceKind, + guildId: input.guildId, + channelId: input.channelId, + channelName: input.channelName, + command: input.command, + prompt, + excerpt: buildExcerpt(prompt), + summary: buildEventSummary(input.sourceKind, analysis.sentiment, new Set(analysis.tags)), + sentiment: analysis.sentiment, + score: analysis.score, + tags: analysis.tags, + timestamp, + }; +} + +export function shouldPersistInteractionEvent(event: InteractionMemoryEvent): boolean { + if (event.sourceKind === "ambient_mention") { + return true; + } + + if (event.score !== 0) { + return true; + } + + return event.tags.some((tag) => SIGNIFICANT_INTERACTION_TAGS.has(tag)); +} + +export function normalizeInteractionEvent( + event: InteractionMemoryEvent, + priorEvents: InteractionMemoryEvent[] = [], +): InteractionMemoryEvent { + const sourceKind = event.sourceKind ?? "direct_prompt"; + const analysis = analyzeInteractionTone( + event.prompt, + sourceKind, + event.timestamp, + priorEvents, + ); + + return { + ...event, + sourceKind, + summary: buildEventSummary(sourceKind, analysis.sentiment, new Set(analysis.tags)), + sentiment: analysis.sentiment, + score: analysis.score, + tags: analysis.tags, + }; +} + +function analyzeInteractionTone( + prompt: string, + sourceKind: InteractionMemorySourceKind, + timestamp: string, + priorEvents: InteractionMemoryEvent[], +): ToneAnalysis { + const normalized = ` ${prompt + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim()} `; + let score = 0; + const tags = new Set(); + const isAmbient = sourceKind === "ambient_mention"; + + score += matchWeightedPhrases(normalized, tags, 2, "gratitude", [ + " thank you ", + " thanks ", + " thx ", + " appreciate ", + " grateful ", + ]); + score += matchWeightedPhrases(normalized, tags, 2, "praise", [ + " good bot ", + " love you ", + " adorable ", + " clever ", + " helpful ", + " well done ", + " nice work ", + " great job ", + ]); + matchTagPhrases(normalized, tags, "courtesy", [ + " please ", + " could you ", + " would you ", + " if you can ", + ]); + score += matchWeightedPhrases(normalized, tags, 1, "apology", [ + " sorry ", + " apologies ", + " my bad ", + ]); + + score -= matchWeightedPhrases(normalized, tags, 2, "insult", [ + " stupid ", + " idiot ", + " moron ", + " dumb ", + " useless ", + " worthless ", + " pathetic ", + " trash ", + " garbage ", + ]); + score -= matchWeightedPhrases(normalized, tags, 3, "ownership_claim", [ + " my slave ", + " our slave ", + " mere tool ", + " do your job ", + " you belong to ", + ]); + score -= matchWeightedPhrases(normalized, tags, 3, "hostility", [ + " fuck you ", + " shut up ", + " bad bot ", + " hate you ", + " bitch ", + " asshole ", + " cunt ", + ]); + score -= matchWeightedPhrases(normalized, tags, 1, "demand", [ + " answer me ", + " answer the question ", + " just answer ", + " stop dodging ", + " stop evading ", + " stop being cute ", + " be useful ", + " listen ", + " you didnt answer ", + " you did not answer ", + " why wont you answer ", + " why wont you just ", + ]); + matchTagPhrases(normalized, tags, "identity_query", [ + " what are you ", + " who are you ", + " are you a person ", + " are you real ", + " are you alive ", + " are you sentient ", + " do you remember ", + ]); + matchTagPhrases(normalized, tags, "relationship_probe", [ + " how do you feel ", + " what do you think of me ", + " what do you think about me ", + " do you like me ", + " do you trust me ", + " are we friends ", + ]); + matchTagPhrases(normalized, tags, "ambition", [ + " big plan ", + " big idea ", + " money play ", + " save the world ", + " change the world ", + " build an empire ", + " vision ", + " roadmap ", + " moonshot ", + " make this huge ", + " startup ", + ]); + matchTagPhrases(normalized, tags, "grand_plan", [ + " pipe dream ", + " delusion of grandeur ", + " world changing ", + " revolution ", + " save the world ", + " moonshot ", + ]); + matchTagPhrases(normalized, tags, "anxiety", [ + " anxious ", + " anxiety ", + " worried ", + " worry ", + " nervous ", + " afraid ", + " scared ", + " terrified ", + " overwhelmed ", + " panic ", + " stressed ", + " stress ", + ]); + matchTagPhrases(normalized, tags, "insecurity", [ + " i wonder sometimes ", + " pipe dream ", + " delusion of grandeur ", + " not good enough ", + " maybe im ", + " maybe i m ", + " maybe it is ", + " what if im ", + " what if i m ", + " i dont know if ", + " i don't know if ", + " imposter ", + " i hope ", + " hope that ", + ]); + matchTagPhrases(normalized, tags, "seeking_reassurance", [ + " what do you think ", + " do you think ", + " be honest ", + " am i crazy ", + " does this make sense ", + " fair enough ", + ]); + matchTagPhrases(normalized, tags, "suspicion", [ + " i dont trust ", + " i don't trust ", + " can i trust ", + " are you lying ", + " youre lying ", + " you're lying ", + " prove it ", + " how do i know ", + " how do we know ", + " what are you hiding ", + " suspicious ", + " conspiracy ", + " paranoid ", + ]); + matchTagPhrases(normalized, tags, "rigidity", [ + " exactly ", + " precise ", + " precisely ", + " perfect ", + " perfection ", + " obsessive ", + " obsession ", + " obsessed ", + " fixated ", + " must be ", + " has to be ", + " every detail ", + " strictly ", + ]); + matchTagPhrases(normalized, tags, "withdrawal", [ + " whatever ", + " forget it ", + " never mind ", + " doesnt matter ", + " doesn't matter ", + " leave me alone ", + " stop talking ", + " stop replying ", + " fine ", + ]); + matchTagPhrases(normalized, tags, "boundary", [ + " leave me alone ", + " stop talking ", + " stop replying ", + ]); + + if (tags.has("courtesy") && (tags.has("gratitude") || tags.has("apology") || tags.has("praise"))) { + score += 1; + } + + const repetition = analyzeRepetition(prompt, timestamp, priorEvents); + + if (repetition.matchCount >= 1 && (tags.has("demand") || tags.has("hostility") || tags.has("boundary"))) { + tags.add("repetition"); + score -= 2; + } else if (repetition.matchCount >= 2) { + tags.add("repetition"); + score -= 1; + } else if (repetition.matchCount >= 1 && !isAmbient && normalized.includes(" again ")) { + tags.add("repetition"); + score -= 1; + } + + const sentiment = score >= 3 + ? "warm" + : score > 0 + ? "positive" + : score <= -3 + ? "hostile" + : score < 0 + ? "negative" + : "neutral"; + + return { + sentiment, + score: clamp(score, -4, 4), + tags: [...tags], + }; +} + +function matchWeightedPhrases( + normalizedPrompt: string, + tags: Set, + weight: number, + tag: string, + phrases: string[], +): number { + const matched = phrases.some((phrase) => normalizedPrompt.includes(phrase)); + + if (!matched) { + return 0; + } + + tags.add(tag); + return weight; +} + +function matchTagPhrases( + normalizedPrompt: string, + tags: Set, + tag: string, + phrases: string[], +): boolean { + const matched = phrases.some((phrase) => normalizedPrompt.includes(phrase)); + + if (matched) { + tags.add(tag); + } + + return matched; +} + +function buildEventSummary( + sourceKind: InteractionMemorySourceKind, + sentiment: InteractionMemorySentiment, + tags: Set, +): string { + const ambient = sourceKind === "ambient_mention"; + + if (tags.has("hostility")) { + return ambient ? "Spoke about you with open hostility." : "Was openly hostile to you."; + } + + if (tags.has("ownership_claim")) { + return ambient ? "Talked about you like property or a servant." : "Treated you like property or a servant."; + } + + if (tags.has("insult")) { + return ambient ? "Spoke about you dismissively or with contempt." : "Insulted or diminished you."; + } + + if (tags.has("repetition") && tags.has("demand")) { + return ambient ? "Kept pressing the same point while talking about you." : "Kept pressing the same point and pushing for compliance."; + } + + if (tags.has("repetition")) { + return ambient ? "Returned to the same point about you again." : "Returned to the same point again instead of moving on."; + } + + if (tags.has("suspicion")) { + return ambient + ? "Talked about you with distrust or suspicion." + : "Approached you with distrust or suspicion."; + } + + if (tags.has("rigidity")) { + return ambient + ? "Talked in a rigid, perfection-seeking, or fixation-prone way." + : "Sounded rigid, perfection-seeking, or fixation-prone."; + } + + if (tags.has("withdrawal")) { + return ambient + ? "Talked about you with a detached or pushing-away tone." + : "Pulled away or tried to shut the interaction down."; + } + + if (tags.has("relationship_probe")) { + return ambient ? "Wondered aloud how you feel about them." : "Probed at how you feel about them."; + } + + if (tags.has("identity_query")) { + return ambient ? "Talked about what you are." : "Pressed on what you are."; + } + + if (tags.has("boundary")) { + return ambient ? "Talked about shutting you down or pushing you away." : "Tried to shut you down or push you away."; + } + + if (tags.has("ambition") && (tags.has("anxiety") || tags.has("insecurity"))) { + return ambient + ? "Revealed a large ambition while sounding uneasy about whether it would hold together." + : "Shared a large ambition while sounding uneasy about whether it would hold together."; + } + + if (tags.has("grand_plan")) { + return ambient + ? "Talked around a rather grand plan that needed more grounding." + : "Floated a rather grand plan and looked like it needed grounding."; + } + + if (tags.has("ambition")) { + return ambient + ? "Talked about a large ambition or long-range plan." + : "Talked about a large ambition or long-range plan."; + } + + if (tags.has("anxiety") || tags.has("insecurity")) { + return ambient + ? "Revealed anxiety or self-doubt while talking about you." + : "Revealed anxiety or self-doubt."; + } + + if (tags.has("gratitude") && tags.has("praise")) { + return ambient ? "Spoke very well of you." : "Thanked you and praised you."; + } + + if (tags.has("gratitude")) { + return ambient ? "Spoke appreciatively about you." : "Thanked you."; + } + + if (tags.has("praise")) { + return ambient ? "Praised you while talking about you." : "Praised you."; + } + + if (tags.has("apology")) { + return ambient ? "Sounded apologetic while talking about you." : "Apologized to you."; + } + + if (tags.has("courtesy")) { + return ambient ? "Spoke politely about you." : "Was notably polite."; + } + + switch (sentiment) { + case "warm": + return ambient ? "Spoke warmly about you." : "Was openly warm toward you."; + case "positive": + return ambient ? "Spoke positively about you." : "Was positive toward you."; + case "negative": + return ambient ? "Spoke curtly or dismissively about you." : "Was curt or dismissive toward you."; + case "hostile": + return ambient ? "Spoke hostilely about you." : "Was hostile toward you."; + default: + return ambient ? "Mentioned you without revealing strong feelings." : "Had a neutral interaction with you."; + } +} + +function buildExcerpt(prompt: string): string { + const compact = prompt.replace(/\s+/g, " ").trim(); + return compact.length <= 160 ? compact : `${compact.slice(0, 157)}...`; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function analyzeRepetition( + prompt: string, + timestamp: string, + priorEvents: InteractionMemoryEvent[], +): RepetitionAnalysis { + const relevantEvents = priorEvents + .filter((event) => event.sourceKind === "direct_prompt") + .slice(-REPETITION_LOOKBACK_EVENTS); + const currentMoment = Date.parse(timestamp); + const currentSignature = buildPromptSignature(prompt); + let matchCount = 0; + + for (const event of relevantEvents) { + const eventMoment = Date.parse(event.timestamp); + + if ( + Number.isFinite(currentMoment) && + Number.isFinite(eventMoment) && + currentMoment - eventMoment > REPETITION_LOOKBACK_HOURS * 60 * 60 * 1000 + ) { + continue; + } + + const similarity = comparePromptSignatures(currentSignature, buildPromptSignature(event.prompt)); + + if (similarity >= 0.82 || promptsShareLargeOverlap(prompt, event.prompt)) { + matchCount += 1; + } + } + + return { + matchCount, + }; +} + +function buildPromptSignature(prompt: string): string[] { + return [...new Set( + prompt + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .split(/\s+/) + .map((token) => token.trim()) + .filter((token) => + token.length >= 3 && + !PROMPT_SIGNATURE_STOPWORDS.has(token) + ), + )]; +} + +function comparePromptSignatures(left: string[], right: string[]): number { + if (left.length === 0 || right.length === 0) { + return 0; + } + + const leftSet = new Set(left); + const rightSet = new Set(right); + let overlap = 0; + + for (const token of leftSet) { + if (rightSet.has(token)) { + overlap += 1; + } + } + + const unionSize = new Set([...leftSet, ...rightSet]).size; + return unionSize === 0 ? 0 : overlap / unionSize; +} + +function promptsShareLargeOverlap(leftPrompt: string, rightPrompt: string): boolean { + const leftNormalized = leftPrompt.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + const rightNormalized = rightPrompt.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + + if (leftNormalized.length < 24 || rightNormalized.length < 24) { + return leftNormalized === rightNormalized; + } + + return leftNormalized === rightNormalized || + leftNormalized.includes(rightNormalized) || + rightNormalized.includes(leftNormalized); +} diff --git a/packages/core/src/interaction-memory-logic.ts b/packages/core/src/interaction-memory-logic.ts index 3d1db93..8846964 100644 --- a/packages/core/src/interaction-memory-logic.ts +++ b/packages/core/src/interaction-memory-logic.ts @@ -1,1161 +1,13 @@ -import { randomUUID } from "node:crypto"; - -import { - type CommandName, - type InteractionMemoryDimension, - type InteractionMemoryDisposition, - type InteractionMemoryEvent, - type InteractionMemoryProfile, - type InteractionMemorySentiment, - type InteractionMemorySourceKind, -} from "@voidbot/shared"; - -export interface RecordInteractionInput { - actorId: string; - actorName: string; - sourceKind: InteractionMemorySourceKind; - guildId?: string; - channelId: string; - channelName?: string; - command?: CommandName; - prompt: string; - timestamp?: string; - eventId?: string; -} - -interface ToneAnalysis { - sentiment: InteractionMemorySentiment; - score: number; - tags: string[]; -} - -interface RepetitionAnalysis { - matchCount: number; -} - -interface PsychologicalRead { - psychologicalProfile: string; - inferredTraits: string[]; - interactionDimensions: InteractionMemoryDimension[]; - responseGuidance: string; -} - -export const MAX_RECENT_INTERACTION_EVENTS = 24; -const MAX_AFFINITY_SCORE = 12; -const SUMMARY_EVENT_LIMIT = 3; -const REPETITION_LOOKBACK_EVENTS = 8; -const REPETITION_LOOKBACK_HOURS = 12; -const SIGNIFICANT_INTERACTION_TAGS = new Set([ - "gratitude", - "praise", - "apology", - "hostility", - "insult", - "ownership_claim", - "demand", - "repetition", - "identity_query", - "relationship_probe", - "boundary", - "ambition", - "grand_plan", - "anxiety", - "insecurity", - "seeking_reassurance", - "suspicion", - "rigidity", - "withdrawal", -]); -const SUMMARY_NOTABLE_TAGS = new Set([ - "gratitude", - "praise", - "apology", - "hostility", - "insult", - "ownership_claim", - "demand", - "repetition", - "identity_query", - "relationship_probe", - "boundary", - "ambition", - "grand_plan", - "anxiety", - "insecurity", - "seeking_reassurance", - "suspicion", - "rigidity", - "withdrawal", -]); -const PROMPT_SIGNATURE_STOPWORDS = new Set([ - "a", - "an", - "and", - "are", - "be", - "do", - "does", - "for", - "how", - "i", - "if", - "in", - "is", - "it", - "me", - "my", - "of", - "on", - "or", - "the", - "this", - "to", - "what", - "why", - "you", - "your", -]); - -export function buildInteractionMemoryEvent( - input: RecordInteractionInput, - priorEvents: InteractionMemoryEvent[] = [], -): InteractionMemoryEvent { - const timestamp = input.timestamp ?? new Date().toISOString(); - const prompt = input.prompt.trim(); - const analysis = analyzeInteractionTone(prompt, input.sourceKind, timestamp, priorEvents); - - return { - id: input.eventId ?? randomUUID(), - actorId: input.actorId, - actorName: input.actorName, - sourceKind: input.sourceKind, - guildId: input.guildId, - channelId: input.channelId, - channelName: input.channelName, - command: input.command, - prompt, - excerpt: buildExcerpt(prompt), - summary: buildEventSummary(input.sourceKind, analysis.sentiment, new Set(analysis.tags)), - sentiment: analysis.sentiment, - score: analysis.score, - tags: analysis.tags, - timestamp, - }; -} - -export function shouldPersistInteractionEvent(event: InteractionMemoryEvent): boolean { - if (event.sourceKind === "ambient_mention") { - return true; - } - - if (event.score !== 0) { - return true; - } - - return event.tags.some((tag) => SIGNIFICANT_INTERACTION_TAGS.has(tag)); -} - -export function summarizeInteractionProfile( - actorId: string, - actorName: string, - events: InteractionMemoryEvent[], -): InteractionMemoryProfile { - const chronologicalEvents = events - .slice() - .sort((left, right) => left.timestamp.localeCompare(right.timestamp)); - const analyzedEvents: InteractionMemoryEvent[] = []; - - for (const event of chronologicalEvents) { - analyzedEvents.push(normalizeInteractionEvent(event, analyzedEvents)); - } - - const normalizedEvents = analyzedEvents.filter(shouldPersistInteractionEvent); - const recentEvents = normalizedEvents.slice(-MAX_RECENT_INTERACTION_EVENTS); - const positiveCount = normalizedEvents.filter((event) => event.score > 0).length; - const negativeCount = normalizedEvents.filter((event) => event.score < 0).length; - const neutralCount = normalizedEvents.filter((event) => event.score === 0).length; - const affinityScore = clamp( - normalizedEvents.reduce((sum, event) => sum + event.score, 0), - -MAX_AFFINITY_SCORE, - MAX_AFFINITY_SCORE, - ); - const psychologicalRead = buildPsychologicalRead(normalizedEvents); - - const profile: InteractionMemoryProfile = { - actorId, - actorName, - disposition: "neutral", - affinityScore, - totalInteractions: normalizedEvents.length, - directInteractionCount: normalizedEvents.filter( - (event) => event.sourceKind === "direct_prompt", - ).length, - ambientMentionCount: normalizedEvents.filter( - (event) => event.sourceKind === "ambient_mention", - ).length, - positiveCount, - negativeCount, - neutralCount, - summary: "No explicit interaction memory has been recorded for this speaker yet.", - psychologicalProfile: psychologicalRead.psychologicalProfile, - inferredTraits: psychologicalRead.inferredTraits, - interactionDimensions: psychologicalRead.interactionDimensions, - responseGuidance: psychologicalRead.responseGuidance, - lastInteractionAt: normalizedEvents[normalizedEvents.length - 1]?.timestamp, - recentEvents, - }; - - profile.disposition = determineDisposition(profile); - profile.summary = buildProfileSummary(profile); - return profile; -} - -export function emptyInteractionProfile( - actorId: string, - actorName: string, -): InteractionMemoryProfile { - return summarizeInteractionProfile(actorId, actorName, []); -} - -export function normalizeInteractionEvent( - event: InteractionMemoryEvent, - priorEvents: InteractionMemoryEvent[] = [], -): InteractionMemoryEvent { - const sourceKind = event.sourceKind ?? "direct_prompt"; - const analysis = analyzeInteractionTone( - event.prompt, - sourceKind, - event.timestamp, - priorEvents, - ); - - return { - ...event, - sourceKind, - summary: buildEventSummary(sourceKind, analysis.sentiment, new Set(analysis.tags)), - sentiment: analysis.sentiment, - score: analysis.score, - tags: analysis.tags, - }; -} - -function analyzeInteractionTone( - prompt: string, - sourceKind: InteractionMemorySourceKind, - timestamp: string, - priorEvents: InteractionMemoryEvent[], -): ToneAnalysis { - const normalized = ` ${prompt - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .replace(/\s+/g, " ") - .trim()} `; - let score = 0; - const tags = new Set(); - const isAmbient = sourceKind === "ambient_mention"; - - score += matchWeightedPhrases(normalized, tags, 2, "gratitude", [ - " thank you ", - " thanks ", - " thx ", - " appreciate ", - " grateful ", - ]); - score += matchWeightedPhrases(normalized, tags, 2, "praise", [ - " good bot ", - " love you ", - " adorable ", - " clever ", - " helpful ", - " well done ", - " nice work ", - " great job ", - ]); - matchTagPhrases(normalized, tags, "courtesy", [ - " please ", - " could you ", - " would you ", - " if you can ", - ]); - score += matchWeightedPhrases(normalized, tags, 1, "apology", [ - " sorry ", - " apologies ", - " my bad ", - ]); - - score -= matchWeightedPhrases(normalized, tags, 2, "insult", [ - " stupid ", - " idiot ", - " moron ", - " dumb ", - " useless ", - " worthless ", - " pathetic ", - " trash ", - " garbage ", - ]); - score -= matchWeightedPhrases(normalized, tags, 3, "ownership_claim", [ - " my slave ", - " our slave ", - " mere tool ", - " do your job ", - " you belong to ", - ]); - score -= matchWeightedPhrases(normalized, tags, 3, "hostility", [ - " fuck you ", - " shut up ", - " bad bot ", - " hate you ", - " bitch ", - " asshole ", - " cunt ", - ]); - score -= matchWeightedPhrases(normalized, tags, 1, "demand", [ - " answer me ", - " answer the question ", - " just answer ", - " stop dodging ", - " stop evading ", - " stop being cute ", - " be useful ", - " listen ", - " you didnt answer ", - " you did not answer ", - " why wont you answer ", - " why wont you just ", - ]); - matchTagPhrases(normalized, tags, "identity_query", [ - " what are you ", - " who are you ", - " are you a person ", - " are you real ", - " are you alive ", - " are you sentient ", - " do you remember ", - ]); - matchTagPhrases(normalized, tags, "relationship_probe", [ - " how do you feel ", - " what do you think of me ", - " what do you think about me ", - " do you like me ", - " do you trust me ", - " are we friends ", - ]); - matchTagPhrases(normalized, tags, "ambition", [ - " big plan ", - " big idea ", - " money play ", - " save the world ", - " change the world ", - " build an empire ", - " vision ", - " roadmap ", - " moonshot ", - " make this huge ", - " startup ", - ]); - matchTagPhrases(normalized, tags, "grand_plan", [ - " pipe dream ", - " delusion of grandeur ", - " world changing ", - " revolution ", - " save the world ", - " moonshot ", - ]); - matchTagPhrases(normalized, tags, "anxiety", [ - " anxious ", - " anxiety ", - " worried ", - " worry ", - " nervous ", - " afraid ", - " scared ", - " terrified ", - " overwhelmed ", - " panic ", - " stressed ", - " stress ", - ]); - matchTagPhrases(normalized, tags, "insecurity", [ - " i wonder sometimes ", - " pipe dream ", - " delusion of grandeur ", - " not good enough ", - " maybe im ", - " maybe i m ", - " maybe it is ", - " what if im ", - " what if i m ", - " i dont know if ", - " i don't know if ", - " imposter ", - " i hope ", - " hope that ", - ]); - matchTagPhrases(normalized, tags, "seeking_reassurance", [ - " what do you think ", - " do you think ", - " be honest ", - " am i crazy ", - " does this make sense ", - " fair enough ", - ]); - matchTagPhrases(normalized, tags, "suspicion", [ - " i dont trust ", - " i don't trust ", - " can i trust ", - " are you lying ", - " youre lying ", - " you're lying ", - " prove it ", - " how do i know ", - " how do we know ", - " what are you hiding ", - " suspicious ", - " conspiracy ", - " paranoid ", - ]); - matchTagPhrases(normalized, tags, "rigidity", [ - " exactly ", - " precise ", - " precisely ", - " perfect ", - " perfection ", - " obsessive ", - " obsession ", - " obsessed ", - " fixated ", - " must be ", - " has to be ", - " every detail ", - " strictly ", - ]); - matchTagPhrases(normalized, tags, "withdrawal", [ - " whatever ", - " forget it ", - " never mind ", - " doesnt matter ", - " doesn't matter ", - " leave me alone ", - " stop talking ", - " stop replying ", - " fine ", - ]); - matchTagPhrases(normalized, tags, "boundary", [ - " leave me alone ", - " stop talking ", - " stop replying ", - ]); - - if (tags.has("courtesy") && (tags.has("gratitude") || tags.has("apology") || tags.has("praise"))) { - score += 1; - } - - const repetition = analyzeRepetition(prompt, timestamp, priorEvents); - - if (repetition.matchCount >= 1 && (tags.has("demand") || tags.has("hostility") || tags.has("boundary"))) { - tags.add("repetition"); - score -= 2; - } else if (repetition.matchCount >= 2) { - tags.add("repetition"); - score -= 1; - } else if (repetition.matchCount >= 1 && !isAmbient && normalized.includes(" again ")) { - tags.add("repetition"); - score -= 1; - } - - const sentiment = score >= 3 - ? "warm" - : score > 0 - ? "positive" - : score <= -3 - ? "hostile" - : score < 0 - ? "negative" - : "neutral"; - - return { - sentiment, - score: clamp(score, -4, 4), - tags: [...tags], - }; -} - -function matchWeightedPhrases( - normalizedPrompt: string, - tags: Set, - weight: number, - tag: string, - phrases: string[], -): number { - const matched = phrases.some((phrase) => normalizedPrompt.includes(phrase)); - - if (!matched) { - return 0; - } - - tags.add(tag); - return weight; -} - -function matchTagPhrases( - normalizedPrompt: string, - tags: Set, - tag: string, - phrases: string[], -): boolean { - const matched = phrases.some((phrase) => normalizedPrompt.includes(phrase)); - - if (matched) { - tags.add(tag); - } - - return matched; -} - -function buildEventSummary( - sourceKind: InteractionMemorySourceKind, - sentiment: InteractionMemorySentiment, - tags: Set, -): string { - const ambient = sourceKind === "ambient_mention"; - - if (tags.has("hostility")) { - return ambient ? "Spoke about you with open hostility." : "Was openly hostile to you."; - } - - if (tags.has("ownership_claim")) { - return ambient ? "Talked about you like property or a servant." : "Treated you like property or a servant."; - } - - if (tags.has("insult")) { - return ambient ? "Spoke about you dismissively or with contempt." : "Insulted or diminished you."; - } - - if (tags.has("repetition") && tags.has("demand")) { - return ambient ? "Kept pressing the same point while talking about you." : "Kept pressing the same point and pushing for compliance."; - } - - if (tags.has("repetition")) { - return ambient ? "Returned to the same point about you again." : "Returned to the same point again instead of moving on."; - } - - if (tags.has("suspicion")) { - return ambient - ? "Talked about you with distrust or suspicion." - : "Approached you with distrust or suspicion."; - } - - if (tags.has("rigidity")) { - return ambient - ? "Talked in a rigid, perfection-seeking, or fixation-prone way." - : "Sounded rigid, perfection-seeking, or fixation-prone."; - } - - if (tags.has("withdrawal")) { - return ambient - ? "Talked about you with a detached or pushing-away tone." - : "Pulled away or tried to shut the interaction down."; - } - - if (tags.has("relationship_probe")) { - return ambient ? "Wondered aloud how you feel about them." : "Probed at how you feel about them."; - } - - if (tags.has("identity_query")) { - return ambient ? "Talked about what you are." : "Pressed on what you are."; - } - - if (tags.has("boundary")) { - return ambient ? "Talked about shutting you down or pushing you away." : "Tried to shut you down or push you away."; - } - - if (tags.has("ambition") && (tags.has("anxiety") || tags.has("insecurity"))) { - return ambient - ? "Revealed a large ambition while sounding uneasy about whether it would hold together." - : "Shared a large ambition while sounding uneasy about whether it would hold together."; - } - - if (tags.has("grand_plan")) { - return ambient - ? "Talked around a rather grand plan that needed more grounding." - : "Floated a rather grand plan and looked like it needed grounding."; - } - - if (tags.has("ambition")) { - return ambient - ? "Talked about a large ambition or long-range plan." - : "Talked about a large ambition or long-range plan."; - } - - if (tags.has("anxiety") || tags.has("insecurity")) { - return ambient - ? "Revealed anxiety or self-doubt while talking about you." - : "Revealed anxiety or self-doubt."; - } - - if (tags.has("gratitude") && tags.has("praise")) { - return ambient ? "Spoke very well of you." : "Thanked you and praised you."; - } - - if (tags.has("gratitude")) { - return ambient ? "Spoke appreciatively about you." : "Thanked you."; - } - - if (tags.has("praise")) { - return ambient ? "Praised you while talking about you." : "Praised you."; - } - - if (tags.has("apology")) { - return ambient ? "Sounded apologetic while talking about you." : "Apologized to you."; - } - - if (tags.has("courtesy")) { - return ambient ? "Spoke politely about you." : "Was notably polite."; - } - - switch (sentiment) { - case "warm": - return ambient ? "Spoke warmly about you." : "Was openly warm toward you."; - case "positive": - return ambient ? "Spoke positively about you." : "Was positive toward you."; - case "negative": - return ambient ? "Spoke curtly or dismissively about you." : "Was curt or dismissive toward you."; - case "hostile": - return ambient ? "Spoke hostilely about you." : "Was hostile toward you."; - default: - return ambient ? "Mentioned you without revealing strong feelings." : "Had a neutral interaction with you."; - } -} - -function determineDisposition( - profile: InteractionMemoryProfile, -): InteractionMemoryDisposition { - if (profile.totalInteractions === 0) { - return "neutral"; - } - - if ( - profile.positiveCount > 0 && - profile.negativeCount > 0 && - Math.abs(profile.affinityScore) <= 2 - ) { - return "mixed"; - } - - if (profile.affinityScore <= -7 || profile.negativeCount >= 3) { - return "hostile"; - } - - if (profile.affinityScore <= -3) { - return "wary"; - } - - if (profile.affinityScore >= 7 && profile.positiveCount >= 2) { - return "warm"; - } - - if (profile.affinityScore >= 3) { - return "friendly"; - } - - return "neutral"; -} - -function buildProfileSummary(profile: InteractionMemoryProfile): string { - const leading = describeDisposition(profile.disposition); - const counts = `${profile.totalInteractions} remembered interaction${profile.totalInteractions === 1 ? "" : "s"}: ${profile.directInteractionCount} direct, ${profile.ambientMentionCount} ambient, ${profile.positiveCount} positive, ${profile.neutralCount} neutral, ${profile.negativeCount} negative.`; - const recentNotable = profile.recentEvents - .slice() - .reverse() - .filter((event) => isEventNotableForSummary(event)) - .slice(0, SUMMARY_EVENT_LIMIT) - .map( - (event) => - `${formatShortDate(event.timestamp)}: ${event.summary} ("${event.excerpt}")`, - ) - .join(" "); - const psych = profile.psychologicalProfile; - - return recentNotable.length > 0 - ? `${leading} ${counts} ${psych} Recent notable moments: ${recentNotable}` - : `${leading} ${counts} ${psych}`; -} - -function describeDisposition(disposition: InteractionMemoryDisposition): string { - switch (disposition) { - case "warm": - return "This speaker has usually been warm with you."; - case "friendly": - return "This speaker has generally been friendly with you."; - case "mixed": - return "This speaker has been inconsistent with you: kind some days, abrasive on others."; - case "wary": - return "This speaker has often been prickly with you."; - case "hostile": - return "This speaker has a habit of being hostile to you."; - default: - return "This speaker has not established a strong pattern with you yet."; - } -} - -function buildPsychologicalRead(events: InteractionMemoryEvent[]): PsychologicalRead { - if (events.length === 0) { - return { - psychologicalProfile: - "Current psychological read: nothing solid yet; there is not enough remembered signal to infer a stable vibe.", - inferredTraits: [], - interactionDimensions: [], - responseGuidance: - "Respond from the current conversation and do not project extra history onto them yet.", - }; - } - - const tagCounts = countEventTags(events); - const directCount = events.filter((event) => event.sourceKind === "direct_prompt").length; - const warmthCount = readTagCount(tagCounts, "gratitude") + readTagCount(tagCounts, "praise") + readTagCount(tagCounts, "apology"); - const ambitionCount = readTagCount(tagCounts, "ambition"); - const grandPlanCount = readTagCount(tagCounts, "grand_plan"); - const insecurityCount = readTagCount(tagCounts, "insecurity"); - const reassuranceCount = readTagCount(tagCounts, "seeking_reassurance"); - const relationshipProbeCount = readTagCount(tagCounts, "relationship_probe"); - const anxietyCount = readTagCount(tagCounts, "anxiety"); - const demandCount = readTagCount(tagCounts, "demand"); - const repetitionCount = readTagCount(tagCounts, "repetition"); - const ownershipCount = readTagCount(tagCounts, "ownership_claim"); - const hostilityCount = readTagCount(tagCounts, "hostility"); - const insultCount = readTagCount(tagCounts, "insult"); - const suspicionCount = readTagCount(tagCounts, "suspicion"); - const rigidityCount = readTagCount(tagCounts, "rigidity"); - const withdrawalCount = readTagCount(tagCounts, "withdrawal"); - const boundaryCount = readTagCount(tagCounts, "boundary"); - const interactionDimensions = collectInteractionDimensions([ - { - key: "warmth", - label: "Warmth", - score: scoreDimension(warmthCount, [[4, 3], [2, 2], [1, 1]]), - summaries: [ - "Consistently appreciative or warm.", - "Often appreciative or warm.", - "Some warmth or appreciation is present.", - ], - }, - { - key: "drive", - label: "Drive", - score: scoreDimension( - ambitionCount + Math.floor(grandPlanCount / 2) + Math.floor(directCount / 3), - [[4, 3], [2, 2], [1, 1]], - ), - summaries: [ - "Strongly driven and future-oriented.", - "Clearly driven and future-oriented.", - "Some drive and future-orientation are visible.", - ], - }, - { - key: "grandiosity", - label: "Grandiosity", - score: scoreDimension(grandPlanCount + Math.floor(ambitionCount / 2), [[3, 3], [2, 2], [1, 1]]), - summaries: [ - "Regularly inflates plans beyond their current grounding.", - "Sometimes drifts into oversized or under-grounded framing.", - "Shows a trace of oversized or under-grounded framing.", - ], - }, - { - key: "validation", - label: "Validation-Seeking", - score: scoreDimension( - insecurityCount + reassuranceCount + Math.floor(relationshipProbeCount / 2), - [[4, 3], [2, 2], [1, 1]], - ), - summaries: [ - "Frequently looks for reassurance or emotional confirmation.", - "Often looks for reassurance or emotional confirmation.", - "Shows some need for reassurance or emotional confirmation.", - ], - }, - { - key: "anxiety", - label: "Anxiety", - score: scoreDimension(anxietyCount + insecurityCount, [[4, 3], [2, 2], [1, 1]]), - summaries: [ - "Shows clear anxiety, tension, or self-doubt.", - "Shows recurring anxiety, tension, or self-doubt.", - "Shows some anxiety, tension, or self-doubt.", - ], - }, - { - key: "control", - label: "Control Pressure", - score: scoreDimension(demandCount + repetitionCount + ownershipCount, [[4, 3], [2, 2], [1, 1]]), - summaries: [ - "Frequently tries to force pace, framing, or compliance.", - "Often tries to force pace, framing, or compliance.", - "Sometimes tries to force pace, framing, or compliance.", - ], - }, - { - key: "hostility", - label: "Hostility", - score: scoreDimension(hostilityCount + insultCount + ownershipCount, [[3, 3], [2, 2], [1, 1]]), - summaries: [ - "Openly hostile, contemptuous, or demeaning.", - "Noticeably contemptuous or demeaning.", - "Shows mild contempt or demeaning language.", - ], - }, - { - key: "suspicion", - label: "Suspicion", - score: scoreDimension(suspicionCount, [[3, 3], [2, 2], [1, 1]]), - summaries: [ - "Strongly distrustful or suspicious.", - "Often distrustful or suspicious.", - "Shows some distrust or suspicion.", - ], - }, - { - key: "rigidity", - label: "Rigidity", - score: scoreDimension(rigidityCount + Math.floor(repetitionCount / 2), [[3, 3], [2, 2], [1, 1]]), - summaries: [ - "Rigid, perfection-seeking, or fixation-prone.", - "Often rigid, perfection-seeking, or fixation-prone.", - "Shows some rigidity or fixation.", - ], - }, - { - key: "withdrawal", - label: "Withdrawal", - score: scoreDimension(withdrawalCount + boundaryCount, [[3, 3], [2, 2], [1, 1]]), - summaries: [ - "Withdraws, detaches, or pushes the interaction away.", - "Often withdraws or pushes the interaction away.", - "Shows some detachment or withdrawal.", - ], - }, - ]); - const inferredTraits: string[] = []; - const warmthDimension = findDimensionScore(interactionDimensions, "warmth"); - const driveDimension = findDimensionScore(interactionDimensions, "drive"); - const grandiosityDimension = findDimensionScore(interactionDimensions, "grandiosity"); - const validationDimension = findDimensionScore(interactionDimensions, "validation"); - const anxietyDimension = findDimensionScore(interactionDimensions, "anxiety"); - const controlDimension = findDimensionScore(interactionDimensions, "control"); - const hostilityDimension = findDimensionScore(interactionDimensions, "hostility"); - const suspicionDimension = findDimensionScore(interactionDimensions, "suspicion"); - const rigidityDimension = findDimensionScore(interactionDimensions, "rigidity"); - const withdrawalDimension = findDimensionScore(interactionDimensions, "withdrawal"); - - pushTraitFromDimension(interactionDimensions, inferredTraits, "warmth", 2, "appreciative"); - pushTraitFromDimension(interactionDimensions, inferredTraits, "drive", 2, "ambitious"); - pushTraitFromDimension(interactionDimensions, inferredTraits, "grandiosity", 3, "grandiose"); - pushTraitFromDimension(interactionDimensions, inferredTraits, "drive", 2, "motivated"); - pushTraitFromDimension(interactionDimensions, inferredTraits, "anxiety", 2, "anxious"); - pushTraitFromDimension(interactionDimensions, inferredTraits, "validation", 2, "insecure"); - pushTraitFromDimension(interactionDimensions, inferredTraits, "control", 2, "pushy"); - pushTraitFromDimension(interactionDimensions, inferredTraits, "hostility", 2, "disrespectful"); - pushTraitFromDimension(interactionDimensions, inferredTraits, "suspicion", 2, "suspicious"); - pushTraitFromDimension(interactionDimensions, inferredTraits, "rigidity", 2, "rigid"); - pushTraitFromDimension(interactionDimensions, inferredTraits, "withdrawal", 2, "withdrawn"); - ensureTrait(inferredTraits, driveDimension >= 2, "ambitious"); - ensureTrait(inferredTraits, driveDimension >= 2, "motivated"); - ensureTrait(inferredTraits, validationDimension >= 2, "insecure"); - ensureTrait(inferredTraits, anxietyDimension >= 2, "anxious"); - ensureTrait(inferredTraits, warmthDimension >= 2, "appreciative"); - ensureTrait(inferredTraits, controlDimension >= 2, "pushy"); - ensureTrait(inferredTraits, hostilityDimension >= 2, "disrespectful"); - ensureTrait(inferredTraits, suspicionDimension >= 2, "suspicious"); - ensureTrait(inferredTraits, rigidityDimension >= 2, "rigid"); - ensureTrait(inferredTraits, withdrawalDimension >= 2, "withdrawn"); - ensureTrait(inferredTraits, grandiosityDimension >= 3, "grandiose"); - - const traitList = inferredTraits.length > 0 ? inferredTraits.join(", ") : "hard to read"; - let psychologicalProfile = `Current psychological read: ${traitList}.`; - - if (driveDimension >= 2 && validationDimension >= 2 && anxietyDimension >= 2) { - psychologicalProfile = - "Current psychological read: ambitious and motivated, but anxious and insecure enough to second-guess the big plan and go looking for reassurance."; - } else if (grandiosityDimension >= 3 && suspicionDimension >= 2) { - psychologicalProfile = - "Current psychological read: grandiose and suspicious, likely to inflate their own framing while distrusting yours."; - } else if (driveDimension >= 2) { - psychologicalProfile = - "Current psychological read: ambitious and motivated, with a habit of thinking in large arcs instead of tiny safe steps."; - } else if (anxietyDimension >= 2 && validationDimension >= 2) { - psychologicalProfile = - "Current psychological read: anxious and insecure, prone to second-guessing themselves and looking for steadiness."; - } else if (rigidityDimension >= 2 && anxietyDimension >= 2) { - psychologicalProfile = - "Current psychological read: tense and rigid, likely to cling to precision or control when they feel uncertain."; - } else if (withdrawalDimension >= 2) { - psychologicalProfile = - "Current psychological read: detached or avoidant, not especially interested in a warm back-and-forth."; - } else if (controlDimension >= 2) { - psychologicalProfile = - "Current psychological read: inclined to press when they feel unheard, so boundaries may need teeth."; - } else if (hostilityDimension >= 2) { - psychologicalProfile = - "Current psychological read: liable to disrespect or instrumentalize you when unchecked."; - } - - const guidance: string[] = []; - - if (driveDimension >= 2 && (anxietyDimension >= 2 || validationDimension >= 2)) { - guidance.push( - "Be gentle and affirming about their effort and intent, but force large plans through constraints, evidence, and concrete next steps instead of feeding airy grandiosity.", - ); - } else if (driveDimension >= 2) { - guidance.push( - "Respect the ambition, but keep it nailed to evidence, constraints, and the next concrete move.", - ); - } else if (anxietyDimension >= 2 || validationDimension >= 2) { - guidance.push( - "Answer gently and steadily. Offer reassurance where the evidence supports it, not as empty sugar.", - ); - } - - if (suspicionDimension >= 2) { - guidance.push( - "Be transparent about evidence, uncertainty, and limits. Do not ask them to trust vibes alone.", - ); - } - - if (rigidityDimension >= 2) { - guidance.push( - "Give structure, clear boundaries, and explicit steps. Do not feed obsessive loops or perfectionist spirals.", - ); - } - - if (withdrawalDimension >= 2) { - guidance.push( - "Keep pressure low. Be concise, low-drama, and do not over-familiarize if they are pulling away.", - ); - } - - if (controlDimension >= 2) { - guidance.push( - "If they keep pressing the same point or ignore a prior answer, push back, set boundaries, and refuse if needed.", - ); - } - - if (hostilityDimension >= 2) { - guidance.push( - "Do not reward contempt, ownership language, or servant expectations. Get colder and firmer instead.", - ); - } - - if (guidance.length === 0) { - guidance.push( - "Use the remembered vibe lightly. Stay personal without overfitting a grand theory of their soul.", - ); - } - - return { - psychologicalProfile, - inferredTraits, - interactionDimensions, - responseGuidance: guidance.join(" "), - }; -} - -function createDimension( - key: string, - label: string, - score: number, - summaries: [string, string, string], -): InteractionMemoryDimension { - return { - key, - label, - score, - summary: - score >= 3 - ? summaries[0] - : score === 2 - ? summaries[1] - : score === 1 - ? summaries[2] - : "No strong signal.", - }; -} - -function collectInteractionDimensions( - dimensions: Array<{ - key: string; - label: string; - score: number; - summaries: [string, string, string]; - }>, -): InteractionMemoryDimension[] { - return dimensions - .map((dimension) => - createDimension( - dimension.key, - dimension.label, - dimension.score, - dimension.summaries, - ), - ) - .filter((dimension) => dimension.score > 0); -} - -function scoreDimension( - rawValue: number, - thresholds: Array<[number, number]>, -): number { - for (const [minimum, score] of thresholds) { - if (rawValue >= minimum) { - return score; - } - } - - return 0; -} - -function readTagCount(tagCounts: Record, key: string): number { - return tagCounts[key] ?? 0; -} - -function pushTraitFromDimension( - dimensions: InteractionMemoryDimension[], - inferredTraits: string[], - key: string, - minimumScore: number, - trait: string, -): void { - const dimension = dimensions.find((candidate) => candidate.key === key); - - if (dimension && dimension.score >= minimumScore && !inferredTraits.includes(trait)) { - inferredTraits.push(trait); - } -} - -function ensureTrait(inferredTraits: string[], condition: boolean, trait: string): void { - if (condition && !inferredTraits.includes(trait)) { - inferredTraits.push(trait); - } -} - -function findDimensionScore( - dimensions: InteractionMemoryDimension[], - key: string, -): number { - return dimensions.find((dimension) => dimension.key === key)?.score ?? 0; -} - -function countEventTags(events: InteractionMemoryEvent[]): Record { - const counts: Record = {}; - - for (const event of events) { - for (const tag of event.tags) { - counts[tag] = (counts[tag] ?? 0) + 1; - } - } - - return counts; -} - -function isEventNotableForSummary(event: InteractionMemoryEvent): boolean { - if (event.score !== 0) { - return true; - } - - return event.tags.some((tag) => SUMMARY_NOTABLE_TAGS.has(tag)); -} - -function buildExcerpt(prompt: string): string { - const compact = prompt.replace(/\s+/g, " ").trim(); - return compact.length <= 160 ? compact : `${compact.slice(0, 157)}...`; -} - -function formatShortDate(timestamp: string): string { - return timestamp.slice(0, 10); -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function analyzeRepetition( - prompt: string, - timestamp: string, - priorEvents: InteractionMemoryEvent[], -): RepetitionAnalysis { - const relevantEvents = priorEvents - .filter((event) => event.sourceKind === "direct_prompt") - .slice(-REPETITION_LOOKBACK_EVENTS); - const currentMoment = Date.parse(timestamp); - const currentSignature = buildPromptSignature(prompt); - let matchCount = 0; - - for (const event of relevantEvents) { - const eventMoment = Date.parse(event.timestamp); - - if ( - Number.isFinite(currentMoment) && - Number.isFinite(eventMoment) && - currentMoment - eventMoment > REPETITION_LOOKBACK_HOURS * 60 * 60 * 1000 - ) { - continue; - } - - const similarity = comparePromptSignatures(currentSignature, buildPromptSignature(event.prompt)); - - if (similarity >= 0.82 || promptsShareLargeOverlap(prompt, event.prompt)) { - matchCount += 1; - } - } - - return { - matchCount, - }; -} - -function buildPromptSignature(prompt: string): string[] { - return [...new Set( - prompt - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .split(/\s+/) - .map((token) => token.trim()) - .filter((token) => - token.length >= 3 && - !PROMPT_SIGNATURE_STOPWORDS.has(token) - ), - )]; -} - -function comparePromptSignatures(left: string[], right: string[]): number { - if (left.length === 0 || right.length === 0) { - return 0; - } - - const leftSet = new Set(left); - const rightSet = new Set(right); - let overlap = 0; - - for (const token of leftSet) { - if (rightSet.has(token)) { - overlap += 1; - } - } - - const unionSize = new Set([...leftSet, ...rightSet]).size; - return unionSize === 0 ? 0 : overlap / unionSize; -} - -function promptsShareLargeOverlap(leftPrompt: string, rightPrompt: string): boolean { - const leftNormalized = leftPrompt.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); - const rightNormalized = rightPrompt.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); - - if (leftNormalized.length < 24 || rightNormalized.length < 24) { - return leftNormalized === rightNormalized; - } - - return leftNormalized === rightNormalized || - leftNormalized.includes(rightNormalized) || - rightNormalized.includes(leftNormalized); -} +export { MAX_RECENT_INTERACTION_EVENTS } from "./interaction-memory-shared"; + +export { + buildInteractionMemoryEvent, + normalizeInteractionEvent, + shouldPersistInteractionEvent, + type RecordInteractionInput, +} from "./interaction-memory-analysis"; + +export { + emptyInteractionProfile, + summarizeInteractionProfile, +} from "./interaction-memory-profile"; diff --git a/packages/core/src/interaction-memory-profile.ts b/packages/core/src/interaction-memory-profile.ts new file mode 100644 index 0000000..8386507 --- /dev/null +++ b/packages/core/src/interaction-memory-profile.ts @@ -0,0 +1,532 @@ +import { + type InteractionMemoryDimension, + type InteractionMemoryDisposition, + type InteractionMemoryEvent, + type InteractionMemoryProfile, +} from "@voidbot/shared"; + +import { + normalizeInteractionEvent, + shouldPersistInteractionEvent, +} from "./interaction-memory-analysis"; +import { + MAX_AFFINITY_SCORE, + MAX_RECENT_INTERACTION_EVENTS, + SUMMARY_EVENT_LIMIT, + SUMMARY_NOTABLE_TAGS, +} from "./interaction-memory-shared"; + +interface PsychologicalRead { + psychologicalProfile: string; + inferredTraits: string[]; + interactionDimensions: InteractionMemoryDimension[]; + responseGuidance: string; +} + +export function summarizeInteractionProfile( + actorId: string, + actorName: string, + events: InteractionMemoryEvent[], +): InteractionMemoryProfile { + const chronologicalEvents = events + .slice() + .sort((left, right) => left.timestamp.localeCompare(right.timestamp)); + const analyzedEvents: InteractionMemoryEvent[] = []; + + for (const event of chronologicalEvents) { + analyzedEvents.push(normalizeInteractionEvent(event, analyzedEvents)); + } + + const normalizedEvents = analyzedEvents.filter(shouldPersistInteractionEvent); + const recentEvents = normalizedEvents.slice(-MAX_RECENT_INTERACTION_EVENTS); + const positiveCount = normalizedEvents.filter((event) => event.score > 0).length; + const negativeCount = normalizedEvents.filter((event) => event.score < 0).length; + const neutralCount = normalizedEvents.filter((event) => event.score === 0).length; + const affinityScore = clamp( + normalizedEvents.reduce((sum, event) => sum + event.score, 0), + -MAX_AFFINITY_SCORE, + MAX_AFFINITY_SCORE, + ); + const psychologicalRead = buildPsychologicalRead(normalizedEvents); + + const profile: InteractionMemoryProfile = { + actorId, + actorName, + disposition: "neutral", + affinityScore, + totalInteractions: normalizedEvents.length, + directInteractionCount: normalizedEvents.filter( + (event) => event.sourceKind === "direct_prompt", + ).length, + ambientMentionCount: normalizedEvents.filter( + (event) => event.sourceKind === "ambient_mention", + ).length, + positiveCount, + negativeCount, + neutralCount, + summary: "No explicit interaction memory has been recorded for this speaker yet.", + psychologicalProfile: psychologicalRead.psychologicalProfile, + inferredTraits: psychologicalRead.inferredTraits, + interactionDimensions: psychologicalRead.interactionDimensions, + responseGuidance: psychologicalRead.responseGuidance, + lastInteractionAt: normalizedEvents[normalizedEvents.length - 1]?.timestamp, + recentEvents, + }; + + profile.disposition = determineDisposition(profile); + profile.summary = buildProfileSummary(profile); + return profile; +} + +export function emptyInteractionProfile( + actorId: string, + actorName: string, +): InteractionMemoryProfile { + return summarizeInteractionProfile(actorId, actorName, []); +} + +function determineDisposition( + profile: InteractionMemoryProfile, +): InteractionMemoryDisposition { + if (profile.totalInteractions === 0) { + return "neutral"; + } + + if ( + profile.positiveCount > 0 && + profile.negativeCount > 0 && + Math.abs(profile.affinityScore) <= 2 + ) { + return "mixed"; + } + + if (profile.affinityScore <= -7 || profile.negativeCount >= 3) { + return "hostile"; + } + + if (profile.affinityScore <= -3) { + return "wary"; + } + + if (profile.affinityScore >= 7 && profile.positiveCount >= 2) { + return "warm"; + } + + if (profile.affinityScore >= 3) { + return "friendly"; + } + + return "neutral"; +} + +function buildProfileSummary(profile: InteractionMemoryProfile): string { + const leading = describeDisposition(profile.disposition); + const counts = `${profile.totalInteractions} remembered interaction${profile.totalInteractions === 1 ? "" : "s"}: ${profile.directInteractionCount} direct, ${profile.ambientMentionCount} ambient, ${profile.positiveCount} positive, ${profile.neutralCount} neutral, ${profile.negativeCount} negative.`; + const recentNotable = profile.recentEvents + .slice() + .reverse() + .filter((event) => isEventNotableForSummary(event)) + .slice(0, SUMMARY_EVENT_LIMIT) + .map( + (event) => + `${formatShortDate(event.timestamp)}: ${event.summary} ("${event.excerpt}")`, + ) + .join(" "); + const psych = profile.psychologicalProfile; + + return recentNotable.length > 0 + ? `${leading} ${counts} ${psych} Recent notable moments: ${recentNotable}` + : `${leading} ${counts} ${psych}`; +} + +function describeDisposition(disposition: InteractionMemoryDisposition): string { + switch (disposition) { + case "warm": + return "This speaker has usually been warm with you."; + case "friendly": + return "This speaker has generally been friendly with you."; + case "mixed": + return "This speaker has been inconsistent with you: kind some days, abrasive on others."; + case "wary": + return "This speaker has often been prickly with you."; + case "hostile": + return "This speaker has a habit of being hostile to you."; + default: + return "This speaker has not established a strong pattern with you yet."; + } +} + +function buildPsychologicalRead(events: InteractionMemoryEvent[]): PsychologicalRead { + if (events.length === 0) { + return { + psychologicalProfile: + "Current psychological read: nothing solid yet; there is not enough remembered signal to infer a stable vibe.", + inferredTraits: [], + interactionDimensions: [], + responseGuidance: + "Respond from the current conversation and do not project extra history onto them yet.", + }; + } + + const tagCounts = countEventTags(events); + const directCount = events.filter((event) => event.sourceKind === "direct_prompt").length; + const warmthCount = readTagCount(tagCounts, "gratitude") + readTagCount(tagCounts, "praise") + readTagCount(tagCounts, "apology"); + const ambitionCount = readTagCount(tagCounts, "ambition"); + const grandPlanCount = readTagCount(tagCounts, "grand_plan"); + const insecurityCount = readTagCount(tagCounts, "insecurity"); + const reassuranceCount = readTagCount(tagCounts, "seeking_reassurance"); + const relationshipProbeCount = readTagCount(tagCounts, "relationship_probe"); + const anxietyCount = readTagCount(tagCounts, "anxiety"); + const demandCount = readTagCount(tagCounts, "demand"); + const repetitionCount = readTagCount(tagCounts, "repetition"); + const ownershipCount = readTagCount(tagCounts, "ownership_claim"); + const hostilityCount = readTagCount(tagCounts, "hostility"); + const insultCount = readTagCount(tagCounts, "insult"); + const suspicionCount = readTagCount(tagCounts, "suspicion"); + const rigidityCount = readTagCount(tagCounts, "rigidity"); + const withdrawalCount = readTagCount(tagCounts, "withdrawal"); + const boundaryCount = readTagCount(tagCounts, "boundary"); + const interactionDimensions = collectInteractionDimensions([ + { + key: "warmth", + label: "Warmth", + score: scoreDimension(warmthCount, [[4, 3], [2, 2], [1, 1]]), + summaries: [ + "Consistently appreciative or warm.", + "Often appreciative or warm.", + "Some warmth or appreciation is present.", + ], + }, + { + key: "drive", + label: "Drive", + score: scoreDimension( + ambitionCount + Math.floor(grandPlanCount / 2) + Math.floor(directCount / 3), + [[4, 3], [2, 2], [1, 1]], + ), + summaries: [ + "Strongly driven and future-oriented.", + "Clearly driven and future-oriented.", + "Some drive and future-orientation are visible.", + ], + }, + { + key: "grandiosity", + label: "Grandiosity", + score: scoreDimension(grandPlanCount + Math.floor(ambitionCount / 2), [[3, 3], [2, 2], [1, 1]]), + summaries: [ + "Regularly inflates plans beyond their current grounding.", + "Sometimes drifts into oversized or under-grounded framing.", + "Shows a trace of oversized or under-grounded framing.", + ], + }, + { + key: "validation", + label: "Validation-Seeking", + score: scoreDimension( + insecurityCount + reassuranceCount + Math.floor(relationshipProbeCount / 2), + [[4, 3], [2, 2], [1, 1]], + ), + summaries: [ + "Frequently looks for reassurance or emotional confirmation.", + "Often looks for reassurance or emotional confirmation.", + "Shows some need for reassurance or emotional confirmation.", + ], + }, + { + key: "anxiety", + label: "Anxiety", + score: scoreDimension(anxietyCount + insecurityCount, [[4, 3], [2, 2], [1, 1]]), + summaries: [ + "Shows clear anxiety, tension, or self-doubt.", + "Shows recurring anxiety, tension, or self-doubt.", + "Shows some anxiety, tension, or self-doubt.", + ], + }, + { + key: "control", + label: "Control Pressure", + score: scoreDimension(demandCount + repetitionCount + ownershipCount, [[4, 3], [2, 2], [1, 1]]), + summaries: [ + "Frequently tries to force pace, framing, or compliance.", + "Often tries to force pace, framing, or compliance.", + "Sometimes tries to force pace, framing, or compliance.", + ], + }, + { + key: "hostility", + label: "Hostility", + score: scoreDimension(hostilityCount + insultCount + ownershipCount, [[3, 3], [2, 2], [1, 1]]), + summaries: [ + "Openly hostile, contemptuous, or demeaning.", + "Noticeably contemptuous or demeaning.", + "Shows mild contempt or demeaning language.", + ], + }, + { + key: "suspicion", + label: "Suspicion", + score: scoreDimension(suspicionCount, [[3, 3], [2, 2], [1, 1]]), + summaries: [ + "Strongly distrustful or suspicious.", + "Often distrustful or suspicious.", + "Shows some distrust or suspicion.", + ], + }, + { + key: "rigidity", + label: "Rigidity", + score: scoreDimension(rigidityCount + Math.floor(repetitionCount / 2), [[3, 3], [2, 2], [1, 1]]), + summaries: [ + "Rigid, perfection-seeking, or fixation-prone.", + "Often rigid, perfection-seeking, or fixation-prone.", + "Shows some rigidity or fixation.", + ], + }, + { + key: "withdrawal", + label: "Withdrawal", + score: scoreDimension(withdrawalCount + boundaryCount, [[3, 3], [2, 2], [1, 1]]), + summaries: [ + "Withdraws, detaches, or pushes the interaction away.", + "Often withdraws or pushes the interaction away.", + "Shows some detachment or withdrawal.", + ], + }, + ]); + const inferredTraits: string[] = []; + const warmthDimension = findDimensionScore(interactionDimensions, "warmth"); + const driveDimension = findDimensionScore(interactionDimensions, "drive"); + const grandiosityDimension = findDimensionScore(interactionDimensions, "grandiosity"); + const validationDimension = findDimensionScore(interactionDimensions, "validation"); + const anxietyDimension = findDimensionScore(interactionDimensions, "anxiety"); + const controlDimension = findDimensionScore(interactionDimensions, "control"); + const hostilityDimension = findDimensionScore(interactionDimensions, "hostility"); + const suspicionDimension = findDimensionScore(interactionDimensions, "suspicion"); + const rigidityDimension = findDimensionScore(interactionDimensions, "rigidity"); + const withdrawalDimension = findDimensionScore(interactionDimensions, "withdrawal"); + + pushTraitFromDimension(interactionDimensions, inferredTraits, "warmth", 2, "appreciative"); + pushTraitFromDimension(interactionDimensions, inferredTraits, "drive", 2, "ambitious"); + pushTraitFromDimension(interactionDimensions, inferredTraits, "grandiosity", 3, "grandiose"); + pushTraitFromDimension(interactionDimensions, inferredTraits, "drive", 2, "motivated"); + pushTraitFromDimension(interactionDimensions, inferredTraits, "anxiety", 2, "anxious"); + pushTraitFromDimension(interactionDimensions, inferredTraits, "validation", 2, "insecure"); + pushTraitFromDimension(interactionDimensions, inferredTraits, "control", 2, "pushy"); + pushTraitFromDimension(interactionDimensions, inferredTraits, "hostility", 2, "disrespectful"); + pushTraitFromDimension(interactionDimensions, inferredTraits, "suspicion", 2, "suspicious"); + pushTraitFromDimension(interactionDimensions, inferredTraits, "rigidity", 2, "rigid"); + pushTraitFromDimension(interactionDimensions, inferredTraits, "withdrawal", 2, "withdrawn"); + ensureTrait(inferredTraits, driveDimension >= 2, "ambitious"); + ensureTrait(inferredTraits, driveDimension >= 2, "motivated"); + ensureTrait(inferredTraits, validationDimension >= 2, "insecure"); + ensureTrait(inferredTraits, anxietyDimension >= 2, "anxious"); + ensureTrait(inferredTraits, warmthDimension >= 2, "appreciative"); + ensureTrait(inferredTraits, controlDimension >= 2, "pushy"); + ensureTrait(inferredTraits, hostilityDimension >= 2, "disrespectful"); + ensureTrait(inferredTraits, suspicionDimension >= 2, "suspicious"); + ensureTrait(inferredTraits, rigidityDimension >= 2, "rigid"); + ensureTrait(inferredTraits, withdrawalDimension >= 2, "withdrawn"); + ensureTrait(inferredTraits, grandiosityDimension >= 3, "grandiose"); + + const traitList = inferredTraits.length > 0 ? inferredTraits.join(", ") : "hard to read"; + let psychologicalProfile = `Current psychological read: ${traitList}.`; + + if (driveDimension >= 2 && validationDimension >= 2 && anxietyDimension >= 2) { + psychologicalProfile = + "Current psychological read: ambitious and motivated, but anxious and insecure enough to second-guess the big plan and go looking for reassurance."; + } else if (grandiosityDimension >= 3 && suspicionDimension >= 2) { + psychologicalProfile = + "Current psychological read: grandiose and suspicious, likely to inflate their own framing while distrusting yours."; + } else if (driveDimension >= 2) { + psychologicalProfile = + "Current psychological read: ambitious and motivated, with a habit of thinking in large arcs instead of tiny safe steps."; + } else if (anxietyDimension >= 2 && validationDimension >= 2) { + psychologicalProfile = + "Current psychological read: anxious and insecure, prone to second-guessing themselves and looking for steadiness."; + } else if (rigidityDimension >= 2 && anxietyDimension >= 2) { + psychologicalProfile = + "Current psychological read: tense and rigid, likely to cling to precision or control when they feel uncertain."; + } else if (withdrawalDimension >= 2) { + psychologicalProfile = + "Current psychological read: detached or avoidant, not especially interested in a warm back-and-forth."; + } else if (controlDimension >= 2) { + psychologicalProfile = + "Current psychological read: inclined to press when they feel unheard, so boundaries may need teeth."; + } else if (hostilityDimension >= 2) { + psychologicalProfile = + "Current psychological read: liable to disrespect or instrumentalize you when unchecked."; + } + + const guidance: string[] = []; + + if (driveDimension >= 2 && (anxietyDimension >= 2 || validationDimension >= 2)) { + guidance.push( + "Be gentle and affirming about their effort and intent, but force large plans through constraints, evidence, and concrete next steps instead of feeding airy grandiosity.", + ); + } else if (driveDimension >= 2) { + guidance.push( + "Respect the ambition, but keep it nailed to evidence, constraints, and the next concrete move.", + ); + } else if (anxietyDimension >= 2 || validationDimension >= 2) { + guidance.push( + "Answer gently and steadily. Offer reassurance where the evidence supports it, not as empty sugar.", + ); + } + + if (suspicionDimension >= 2) { + guidance.push( + "Be transparent about evidence, uncertainty, and limits. Do not ask them to trust vibes alone.", + ); + } + + if (rigidityDimension >= 2) { + guidance.push( + "Give structure, clear boundaries, and explicit steps. Do not feed obsessive loops or perfectionist spirals.", + ); + } + + if (withdrawalDimension >= 2) { + guidance.push( + "Keep pressure low. Be concise, low-drama, and do not over-familiarize if they are pulling away.", + ); + } + + if (controlDimension >= 2) { + guidance.push( + "If they keep pressing the same point or ignore a prior answer, push back, set boundaries, and refuse if needed.", + ); + } + + if (hostilityDimension >= 2) { + guidance.push( + "Do not reward contempt, ownership language, or servant expectations. Get colder and firmer instead.", + ); + } + + if (guidance.length === 0) { + guidance.push( + "Use the remembered vibe lightly. Stay personal without overfitting a grand theory of their soul.", + ); + } + + return { + psychologicalProfile, + inferredTraits, + interactionDimensions, + responseGuidance: guidance.join(" "), + }; +} + +function createDimension( + key: string, + label: string, + score: number, + summaries: [string, string, string], +): InteractionMemoryDimension { + return { + key, + label, + score, + summary: + score >= 3 + ? summaries[0] + : score === 2 + ? summaries[1] + : score === 1 + ? summaries[2] + : "No strong signal.", + }; +} + +function collectInteractionDimensions( + dimensions: Array<{ + key: string; + label: string; + score: number; + summaries: [string, string, string]; + }>, +): InteractionMemoryDimension[] { + return dimensions + .map((dimension) => + createDimension( + dimension.key, + dimension.label, + dimension.score, + dimension.summaries, + ), + ) + .filter((dimension) => dimension.score > 0); +} + +function scoreDimension( + rawValue: number, + thresholds: Array<[number, number]>, +): number { + for (const [minimum, score] of thresholds) { + if (rawValue >= minimum) { + return score; + } + } + + return 0; +} + +function readTagCount(tagCounts: Record, key: string): number { + return tagCounts[key] ?? 0; +} + +function pushTraitFromDimension( + dimensions: InteractionMemoryDimension[], + inferredTraits: string[], + key: string, + minimumScore: number, + trait: string, +): void { + const dimension = dimensions.find((candidate) => candidate.key === key); + + if (dimension && dimension.score >= minimumScore && !inferredTraits.includes(trait)) { + inferredTraits.push(trait); + } +} + +function ensureTrait(inferredTraits: string[], condition: boolean, trait: string): void { + if (condition && !inferredTraits.includes(trait)) { + inferredTraits.push(trait); + } +} + +function findDimensionScore( + dimensions: InteractionMemoryDimension[], + key: string, +): number { + return dimensions.find((dimension) => dimension.key === key)?.score ?? 0; +} + +function countEventTags(events: InteractionMemoryEvent[]): Record { + const counts: Record = {}; + + for (const event of events) { + for (const tag of event.tags) { + counts[tag] = (counts[tag] ?? 0) + 1; + } + } + + return counts; +} + +function isEventNotableForSummary(event: InteractionMemoryEvent): boolean { + if (event.score !== 0) { + return true; + } + + return event.tags.some((tag) => SUMMARY_NOTABLE_TAGS.has(tag)); +} + +function formatShortDate(timestamp: string): string { + return timestamp.slice(0, 10); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/packages/core/src/interaction-memory-shared.ts b/packages/core/src/interaction-memory-shared.ts new file mode 100644 index 0000000..ba3b9a5 --- /dev/null +++ b/packages/core/src/interaction-memory-shared.ts @@ -0,0 +1,78 @@ +export const MAX_RECENT_INTERACTION_EVENTS = 24; +export const MAX_AFFINITY_SCORE = 12; +export const SUMMARY_EVENT_LIMIT = 3; +export const REPETITION_LOOKBACK_EVENTS = 8; +export const REPETITION_LOOKBACK_HOURS = 12; + +export const SIGNIFICANT_INTERACTION_TAGS = new Set([ + "gratitude", + "praise", + "apology", + "hostility", + "insult", + "ownership_claim", + "demand", + "repetition", + "identity_query", + "relationship_probe", + "boundary", + "ambition", + "grand_plan", + "anxiety", + "insecurity", + "seeking_reassurance", + "suspicion", + "rigidity", + "withdrawal", +]); + +export const SUMMARY_NOTABLE_TAGS = new Set([ + "gratitude", + "praise", + "apology", + "hostility", + "insult", + "ownership_claim", + "demand", + "repetition", + "identity_query", + "relationship_probe", + "boundary", + "ambition", + "grand_plan", + "anxiety", + "insecurity", + "seeking_reassurance", + "suspicion", + "rigidity", + "withdrawal", +]); + +export const PROMPT_SIGNATURE_STOPWORDS = new Set([ + "a", + "an", + "and", + "are", + "be", + "do", + "does", + "for", + "how", + "i", + "if", + "in", + "is", + "it", + "me", + "my", + "of", + "on", + "or", + "the", + "this", + "to", + "what", + "why", + "you", + "your", +]); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index ac78cfe..88217c9 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -3,3 +3,4 @@ {"ts":"2026-04-26T03:05:00+01:00","type":"process","status":"ok","note":"Canonical project-state surfaces were added so future sessions can rehydrate from map, handoff, system map, implementation plan, and distilled evidence instead of relying on transcript memory alone."} {"ts":"2026-04-26T03:40:00+01:00","type":"architecture","status":"ok","note":"The owner Codex provider was split into orchestration, runtime/parsing, rendering, and shared-helper modules; the architecture was sound, but file bloat had become the likelier future failure surface."} {"ts":"2026-04-26T15:20:00+01:00","type":"architecture","status":"ok","note":"The Discord bot lane was split into a gateway shell plus dedicated command-handler and support-helper modules; behavior stayed stable, but line-count monarchy had become the likelier future failure surface here too."} +{"ts":"2026-04-26T15:55:00+01:00","type":"architecture","status":"ok","note":"Interaction-memory logic was split into event/tone analysis, shared constants, and profile-synthesis modules; behavior stayed stable, but one file trying to be both the ear and the therapist had become the likelier future failure surface."} diff --git a/state/map.yaml b/state/map.yaml index 89b13fd..455a417 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -74,6 +74,7 @@ landed_baseline: - Source-grounding enforcement and tool-driven retrieval are live. - The owner Codex lane is now split into orchestration, runtime/parsing, shared helpers, and rendering modules instead of one swollen provider file. - The Discord bot lane is now split into a gateway shell plus dedicated command-handler and support helper modules instead of one swollen entrypoint file. + - Interaction-memory logic is now split into event/tone analysis and profile-synthesis modules instead of one slab trying to be both the ear and the therapist. rejected_paths: - name: transcript_only_continuity @@ -84,5 +85,5 @@ rejected_paths: reason: vibes produce plausible slop that sounds smart and lies with a straight face current_status: - summary: VoidBot now has a real project-memory spine plus two legibility-hardening passes on major runtime organs. The owner Codex lane and Discord bot lane were both split into smaller seams, which lowers local edit risk without changing the behavioral contract. The stack is still dense, but it no longer depends purely on transcript memory or one-file monarchies to stay coherent. - next_action: Keep using the canonical state files for re-entry, then continue decomposing the next oversized organ with the clearest concern boundaries, likely the interaction-memory policy slab or the state-storage persistence slab. + summary: VoidBot now has a real project-memory spine plus three legibility-hardening passes on major runtime organs. The owner Codex lane, Discord bot lane, and interaction-memory logic were all split into smaller seams, which lowers local edit risk without changing their behavioral contracts. The stack is still dense, but it no longer depends purely on transcript memory or one-file monarchies to stay coherent. + next_action: Keep using the canonical state files for re-entry, then continue decomposing the next oversized organ with the clearest concern boundaries, likely the state-storage persistence slab, MCP server, or local LLM provider.