File size: 7,586 Bytes
cb5990d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
import type { Message } from "$lib/types/Message";
import type { MetacognitiveConfig, MetacognitivePromptData } from "$lib/types/Metacognitive";

type DetermineState = {
	/**
	 * If the user dismissed a prompt for a particular message, never show again for that message.
	 */
	dismissedForMessageId?: string;
	/**
	 * Target frequency (in assistant messages) for when to show the next prompt.
	 */
	targetFrequency?: number;
	/**
	 * The ID of the message that most recently triggered a metacognitive prompt (globally).
	 * This helps handle cases where the prompt was on a sibling branch not visible in the current path.
	 */
	lastPromptedAtMessageId?: string;
};

type PersonaContext = {
	/**
	 * Active persona IDs from user settings (if any).
	 */
	activePersonas?: string[];
	/**
	 * Persona definitions from user settings (if any).
	 */
	personas?: Array<{ id: string; name?: string }>;
};

function pickRandom<T>(arr: readonly T[]): T | undefined {
	if (!arr.length) return undefined;
	return arr[Math.floor(Math.random() * arr.length)];
}

function getLastShownMetacognitiveIndex(
	messages: readonly Message[],
	lastPromptedAtMessageId?: string
): number {
	for (let i = messages.length - 1; i >= 0; i--) {
		const msg = messages[i];

		// Reset on messages with metacognitive events
		const events = msg.metacognitiveEvents;
		if (events && events.length > 0) return i;

		// Reset on multi-persona messages (more than 1 persona response)
		if (msg.personaResponses && msg.personaResponses.length > 1) {
			return i;
		}

		// Reset if this message matches the global last prompted ID
		if (lastPromptedAtMessageId && msg.id === lastPromptedAtMessageId) {
			return i;
		}

		// Check if a child of this message (a sibling of the next message in path) was the one prompted.
		// If 'lastPromptedAtMessageId' is in 'msg.children', then 'msg' is the parent of the prompted message.
		// The prompt occurred effectively "at" this junction.
		if (lastPromptedAtMessageId && msg.children?.includes(lastPromptedAtMessageId)) {
			return i;
		}
	}
	return -1;
}

/**
 * Checks if a sibling message (same parent) already has an ACCEPTED "perspective" metacognitive prompt.
 * This prevents suggesting "Want to know what X thinks?" if the user just clicked "Want to know what Y thinks?"
 * and we are now on the Y branch, but the X branch (sibling) had that prompt accepted.
 */
function hasSiblingWithMetacognitiveEvent(
	messages: readonly Message[],
	currentMessage: Message
): boolean {
	const parentId = currentMessage.ancestors?.at(-1);
	if (!parentId) return false;

	// Find parent message
	const parent = messages.find((m) => m.id === parentId);
	if (!parent || !parent.children) return false;

	// Check all siblings (children of same parent, excluding self)
	for (const childId of parent.children) {
		if (childId === currentMessage.id) continue;

		const sibling = messages.find((m) => m.id === childId);
		if (sibling?.metacognitiveEvents?.some((e) => e.type === "perspective" && e.accepted)) {
			return true;
		}
	}

	return false;
}

function countAssistantMessagesAfterIndex(
	messages: readonly Message[],
	idxExclusive: number
): number {
	let count = 0;
	for (let i = idxExclusive + 1; i < messages.length; i++) {
		if (messages[i]?.from === "assistant") count++;
	}
	return count;
}

function getCurrentAssistantPersonaId(message: Message): string | undefined {
	// If the assistant message has exactly one persona response, treat that as the "current" persona.
	if (message.personaResponses && message.personaResponses.length === 1) {
		return message.personaResponses[0]?.personaId;
	}
	return undefined;
}

function pickSuggestedPersona(
	currentPersonaId: string | undefined,
	context: PersonaContext
): { id: string; name: string } | undefined {
	const personas = context.personas ?? [];
	const activeIds = new Set(context.activePersonas ?? []);

	// Select from all personas MINUS the active set (and minus current speaker just in case)
	const candidates = personas.filter((p) => {
		if (!p.id) return false;
		if (activeIds.has(p.id)) return false;
		if (currentPersonaId && p.id === currentPersonaId) return false;
		return true;
	});

	const chosen = pickRandom(candidates);
	if (!chosen) return undefined;
	return { id: chosen.id, name: chosen.name ?? chosen.id };
}

function renderPerspectivePrompt(template: string, personaName: string): string {
	// Use a function replacer so personaName is inserted literally (no `$&`, `$1`, `$`` expansions).
	return template.replace(/\{\{personaName\}\}/g, () => personaName);
}

/**
 * Determine whether a metacognitive prompt should be shown for the current (last) assistant message,
 * and if so, which prompt to show.
 *
 * This function is intentionally pure: it derives the decision from the passed-in messages/config/state.
 */
export function determineMetacognitivePrompt(
	messages: readonly Message[],
	config: MetacognitiveConfig | undefined,
	state: DetermineState | undefined,
	context: PersonaContext | undefined
): MetacognitivePromptData | null {
	if (!config?.enabled) return null;

	const targetFrequency = state?.targetFrequency;
	if (!targetFrequency || !Number.isFinite(targetFrequency) || targetFrequency <= 0) return null;

	const lastMessage = messages[messages.length - 1];
	if (!lastMessage || lastMessage.from !== "assistant") return null;
	if (lastMessage.metacognitiveEvents?.length) return null;
	if (state?.dismissedForMessageId && state.dismissedForMessageId === lastMessage.id) return null;

	// Check if a sibling already triggered a perspective prompt (meaning this branch is the result of one)
	if (hasSiblingWithMetacognitiveEvent(messages, lastMessage)) {
		return null;
	}

	// Frequency gate: only show on the Nth assistant message since the last "shown" event OR multi-persona reset.
	// We pass lastPromptedAtMessageId to account for prompts shown on sibling branches (which we might not see in 'messages', but whose parent we see).
	const lastShownIdx = getLastShownMetacognitiveIndex(messages, state?.lastPromptedAtMessageId);
	const assistantSinceLastShown = countAssistantMessagesAfterIndex(messages, lastShownIdx);

	if (assistantSinceLastShown < targetFrequency) return null;

	// Determine available prompt types
	const currentPersonaId = getCurrentAssistantPersonaId(lastMessage);
	const suggestedPersona = pickSuggestedPersona(currentPersonaId, context ?? {});

	const options: Array<() => MetacognitivePromptData> = [];

	// Option 1: Perspective Prompts (requires a suggested persona)
	if (suggestedPersona && config.perspectivePrompts?.length) {
		const prompts = config.perspectivePrompts;
		options.push(() => {
			const template = pickRandom(prompts) ?? "Want to know what {{personaName}} thinks?";
			return {
				type: "perspective",
				promptText: renderPerspectivePrompt(template, suggestedPersona.name),
				triggerFrequency: targetFrequency,
				suggestedPersonaId: suggestedPersona.id,
				suggestedPersonaName: suggestedPersona.name,
				messageId: lastMessage.id,
			};
		});
	}

	// Option 2: Comprehension Prompts
	if (config.comprehensionPrompts?.length) {
		const prompts = config.comprehensionPrompts;
		options.push(() => {
			const promptText =
				pickRandom(prompts) ??
				"Is there anything in this response that you do not fully understand?";
			return {
				type: "comprehension",
				promptText,
				triggerFrequency: targetFrequency,
				messageId: lastMessage.id,
			};
		});
	}

	// Randomly pick one of the available options
	const chosenOption = pickRandom(options);
	if (!chosenOption) return null;

	return chosenOption();
}