tidaldb/applications/iknowyou/app/api/chat/route.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

196 lines
6.0 KiB
TypeScript

import { streamChat } from "@/lib/vllm";
import { sendMessage } from "@/lib/synap";
import { assembleBrief } from "@/lib/briefing";
import type { ObserverOutput } from "@/lib/types";
interface ChatBody {
messages: { role: "user" | "assistant"; content: string }[];
conversationId?: string;
personId?: string;
}
// --- Per-conversation state (in-memory, lost on restart — fine for M3) ---
const turnCounts = new Map<string, number>();
const signalBuffers = new Map<string, ObserverOutput[]>();
const lastTopics = new Map<string, string>();
const SYNTHESIS_INTERVAL = 5;
export async function POST(req: Request) {
const requestTimestamp = Date.now();
let body: ChatBody;
try {
body = await req.json();
} catch {
return new Response("Invalid JSON", { status: 400 });
}
if (!body.messages?.length) {
return new Response("No messages provided", { status: 400 });
}
const lastUserMsg = body.messages.findLast((m) => m.role === "user");
const conversationId = body.conversationId;
const personId = body.personId;
// 1. Store user message in Synap (non-blocking — don't delay stream start)
if (conversationId && lastUserMsg) {
sendMessage("user", lastUserMsg.content, conversationId).catch((err) =>
console.error("[synap] failed to store user message:", err.message)
);
}
// 2. Assemble communication brief (replaces scatter-shot recall + cohort loading)
const brief = personId
? await assembleBrief(personId).catch((err) => {
console.error("[brief] assembly failed:", err.message);
return undefined;
})
: undefined;
const encoder = new TextEncoder();
let fullResponse = "";
const stream = new ReadableStream({
async start(controller) {
try {
for await (const token of streamChat(body.messages, brief)) {
fullResponse += token;
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ token })}\n\n`)
);
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
} catch (err) {
const message =
err instanceof Error ? err.message : "Connection failed";
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ error: message })}\n\n`)
);
} finally {
controller.close();
// 5. Store assistant response in Synap (after stream ends)
if (conversationId && fullResponse) {
sendMessage("aeries", fullResponse, conversationId).catch((err) =>
console.error(
"[synap] failed to store assistant message:",
err.message
)
);
}
// 6. Fire deep observer (non-blocking)
if (conversationId && lastUserMsg && fullResponse) {
fireDeepObserver(
lastUserMsg.content,
fullResponse,
conversationId,
requestTimestamp,
body.messages,
personId
).catch((err) =>
console.error("[observe] failed:", err.message)
);
}
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
/** Deep observer: Tier 1 structured extraction + Tier 2 periodic synthesis + profile update. */
async function fireDeepObserver(
userMessage: string,
assistantMessage: string,
conversationId: string,
requestTimestamp: number,
allMessages: { role: string; content: string }[],
personId?: string
): Promise<void> {
const {
extractObserverOutput,
outputToSignalMemories,
storeSignals,
synthesizeObservations,
storeObservations,
} = await import("@/lib/observer");
// Track turns
const turn = (turnCounts.get(conversationId) ?? 0) + 1;
turnCounts.set(conversationId, turn);
// Compute latency (time between request arrival and now — approximation)
const latencySeconds = Math.round((Date.now() - requestTimestamp) / 1000);
// Tier 1: Structured extraction
const output = await extractObserverOutput(userMessage, assistantMessage, {
turnNumber: turn,
latencySeconds,
previousTopic: lastTopics.get(conversationId),
});
if (!output) {
console.log(`[observer] turn ${turn}: extraction returned null`);
return;
}
console.log(
`[observer] turn ${turn}: topic=${output.topic.primary}, sentiment=${output.engagement.sentiment_score}, formality=${output.style.formality}`
);
// Track topic for next turn's context
lastTopics.set(conversationId, output.topic.primary);
// Store dimension-tagged signals in Synap (with person tag)
const memories = outputToSignalMemories(output, conversationId, personId);
await storeSignals(memories);
// Buffer for synthesis
const buffer = signalBuffers.get(conversationId) ?? [];
buffer.push(output);
// Keep only last 10 to bound memory
if (buffer.length > 10) buffer.shift();
signalBuffers.set(conversationId, buffer);
// Tier 2: Periodic synthesis (every N turns)
if (turn % SYNTHESIS_INTERVAL === 0 && buffer.length >= 3) {
console.log(`[observer] turn ${turn}: running Tier 2 synthesis`);
// Build conversation snippet from last few messages
const snippet = allMessages
.slice(-6)
.map((m) => `${m.role}: ${m.content.slice(0, 100)}`)
.join("\n");
const observations = await synthesizeObservations(buffer, snippet);
if (observations.length) {
await storeObservations(observations, conversationId, personId);
}
}
// M4: Update person profile after signal extraction
if (personId) {
const { computeProfile, storeProfile, loadProfile } = await import(
"@/lib/cohorts"
);
const existing = await loadProfile(personId);
const updated = computeProfile([output], existing, personId);
console.log(
`[cohorts] updated profile for ${personId.slice(0, 8)}…: interactions=${updated.interactionCount}, cohorts=[${updated.cohorts.map((c) => `${c.cohort}(${c.probability.toFixed(2)})`).join(", ")}]`
);
await storeProfile(updated);
}
}