import { remember, recallByTag } from "./synap"; import type { ObserverOutput, PersonProfile, CohortDefinition, CohortMembership, } from "./types"; // --------------------------------------------------------------------------- // Cohort definitions — rule-based behavioral clusters // --------------------------------------------------------------------------- export const COHORT_DEFINITIONS: CohortDefinition[] = [ { name: "casual", description: "Uses informal, relaxed communication style", match: (p) => (p.avgFormality < 0.4 ? 0.6 + (0.4 - p.avgFormality) : 0), }, { name: "formal", description: "Uses structured, professional communication style", match: (p) => (p.avgFormality >= 0.6 ? 0.5 + (p.avgFormality - 0.6) : 0), }, { name: "technical", description: "Uses jargon and goes beyond surface-level specificity", match: (p) => p.jargonRate > 0.5 && p.topSpecificity !== "surface" ? 0.5 + p.jargonRate * 0.4 : 0, }, { name: "accessible", description: "Avoids jargon or stays at surface-level specificity", match: (p) => p.jargonRate < 0.3 || p.topSpecificity === "surface" ? 0.6 + (1 - p.jargonRate) * 0.3 : 0, }, { name: "leader", description: "Tends to steer the conversation direction", match: (p) => (p.leaderRate > 0.6 ? 0.5 + (p.leaderRate - 0.6) : 0), }, { name: "responder", description: "Follows the system's conversation lead", match: (p) => (p.leaderRate < 0.4 ? 0.5 + (0.4 - p.leaderRate) : 0), }, { name: "positive-engager", description: "Generally positive sentiment in exchanges", match: (p) => p.avgSentiment > 0.65 ? 0.5 + (p.avgSentiment - 0.65) * 2 : 0, }, { name: "verbose", description: "Writes longer, more detailed messages", match: (p) => (p.avgWordCount > 40 ? Math.min(1, 0.5 + (p.avgWordCount - 40) / 80) : 0), }, { name: "terse", description: "Writes short, concise messages", match: (p) => (p.avgWordCount < 15 ? 0.6 + (15 - p.avgWordCount) / 30 : 0), }, ]; // --------------------------------------------------------------------------- // Profile computation — running average from ObserverOutput[] // --------------------------------------------------------------------------- export function computeProfile( signals: ObserverOutput[], existing?: PersonProfile | null, personId?: string ): PersonProfile { if (!signals.length && existing) return existing; const id = personId ?? existing?.personId ?? "unknown"; const prevCount = existing?.interactionCount ?? 0; const totalCount = prevCount + signals.length; // Compute averages from new signals let sumFormality = 0; let sumSentiment = 0; let sumWordCount = 0; let jargonTrue = 0; let leaderCount = 0; const specificityCount: Record = {}; const domainCount: Record = {}; for (const s of signals) { sumFormality += s.style.formality; sumSentiment += s.engagement.sentiment_score; sumWordCount += s.engagement.word_count; if (s.style.uses_jargon) jargonTrue++; if (s.dynamics.who_is_leading === "person") leaderCount++; const spec = s.topic.specificity; specificityCount[spec] = (specificityCount[spec] ?? 0) + 1; const dom = s.topic.domain; domainCount[dom] = (domainCount[dom] ?? 0) + 1; } const n = signals.length || 1; // Blend new averages with existing (weighted running average) const blend = (newAvg: number, oldAvg: number | undefined): number => { if (oldAvg === undefined || prevCount === 0) return newAvg; return (oldAvg * prevCount + newAvg * n) / totalCount; }; const avgFormality = blend(sumFormality / n, existing?.avgFormality); const avgSentiment = blend(sumSentiment / n, existing?.avgSentiment); const avgWordCount = blend(sumWordCount / n, existing?.avgWordCount); const jargonRate = blend(jargonTrue / n, existing?.jargonRate); const leaderRate = blend(leaderCount / n, existing?.leaderRate); // Top specificity const topSpecificity = ( Object.entries(specificityCount).sort((a, b) => b[1] - a[1])[0]?.[0] ?? existing?.topSpecificity ?? "surface" ) as "surface" | "intermediate" | "expert"; // Top domains (merge with existing, keep top 5) const mergedDomains: Record = {}; if (existing?.topDomains) { for (const d of existing.topDomains) { mergedDomains[d] = (mergedDomains[d] ?? 0) + prevCount; } } for (const [d, c] of Object.entries(domainCount)) { mergedDomains[d] = (mergedDomains[d] ?? 0) + c; } const topDomains = Object.entries(mergedDomains) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([d]) => d); const profile: PersonProfile = { personId: id, interactionCount: totalCount, avgFormality, avgSentiment, avgWordCount, jargonRate, leaderRate, topSpecificity, topDomains, cohorts: [], updatedAt: Date.now(), }; // Assign cohorts profile.cohorts = assignCohorts(profile); return profile; } // --------------------------------------------------------------------------- // Cohort assignment // --------------------------------------------------------------------------- export function assignCohorts(profile: PersonProfile): CohortMembership[] { const memberships: CohortMembership[] = []; for (const def of COHORT_DEFINITIONS) { const probability = def.match(profile); if (probability > 0.3) { memberships.push({ cohort: def.name, probability: Math.min(1, probability), }); } } return memberships.sort((a, b) => b.probability - a.probability); } // --------------------------------------------------------------------------- // Cohort prior loading — query Synap for profiles of similar people // --------------------------------------------------------------------------- export async function loadCohortPriors( cohorts: CohortMembership[], interactionCount: number ): Promise { if (!cohorts.length) return []; // Weight: fades as individual data grows // 1/(1 + n/10) → 1.0 at n=0, 0.5 at n=10, 0.25 at n=30 const weight = 1 / (1 + interactionCount / 10); if (weight < 0.1) { console.log( `[cohorts] skipping priors — weight ${weight.toFixed(2)} too low (${interactionCount} interactions)` ); return []; } const priors: string[] = []; // Query Synap for profiles tagged with matching cohorts for (const { cohort, probability } of cohorts.slice(0, 3)) { try { const result = await recallByTag( "person communication style", ["person-profile", `cohort:${cohort}`], 5, 0.2 ); const all = [ ...result.memories.vivid, ...result.memories.associated, ]; if (all.length) { const desc = COHORT_DEFINITIONS.find((d) => d.name === cohort)?.description ?? cohort; priors.push( `People in the "${cohort}" group (${desc}) — weight: ${(weight * probability).toFixed(2)}` ); } } catch (err) { console.error(`[cohorts] failed to load priors for ${cohort}:`, err); } } // Add the weight context for the LLM if (priors.length) { priors.unshift( `Cohort prior confidence: ${(weight * 100).toFixed(0)}% (${interactionCount} individual interactions so far)` ); } return priors; } // --------------------------------------------------------------------------- // Synap persistence // --------------------------------------------------------------------------- const profileCache = new Map(); export async function storeProfile(profile: PersonProfile): Promise { profileCache.set(profile.personId, profile); const cohortTags = profile.cohorts.map((c) => `cohort:${c.cohort}`); const content = JSON.stringify({ personId: profile.personId, interactionCount: profile.interactionCount, avgFormality: Number(profile.avgFormality.toFixed(3)), avgSentiment: Number(profile.avgSentiment.toFixed(3)), avgWordCount: Number(profile.avgWordCount.toFixed(1)), jargonRate: Number(profile.jargonRate.toFixed(3)), leaderRate: Number(profile.leaderRate.toFixed(3)), topSpecificity: profile.topSpecificity, topDomains: profile.topDomains, cohorts: profile.cohorts, }); try { await remember(content, { confidence: 0.9, memoryType: "semantic", tags: [ "person-profile", `person:${profile.personId}`, ...cohortTags, ], }); console.log( `[cohorts] stored profile for ${profile.personId}: ${profile.cohorts.map((c) => c.cohort).join(", ") || "no cohorts yet"}` ); } catch (err) { console.error("[cohorts] failed to store profile:", err); } } export async function loadProfile( personId: string ): Promise { // Check cache first const cached = profileCache.get(personId); if (cached) return cached; try { const result = await recallByTag( "person profile", ["person-profile", `person:${personId}`], 1, 0.2 ); const all = [...result.memories.vivid, ...result.memories.associated]; if (!all.length) return null; const parsed = JSON.parse(all[0].content); const profile: PersonProfile = { personId: parsed.personId ?? personId, interactionCount: parsed.interactionCount ?? 0, avgFormality: parsed.avgFormality ?? 0.5, avgSentiment: parsed.avgSentiment ?? 0.5, avgWordCount: parsed.avgWordCount ?? 20, jargonRate: parsed.jargonRate ?? 0, leaderRate: parsed.leaderRate ?? 0.5, topSpecificity: parsed.topSpecificity ?? "surface", topDomains: parsed.topDomains ?? [], cohorts: parsed.cohorts ?? [], updatedAt: Date.now(), }; profileCache.set(personId, profile); return profile; } catch (err) { console.error("[cohorts] failed to load profile:", err); return null; } }