- Extract redeliver_missed(tx, db, log) helper into cluster_transport.rs - heal_region now removes partition then immediately ships any missed batch-log entries to the healed follower's channel - await_convergence refactored to call the same helper (no logic change) - tidal-server: reload_text_index before search in cluster mode - tidal-server: write_signal returns Result instead of panicking on unknown signal - tidal-server: leader shows lag_events=0 (writes directly, no receiver thread) - tidal-server: fix cluster mode error propagation (ServerError::from) - docs/runbooks/cluster.md: add full cluster operations runbook - docker/: add Dockerfile for containerised cluster deployment - README.md: add tidal-server HTTP API getting-started section - Split oversized source files per CODING_GUIDELINES §9 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { recallByTag, type SynapRecallMemory } from "./synap";
|
|
import { loadProfile, loadCohortPriors } from "./cohorts";
|
|
import type {
|
|
CommunicationBrief,
|
|
TopicEntry,
|
|
PersonProfile,
|
|
} from "./types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Signal content parsers — match exact formats from observer.ts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ParsedTopic {
|
|
topic: string;
|
|
domain: string;
|
|
specificity: "surface" | "intermediate" | "expert";
|
|
deepened: boolean;
|
|
continued: boolean;
|
|
}
|
|
|
|
/** Parse: "topic: distributed systems (engineering, expert) [deepened] [continued]" */
|
|
function parseTopicContent(content: string): ParsedTopic | null {
|
|
const match = content.match(
|
|
/^topic:\s*(.+?)\s*\((\w+),\s*(surface|intermediate|expert)\)/
|
|
);
|
|
if (!match) return null;
|
|
return {
|
|
topic: match[1].trim(),
|
|
domain: match[2].trim(),
|
|
specificity: match[3] as ParsedTopic["specificity"],
|
|
deepened: content.includes("[deepened]"),
|
|
continued: content.includes("[continued]"),
|
|
};
|
|
}
|
|
|
|
interface ParsedStyle {
|
|
formality: number;
|
|
lowercase: boolean;
|
|
jargon: boolean;
|
|
structure: string;
|
|
emoji: boolean;
|
|
}
|
|
|
|
/** Parse: "formality: 0.25, lowercase: true, jargon: false, structure: stream_of_thought, emoji: false" */
|
|
function parseStyleContent(content: string): ParsedStyle | null {
|
|
const formality = content.match(/formality:\s*([\d.]+)/);
|
|
if (!formality) return null;
|
|
return {
|
|
formality: parseFloat(formality[1]),
|
|
lowercase: content.includes("lowercase: true"),
|
|
jargon: content.includes("jargon: true"),
|
|
structure:
|
|
content.match(/structure:\s*(\w+)/)?.[1] ?? "stream_of_thought",
|
|
emoji: content.includes("emoji: true"),
|
|
};
|
|
}
|
|
|
|
interface ParsedDynamics {
|
|
leading: "person" | "system";
|
|
builtOnPrevious: boolean;
|
|
redirected: string | null;
|
|
correctedSystem: boolean;
|
|
}
|
|
|
|
/** Parse: "leading: person, built_on_previous: true, redirected: X, corrected system" */
|
|
function parseDynamicsContent(content: string): ParsedDynamics | null {
|
|
const leading = content.match(/leading:\s*(person|system)/);
|
|
if (!leading) return null;
|
|
const redirectMatch = content.match(/redirected:\s*(.+?)(?:,|$)/);
|
|
return {
|
|
leading: leading[1] as "person" | "system",
|
|
builtOnPrevious: content.includes("built_on_previous: true"),
|
|
redirected: redirectMatch ? redirectMatch[1].trim() : null,
|
|
correctedSystem: content.includes("corrected system"),
|
|
};
|
|
}
|
|
|
|
interface ParsedEngagement {
|
|
sentiment: number;
|
|
direction: string;
|
|
substantive: boolean;
|
|
words: number;
|
|
}
|
|
|
|
/** Parse: "sentiment: 0.78 (positive), substantive: true, words: 42" */
|
|
function parseEngagementContent(content: string): ParsedEngagement | null {
|
|
const sentiment = content.match(/sentiment:\s*([\d.]+)\s*\((\w+)\)/);
|
|
if (!sentiment) return null;
|
|
const words = content.match(/words:\s*(\d+)/);
|
|
return {
|
|
sentiment: parseFloat(sentiment[1]),
|
|
direction: sentiment[2],
|
|
substantive: content.includes("substantive: true"),
|
|
words: words ? parseInt(words[1], 10) : 0,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildTopicsSection(
|
|
memories: SynapRecallMemory[]
|
|
): CommunicationBrief["topics"] {
|
|
const topicMap = new Map<
|
|
string,
|
|
{
|
|
domain: string;
|
|
specificity: "surface" | "intermediate" | "expert";
|
|
deepened: boolean;
|
|
count: number;
|
|
lastSeen: number;
|
|
}
|
|
>();
|
|
|
|
for (let i = 0; i < memories.length; i++) {
|
|
const parsed = parseTopicContent(memories[i].content);
|
|
if (!parsed) continue;
|
|
|
|
const key = parsed.topic.toLowerCase();
|
|
const existing = topicMap.get(key);
|
|
if (existing) {
|
|
existing.count++;
|
|
existing.lastSeen = i;
|
|
if (parsed.deepened) existing.deepened = true;
|
|
// Keep highest specificity
|
|
const specOrder = { surface: 0, intermediate: 1, expert: 2 };
|
|
if (specOrder[parsed.specificity] > specOrder[existing.specificity]) {
|
|
existing.specificity = parsed.specificity;
|
|
}
|
|
} else {
|
|
topicMap.set(key, {
|
|
domain: parsed.domain,
|
|
specificity: parsed.specificity,
|
|
deepened: parsed.deepened,
|
|
count: 1,
|
|
lastSeen: i,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by recency * frequency (higher = hotter)
|
|
const entries = [...topicMap.entries()]
|
|
.map(([topic, data]) => ({
|
|
topic,
|
|
...data,
|
|
score: data.count * (1 + data.lastSeen / memories.length),
|
|
}))
|
|
.sort((a, b) => b.score - a.score);
|
|
|
|
const hot: TopicEntry[] = entries.slice(0, 5).map((e) => ({
|
|
topic: e.topic,
|
|
domain: e.domain,
|
|
frequency: e.count,
|
|
specificity: e.specificity,
|
|
deepened: e.deepened,
|
|
}));
|
|
|
|
// Cold: known topics not in the hot list, sorted by count descending
|
|
const cold: TopicEntry[] = entries.slice(5, 8).map((e) => ({
|
|
topic: e.topic,
|
|
domain: e.domain,
|
|
frequency: e.count,
|
|
specificity: e.specificity,
|
|
deepened: e.deepened,
|
|
}));
|
|
|
|
// Domains by frequency
|
|
const domainCount = new Map<string, number>();
|
|
for (const e of entries) {
|
|
domainCount.set(e.domain, (domainCount.get(e.domain) ?? 0) + e.count);
|
|
}
|
|
const domains = [...domainCount.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map(([d]) => d);
|
|
|
|
return { hot, cold, domains };
|
|
}
|
|
|
|
function buildStyleSection(
|
|
profile: PersonProfile | null,
|
|
styleMemories: SynapRecallMemory[]
|
|
): CommunicationBrief["style"] {
|
|
// Default from profile
|
|
let formality = profile?.avgFormality ?? 0.5;
|
|
let jargon = (profile?.jargonRate ?? 0) > 0.5;
|
|
let emoji = false;
|
|
let structure = "stream_of_thought";
|
|
let avgWords = profile?.avgWordCount ?? 20;
|
|
|
|
// Aggregate from recent signals — average numerics, majority-vote booleans/strings
|
|
let styleCount = 0;
|
|
let formalitySum = 0;
|
|
let jargonTrue = 0;
|
|
let emojiTrue = 0;
|
|
const structureCounts = new Map<string, number>();
|
|
|
|
for (const mem of styleMemories) {
|
|
const parsed = parseStyleContent(mem.content);
|
|
if (!parsed) continue;
|
|
styleCount++;
|
|
formalitySum += parsed.formality;
|
|
if (parsed.jargon) jargonTrue++;
|
|
if (parsed.emoji) emojiTrue++;
|
|
structureCounts.set(parsed.structure, (structureCounts.get(parsed.structure) ?? 0) + 1);
|
|
}
|
|
|
|
if (styleCount > 0) {
|
|
formality = formalitySum / styleCount;
|
|
jargon = jargonTrue / styleCount > 0.5;
|
|
emoji = emojiTrue / styleCount > 0.5;
|
|
// Most frequent structure wins
|
|
let maxCount = 0;
|
|
for (const [s, count] of structureCounts) {
|
|
if (count > maxCount) { maxCount = count; structure = s; }
|
|
}
|
|
}
|
|
|
|
const formalityBand: CommunicationBrief["style"]["formality"] =
|
|
formality < 0.35 ? "casual" : formality > 0.65 ? "formal" : "moderate";
|
|
|
|
const lengthBand: CommunicationBrief["style"]["length"] =
|
|
avgWords < 15 ? "terse" : avgWords > 40 ? "verbose" : "moderate";
|
|
|
|
return { formality: formalityBand, length: lengthBand, structure, usesJargon: jargon, usesEmoji: emoji };
|
|
}
|
|
|
|
function buildPatternsSection(
|
|
profile: PersonProfile | null,
|
|
dynamicsMemories: SynapRecallMemory[],
|
|
engagementMemories: SynapRecallMemory[]
|
|
): CommunicationBrief["patterns"] {
|
|
const leadsConversation = (profile?.leaderRate ?? 0.5) > 0.5;
|
|
|
|
// Deepens topics: check if any dynamics show built_on_previous
|
|
let deepensCount = 0;
|
|
let dynamicsTotal = 0;
|
|
for (const mem of dynamicsMemories) {
|
|
const parsed = parseDynamicsContent(mem.content);
|
|
if (!parsed) continue;
|
|
dynamicsTotal++;
|
|
if (parsed.builtOnPrevious) deepensCount++;
|
|
}
|
|
const deepensTopics = dynamicsTotal > 0 ? deepensCount / dynamicsTotal > 0.4 : false;
|
|
|
|
// Sentiment from engagement signals
|
|
const sentiments: number[] = [];
|
|
for (const mem of engagementMemories) {
|
|
const parsed = parseEngagementContent(mem.content);
|
|
if (parsed) sentiments.push(parsed.sentiment);
|
|
}
|
|
|
|
const avgSentiment =
|
|
sentiments.length > 0
|
|
? sentiments.reduce((a, b) => a + b, 0) / sentiments.length
|
|
: profile?.avgSentiment ?? 0.5;
|
|
|
|
// Trend: compare recent batch sentiment against profile's running average
|
|
let sentimentTrend: CommunicationBrief["patterns"]["sentimentTrend"] = "stable";
|
|
if (sentiments.length >= 3 && profile?.avgSentiment !== undefined) {
|
|
const delta = avgSentiment - profile.avgSentiment;
|
|
if (delta > 0.1) sentimentTrend = "warming";
|
|
else if (delta < -0.1) sentimentTrend = "cooling";
|
|
}
|
|
|
|
return { leadsConversation, deepensTopics, avgSentiment, sentimentTrend };
|
|
}
|
|
|
|
/** Raw signal patterns that should never appear in observation content. */
|
|
const RAW_SIGNAL_PATTERNS = [
|
|
/^formality:\s/,
|
|
/^topic:\s/,
|
|
/^leading:\s/,
|
|
/^sentiment:\s[\d.]+\s*\(/,
|
|
/^response latency:/,
|
|
];
|
|
|
|
function buildObservationsSection(memories: SynapRecallMemory[]): string[] {
|
|
return memories
|
|
.map((m) => m.content)
|
|
.filter((c) => c.length > 0 && !RAW_SIGNAL_PATTERNS.some((p) => p.test(c)))
|
|
.slice(0, 5);
|
|
}
|
|
|
|
async function buildCohortSection(
|
|
profile: PersonProfile | null
|
|
): Promise<CommunicationBrief["cohortPriors"]> {
|
|
if (!profile || !profile.cohorts.length || profile.interactionCount >= 30) {
|
|
return { active: false, weight: 0, priors: [] };
|
|
}
|
|
|
|
const weight = 1 / (1 + profile.interactionCount / 10);
|
|
|
|
const priors = await loadCohortPriors(
|
|
profile.cohorts,
|
|
profile.interactionCount
|
|
);
|
|
|
|
return { active: priors.length > 0, weight, priors };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main assembly
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Flatten vivid + associated tiers (reconstructed excluded — lower confidence). */
|
|
function flattenMemories(result: {
|
|
memories: {
|
|
vivid: SynapRecallMemory[];
|
|
associated: SynapRecallMemory[];
|
|
reconstructed: SynapRecallMemory[];
|
|
};
|
|
}): SynapRecallMemory[] {
|
|
const vivid = result.memories?.vivid ?? [];
|
|
const associated = result.memories?.associated ?? [];
|
|
return [...vivid, ...associated];
|
|
}
|
|
|
|
export async function assembleBrief(
|
|
personId: string
|
|
): Promise<CommunicationBrief> {
|
|
const start = Date.now();
|
|
|
|
// 6 parallel Synap queries + profile load — single wave
|
|
const catchSynap = (label: string) => (err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error(`[brief] ${label} failed for ${personId.slice(0, 8)}…: ${msg}`);
|
|
return null;
|
|
};
|
|
|
|
const [topicResult, styleResult, dynamicsResult, observationResult, engagementResult, profile] =
|
|
await Promise.all([
|
|
recallByTag("topics discussed", ["signal:topic", `person:${personId}`], 20, 0.2).catch(catchSynap("topic recall")),
|
|
recallByTag("communication style", ["signal:style", `person:${personId}`], 10, 0.2).catch(catchSynap("style recall")),
|
|
recallByTag("conversation dynamics", ["signal:dynamics", `person:${personId}`], 10, 0.2).catch(catchSynap("dynamics recall")),
|
|
recallByTag("communication patterns", ["observation", `person:${personId}`], 5, 0.3).catch(catchSynap("observation recall")),
|
|
recallByTag("engagement signals", ["signal:engagement", `person:${personId}`], 10, 0.2).catch(catchSynap("engagement recall")),
|
|
loadProfile(personId).catch(catchSynap("profile load")),
|
|
]);
|
|
|
|
// Cohort priors chain off profile (needs interactionCount)
|
|
const cohortPriors = await buildCohortSection(profile);
|
|
|
|
const topicMemories = topicResult ? flattenMemories(topicResult) : [];
|
|
const styleMemories = styleResult ? flattenMemories(styleResult) : [];
|
|
const dynamicsMemories = dynamicsResult
|
|
? flattenMemories(dynamicsResult)
|
|
: [];
|
|
const observationMemories = observationResult
|
|
? flattenMemories(observationResult)
|
|
: [];
|
|
const engagementMemories = engagementResult
|
|
? flattenMemories(engagementResult)
|
|
: [];
|
|
|
|
const brief: CommunicationBrief = {
|
|
personId,
|
|
interactionCount: profile?.interactionCount ?? 0,
|
|
assembledAt: Date.now(),
|
|
topics: buildTopicsSection(topicMemories),
|
|
style: buildStyleSection(profile, styleMemories),
|
|
patterns: buildPatternsSection(profile, dynamicsMemories, engagementMemories),
|
|
observations: buildObservationsSection(observationMemories),
|
|
cohortPriors,
|
|
assemblyMs: Date.now() - start,
|
|
};
|
|
|
|
console.log(
|
|
`[brief] assembled for ${personId.slice(0, 8)}… in ${brief.assemblyMs}ms: ` +
|
|
`${brief.topics.hot.length} hot topics, ` +
|
|
`style=${brief.style.formality}, ` +
|
|
`${brief.observations.length} observations, ` +
|
|
`cohort=${brief.cohortPriors.active ? "active" : "inactive"}`
|
|
);
|
|
|
|
return brief;
|
|
}
|