chat / src /lib /hooks /useMetacognitiveEngine.svelte.ts
Andrew
feat(tree): Add ELK port-based layout and persona-specific branching
cb5990d
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { browser } from "$app/environment";
import superjson from "superjson";
import type { Message, MetacognitiveEvent } from "$lib/types/Message";
import type { MetacognitiveConfig, MetacognitivePromptData } from "$lib/types/Metacognitive";
import { determineMetacognitivePrompt } from "$lib/utils/metacognitiveLogic";
interface MetacognitiveSettings {
activePersonas?: string[];
personas?: Array<{ id: string; name?: string }>;
}
interface UseMetacognitiveEngineProps {
messages: Message[];
loading: boolean;
pending: boolean;
metacognitiveConfig?: MetacognitiveConfig;
metacognitiveState?: {
targetFrequency?: number;
lastPromptedAtMessageId?: string | null;
};
userSettings: MetacognitiveSettings;
onmetacognitivebranch?: (
messageId: string,
personaId: string,
promptData: MetacognitivePromptData
) => void;
}
export function useMetacognitiveEngine(props: () => UseMetacognitiveEngineProps) {
let metacognitiveTargetFrequency = $state<number | null>(null);
let metacognitiveLastPromptedAtMessageId = $state<string | null>(null);
let metacognitivePromptDismissedForMessageId = $state<string | null>(null);
let activeMetacognitivePrompt = $state<MetacognitivePromptData | null>(null);
let lastProcessedMessageId = $state<string | null>(null);
let promptGenerationTimeout: ReturnType<typeof setTimeout> | undefined = $state();
// Cache generated prompts to prevent regeneration with different random values
const promptCache = new Map<string, MetacognitivePromptData | null>();
let lastMessageCount = 0;
// Initialize from server state
$effect(() => {
const { metacognitiveConfig, metacognitiveState, messages } = props();
if (!metacognitiveConfig?.enabled) {
return;
}
if (metacognitiveState?.targetFrequency && metacognitiveTargetFrequency === null) {
metacognitiveTargetFrequency = metacognitiveState.targetFrequency;
}
if (
metacognitiveState?.lastPromptedAtMessageId !== undefined &&
metacognitiveLastPromptedAtMessageId === null
) {
metacognitiveLastPromptedAtMessageId = metacognitiveState.lastPromptedAtMessageId ?? null;
}
// Clear cache if messages array was replaced (e.g., navigation to different conversation)
if (messages.length < lastMessageCount) {
promptCache.clear();
}
lastMessageCount = messages.length;
// Populate cache from server-loaded events to prevent regeneration
messages.forEach((msg) => {
if (msg.from === "assistant" && msg.metacognitiveEvents?.length && !promptCache.has(msg.id)) {
const event = msg.metacognitiveEvents[msg.metacognitiveEvents.length - 1];
promptCache.set(msg.id, {
type: event.type,
promptText: event.promptText,
triggerFrequency: event.triggerFrequency,
suggestedPersonaId: event.suggestedPersonaId,
suggestedPersonaName: event.suggestedPersonaName,
messageId: msg.id,
});
}
});
});
function persistPromptShownEvent(lastMessage: Message, prompt: MetacognitivePromptData) {
if (!prompt) return;
if (lastMessage.from !== "assistant") return;
if (lastMessage.metacognitiveEvents?.length) return;
const eventData: MetacognitiveEvent = {
type: prompt.type,
promptText: prompt.promptText,
triggerFrequency: prompt.triggerFrequency,
suggestedPersonaId: prompt.suggestedPersonaId,
suggestedPersonaName: prompt.suggestedPersonaName,
timestamp: new Date(),
accepted: false,
};
// Defensive copy using structuredClone
const cleanEventData = structuredClone(eventData);
// Immediately persist locally to prevent race conditions
lastMessage.metacognitiveEvents = [cleanEventData];
metacognitiveLastPromptedAtMessageId = lastMessage.id;
// Ensure cache reflects persisted state
promptCache.set(lastMessage.id, prompt);
// Force message update in parent if needed
if (!browser || !page.params.id) return;
(async () => {
try {
const response = await fetch(
`${base}/api/v2/conversations/${page.params.id}/message/${lastMessage.id}/metacognitive-event`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: prompt.type,
promptText: prompt.promptText,
triggerFrequency: prompt.triggerFrequency,
suggestedPersonaId: prompt.suggestedPersonaId,
suggestedPersonaName: prompt.suggestedPersonaName,
accepted: false,
}),
}
);
if (!response.ok) {
console.error("Failed to log metacognitive prompt shown event:", response.status);
return;
}
const parsed = superjson.parse(await response.text()) as {
nextTargetFrequency?: number;
};
if (parsed.nextTargetFrequency) {
metacognitiveTargetFrequency = parsed.nextTargetFrequency;
}
} catch (e) {
console.error("Failed to log metacognitive prompt shown event:", e);
}
})();
}
$effect(() => {
const { messages, loading, pending, metacognitiveConfig, userSettings } = props();
if (!metacognitiveConfig?.enabled) {
activeMetacognitivePrompt = null;
return;
}
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage.from !== "assistant") {
activeMetacognitivePrompt = null;
if (promptGenerationTimeout) {
clearTimeout(promptGenerationTimeout);
promptGenerationTimeout = undefined;
}
return;
}
if (activeMetacognitivePrompt && activeMetacognitivePrompt.messageId !== lastMessage.id) {
activeMetacognitivePrompt = null;
}
if (loading || pending) {
if (promptGenerationTimeout) {
clearTimeout(promptGenerationTimeout);
promptGenerationTimeout = undefined;
}
return;
}
if (lastMessage.metacognitiveEvents?.length) {
lastProcessedMessageId = lastMessage.id;
return;
}
if (lastProcessedMessageId === lastMessage.id) {
return;
}
if (!promptGenerationTimeout) {
promptGenerationTimeout = setTimeout(() => {
promptGenerationTimeout = undefined;
const currentMessages = props().messages;
const currentLast = currentMessages[currentMessages.length - 1];
if (!currentLast || currentLast.id !== lastMessage.id) return;
if (props().loading || props().pending) return;
lastProcessedMessageId = currentLast.id;
if (currentLast.metacognitiveEvents?.length) return;
// Check cache first to ensure immutability
let prompt = promptCache.get(currentLast.id);
if (prompt === undefined) {
// Only generate if not cached
prompt = determineMetacognitivePrompt(
currentMessages,
metacognitiveConfig,
{
dismissedForMessageId: metacognitivePromptDismissedForMessageId || undefined,
targetFrequency: metacognitiveTargetFrequency || undefined,
},
{
activePersonas: userSettings.activePersonas,
personas: userSettings.personas,
}
);
// Cache the result (even if null) to prevent regeneration
promptCache.set(currentLast.id, prompt);
}
if (prompt && prompt.messageId === currentLast.id) {
persistPromptShownEvent(currentLast, prompt);
activeMetacognitivePrompt = prompt;
} else {
activeMetacognitivePrompt = null;
}
}, 1000);
}
});
function handleMetacognitiveAction(messageId?: string, data?: MetacognitivePromptData) {
const { messages, onmetacognitivebranch } = props();
let promptData = data;
let targetMessageIndex = messages.length - 1;
if (messageId) {
const idx = messages.findIndex((m) => m.id === messageId);
if (idx !== -1) {
targetMessageIndex = idx;
const msg = messages[idx];
if (msg.metacognitiveEvents?.length) {
const event = msg.metacognitiveEvents.find((e) => e.type === "perspective");
if (event) {
event.accepted = true;
// Trigger update in UI? The object is mutated.
promptData = {
type: event.type,
promptText: event.promptText,
triggerFrequency: event.triggerFrequency,
suggestedPersonaId: event.suggestedPersonaId,
suggestedPersonaName: event.suggestedPersonaName,
messageId: msg.id,
linkedMessageId: event.linkedMessageId,
};
}
}
}
}
if (!promptData) {
if (activeMetacognitivePrompt) {
if (!messageId || activeMetacognitivePrompt.messageId === messageId) {
promptData = activeMetacognitivePrompt;
}
}
}
if (!promptData || promptData.type !== "perspective") {
return;
}
if (promptData.linkedMessageId) {
const url = new URL(window.location.href);
url.searchParams.set("msgId", promptData.linkedMessageId);
url.searchParams.set("scrollTo", "true");
goto(url.toString(), { replaceState: false, noScroll: true });
return;
}
const targetMessage = messages[targetMessageIndex];
if (!targetMessage) return;
let previousUserMessageId: string | null = null;
for (let i = targetMessageIndex; i >= 0; i--) {
if (messages[i].from === "user") {
previousUserMessageId = messages[i].id;
break;
}
}
if (!previousUserMessageId || !promptData.suggestedPersonaId) {
return;
}
if (targetMessageIndex === messages.length - 1) {
metacognitivePromptDismissedForMessageId = targetMessage.id;
}
onmetacognitivebranch?.(previousUserMessageId, promptData.suggestedPersonaId, promptData);
}
return {
get activeMetacognitivePrompt() {
return activeMetacognitivePrompt;
},
handleMetacognitiveAction,
};
}