- 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>
324 lines
9.8 KiB
TypeScript
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;
|
|
}
|
|
}
|