tidaldb/applications/iknowyou/lib/cohorts.ts
jordan 98bdc18a49 feat: add iknowyou app + complete M8 replication extensions + Aeries agents/skills
- applications/iknowyou: new Next.js chat application with persona-aware conversations,
  briefing API, cohort logic, vLLM streaming, and sidebar navigation
- tidal M8: add replication control plane (control.rs), tenant migration state machine
  (migration.rs), tenant/upgrade coordinators, cluster/fault test harnesses
- tidal M8 tests: expand m8p2/m8p3/m8p4 test suites; add m8p5_multitenancy and m8_uat
- tidal db: split replication_ops out of db/mod.rs (was 647 lines, now 574)
- .claude: add kai-park, kaya-osei, mira-vasquez agents; add aeries-design-architect,
  aeries-fullstack-engineer, aeries-product-visionary skills
- docs: update ROADMAP.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 21:09:11 -07:00

324 lines
9.8 KiB
TypeScript

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<string, number> = {};
const domainCount: Record<string, number> = {};
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<string, number> = {};
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<string[]> {
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<string, PersonProfile>();
export async function storeProfile(profile: PersonProfile): Promise<void> {
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<PersonProfile | null> {
// 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;
}
}