(() => {
// Minimal utilities
const qs = (sel, root=document) => root.querySelector(sel);
const esc = (s) => String((s === null || s === undefined) ? ” : s)
.replaceAll(‘&’,’&’).replaceAll(‘<','<')
.replaceAll('>‘,’>’).replaceAll(‘”‘,’"’)
.replaceAll(“‘”,”'”);
/**
* Allow ONLY basic inline formatting tags that you expect in Formidable content.
* Everything else remains escaped (so no scripts, no arbitrary HTML).
*/
function safeBasicHtml(raw) {
let h = esc(raw);
// allow a small whitelist of tags
h = h.replace(/<(\/?)(strong|em|b|i)>/gi, ‘<$1$2>‘);
h = h.replace(/<br\s*\/?>/gi, ‘
‘);
// preserve newlines as line breaks
h = h.replace(/\r\n|\r|\n/g, ‘
‘);
return h;
}
// Match GTKY behaviour: allow ?lang=en-GB, default en-GB
function getParam(name, fallback = ”) {
const p = new URLSearchParams(window.location.search);
const v = p.get(name);
return String(v == null ? fallback : v).trim();
}
const langVariant = (getParam(‘lang’, ”) || ‘en-GB’).trim() || ‘en-GB’;
// GTKY-equivalent spellcheck, but via your MU proxy (cpVars.insightPage.aiUrl)
async function spellcheckLocale(text) {
try {
const original = String(text || ”).trim();
if (!original) return text;
// Do not spellcheck very short replies like “no”, “yes”, “maybe”, etc.
if (original.length <= 3) return original;
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return original;
const headers = { 'Content-Type': 'application/json' };
if (v.token) headers['X-CP-Token'] = v.token;
else if (v.restNonce) headers['X-WP-Nonce'] = v.restNonce;
const payload = {
model: 'gpt-4o',
temperature: 0,
messages: [{
role: 'user',
content:
`Correct ${langVariant} spelling and grammar only. ` +
`Do not change meaning, do not answer the user, do not explain anything, and do not add content. ` +
`If the text is already fine, return it unchanged. ` +
`Return only the corrected text.\n\n` +
original
}]
};
const res = await fetch(v.aiUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload)
});
const j = await res.json();
let corrected = '';
if (j && typeof j.text === 'string') corrected = j.text.trim();
else {
const alt = j && j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content;
if (typeof alt === 'string') corrected = alt.trim();
}
if (!corrected) return original;
// Reject obvious meta/apology replies from the model
if (
/i need the text you want corrected/i.test(corrected) ||
/please provide the text/i.test(corrected) ||
/i'm sorry/i.test(corrected)
) {
return original;
}
// Reject wildly longer outputs; spellcheck should not rewrite a tiny answer into a sentence
if (corrected.length > original.length + 20) {
return original;
}
return corrected;
} catch (e) {
return text;
}
}
function youtubeEmbedUrl(urlOrId, start=null, end=null) {
if (!urlOrId) return ”;
let id = urlOrId.trim();
// Accept full URLs
const m = id.match(/(?:v=|youtu\.be\/|embed\/)([a-zA-Z0-9_-]{6,})/);
if (m) id = m[1];
let u = `https://www.youtube.com/embed/${encodeURIComponent(id)}`;
const params = new URLSearchParams();
if (start) params.set(‘start’, String(start));
if (end) params.set(‘end’, String(end));
// modest branding
params.set(‘rel’,’0′);
const qs = params.toString();
return qs ? `${u}?${qs}` : u;
}
// Thread state
const thread = [];
// Keep a stable session id (GTKY style)
const SID_KEY = ‘cp_sid’;
let sessionId = getParam(‘sid’, ”) || sessionStorage.getItem(SID_KEY) || ”;
if (!sessionId) {
sessionId = (crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()));
sessionStorage.setItem(SID_KEY, sessionId);
}
// Ensure URL carries sid
if (!getParam(‘sid’,”)) {
const u = new URL(location.href);
u.searchParams.set(‘sid’, sessionId);
history.replaceState(null, ”, u);
}
// Per-Insight stable response_id (so each Insight keeps its own 320/329/338 chain)
function respKeyForThisInsight() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const base = (getParam(‘insight’,”) || v.insightSequence || ‘generic’).trim();
return ‘cp_resp_id_’ + base;
}
// Prevent duplicate 329/338 emits for the same accepted user turn
let metaCommitted = false;
let endReportShown = false;
let aiTurnInProgress = false;
let aiCallCountThisTurn = 0;
let consecutiveDisengagedTurns = 0;
let stepResetAppliedThisPage = false;
function redirectCountKey(stepCfg) {
return stepCfg.stepKey + ‘_redirects_’ + String(stepCfg.currentStep);
}
function getRedirectCount(stepCfg) {
return Number(sessionStorage.getItem(redirectCountKey(stepCfg)) || 0);
}
function setRedirectCount(stepCfg, count) {
sessionStorage.setItem(redirectCountKey(stepCfg), String(count));
}
function clearRedirectCount(stepCfg) {
sessionStorage.removeItem(redirectCountKey(stepCfg));
}
const MAX_AI_CALLS_PER_TURN = 10;
function pushMsg(role, content) {
const isAssistantObject =
role === ‘assistant’ &&
content &&
typeof content === ‘object’;
const safeContent = isAssistantObject
? {
reflection: String(content.reflection || ”).trim(),
question: String(content.question || ”).trim()
}
: String(content ?? ”);
thread.push({ role, content: safeContent });
if (role === ‘user’) metaCommitted = false;
renderThread();
window.cpVars = window.cpVars || {};
window.cpVars.conversationHistory = window.cpVars.conversationHistory || [];
window.cpVars.conversationHistory.push({
role,
content: isAssistantObject
? (safeContent.question || safeContent.reflection)
: safeContent
});
if (window.cpVars.conversationHistory.length > 20) {
window.cpVars.conversationHistory.shift();
}
}
function renderThread() {
const box = qs(‘#cp-ai-thread’);
if (!box) return;
box.innerHTML = ”;
const USER_NAME = getParam(‘nname’, ”) || ‘You’;
const COACH_NAME = ‘AI Coach’;
for (const m of thread) {
const isUser = (m.role === ‘user’);
if (isUser) {
const bubble = document.createElement(‘div’);
bubble.className = ‘cp-bubble cp-user’;
const head = document.createElement(‘div’);
head.className = ‘cp-msg-head cp-user-head’;
head.innerHTML = `👤 ${esc(USER_NAME)}`;
bubble.appendChild(head);
const body = document.createElement(‘div’);
body.className = ‘cp-msg-text’;
body.textContent = String(m.content ?? ”);
bubble.appendChild(body);
box.appendChild(bubble);
continue;
}
const c = (typeof m.content === ‘object’)
? m.content
: { reflection: ”, question: String(m.content ?? ”) };
// reflection block (outside bubble)
if (c.reflection) {
const r = document.createElement(‘div’);
r.className = ‘cp-reflection-block’;
const heading = document.createElement(‘div’);
heading.className = ‘cp-msg-head cp-ai-head cp-reflection-heading’;
heading.textContent = ‘Consider this…’;
const reflectionBody = document.createElement(‘div’);
reflectionBody.className = ‘cp-reflection-body’;
const icon = document.createElement(‘div’);
icon.className = ‘cp-reflection-icon’;
icon.textContent = ‘⚡’;
const text = document.createElement(‘div’);
text.className = ‘cp-reflection-text’;
text.textContent = c.reflection;
reflectionBody.appendChild(icon);
reflectionBody.appendChild(text);
r.appendChild(heading);
r.appendChild(reflectionBody);
box.appendChild(r);
}
// coach bubble (question only)
const bubble = document.createElement(‘div’);
bubble.className = ‘cp-bubble cp-assistant’;
const head = document.createElement(‘div’);
head.className = ‘cp-msg-head cp-ai-head’;
head.innerHTML = `⚡ ${esc(COACH_NAME)}`;
bubble.appendChild(head);
const body = document.createElement(‘div’);
body.className = ‘cp-msg-text’;
body.textContent = c.question || ”;
bubble.appendChild(body);
box.appendChild(bubble);
}
box.scrollTop = box.scrollHeight;
}
function hasUserText() {
return thread.some(m => m.role === ‘user’ && (m.content || ”).trim().length > 0);
}
function buildTranscript(maxChars = 6000) {
const lines = [];
for (const m of thread) {
if (!m?.content) continue;
if (m.role === ‘user’) {
lines.push(`You: ${String(m.content)}`);
continue;
}
if (m.role === ‘assistant’) {
if (typeof m.content === ‘object’) {
if (m.content.reflection) {
lines.push(`Reflection: ${String(m.content.reflection)}`);
}
if (m.content.question) {
lines.push(`AI Coach: ${String(m.content.question)}`);
}
} else {
lines.push(`AI Coach: ${String(m.content)}`);
}
}
}
let t = lines.join(‘\n’);
if (t.length > maxChars) t = t.slice(t.length – maxChars);
return t;
}
async function cpAiFetch(label, url, options) {
aiCallCountThisTurn++;
console.log(‘AI CALL’, aiCallCountThisTurn, label);
if (aiCallCountThisTurn > MAX_AI_CALLS_PER_TURN) {
throw new Error(‘AI call limit exceeded for this turn’);
}
return fetch(url, options);
}
function getRelevantPriorUserSignal(currentUserText = ”) {
const current = String(currentUserText || ”).trim();
const userTurns = thread
.filter(m => m && m.role === ‘user’)
.map(m => String(m.content || ”).trim())
.filter(Boolean);
// The latest user turn is the one we are responding to now, so remove it.
if (current && userTurns.length && userTurns[userTurns.length – 1] === current) {
userTurns.pop();
}
// Pick the most recent substantial prior signal only.
for (let i = userTurns.length – 1; i >= 0; i–) {
const t = userTurns[i];
if (t.length >= 20) return t;
}
return ”;
}
async function decidePriorSignalMention(userText, evalResult) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return ”;
const candidateSignal = getRelevantPriorUserSignal(userText);
if (!candidateSignal) return ”;
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const previousCoachQuestion = (() => {
for (let i = thread.length – 1; i >= 0; i–) {
const m = thread[i];
if (!m || m.role !== ‘assistant’) continue;
if (typeof m.content === ‘object’) {
const q = String(m.content.question || ”).trim();
if (q) return q;
} else {
const q = String(m.content || ”).trim();
if (q) return q;
}
}
return ”;
})();
const insightQuestion = String(v.coachFields?.insightQuestion || v.insight?.question || ”).trim();
const payload = {
model: ‘gpt-4o’,
temperature: 0,
messages: [
{
role: ‘system’,
content:
‘You are deciding whether the coach should briefly refer to one earlier user statement when asking the next question.\n’ +
‘\n’ +
‘Return ONLY valid JSON in this exact form:\n’ +
‘{“use_prior_signal”:true|false,”prior_signal”:”…”}\n’ +
‘\n’ +
‘Rules:\n’ +
‘- This is optional.\n’ +
‘- Use prior material ONLY if it directly helps the current question within the current Insight.\n’ +
‘- The prior signal must be clearly relevant to the same live line of inquiry.\n’ +
‘- Do NOT use prior material just because it is interesting.\n’ +
‘- Do NOT use prior material if it would widen the topic, change topic, or make the coach sound vague.\n’ +
‘- Do NOT rewrite the prior signal into analysis.\n’ +
‘- If used, return the exact prior signal or a very lightly trimmed version of it.\n’ +
‘- If not clearly useful, return {“use_prior_signal”:false,”prior_signal”:””}’
},
{
role: ‘user’,
content:
‘CURRENT INSIGHT QUESTION:\n’ +
insightQuestion + ‘\n’ +
‘\n’ +
‘LAST AI COACH QUESTION:\n’ +
previousCoachQuestion + ‘\n’ +
‘\n’ +
‘GATE RESULT:\n’ +
JSON.stringify(evalResult) + ‘\n’ +
‘\n’ +
‘CURRENT USER REPLY:\n’ +
String(userText || ”).trim() + ‘\n’ +
‘\n’ +
‘CANDIDATE PRIOR USER SIGNAL:\n’ +
candidateSignal
}
]
};
try {
const res = await fetch(v.aiUrl, {
method: ‘POST’,
headers,
body: JSON.stringify(payload)
});
const j = await res.json();
const raw = String(j.text || j.reply || j.content || ”).trim();
const cleaned = raw.replace(/“`json/g, ”).replace(/“`/g, ”).trim();
const parsed = JSON.parse(cleaned);
if (!parsed || !parsed.use_prior_signal) return ”;
return String(parsed.prior_signal || ”).trim().slice(0, 220);
} catch (err) {
console.log(‘PRIOR SIGNAL DECISION ERROR:’, err);
return ”;
}
}
function renderEndOfInsightReport(summaryText = ”, insightText = ”, opts = {}) { const box = qs(‘#cp-end-report’);
if (!box) return;
const summary = String(summaryText || ”).trim();
const insight = String(insightText || ”).trim();
const loading = !!opts.loading;
if (!summary && !insight && !loading) {
box.innerHTML = ”;
box.style.display = ‘none’;
return;
}
if (loading) {
box.innerHTML = `
`;
box.style.display = ‘block’;
return;
}
box.innerHTML = `
${summary ? `
` : “}
${insight ? `
` : “}
`;
box.style.display = ‘block’;
const resumeBtn = qs(‘#cp-end-report-resume’, box);
const nextInsightBtn = qs(‘#cp-end-report-next’, box);
if (resumeBtn) {
resumeBtn.addEventListener(‘click’, () => {
endReportShown = false;
nextConfirmedOverride = false;
renderEndOfInsightReport(”, ”);
const inputWrap = qs(‘.cp-input’);
if (inputWrap) inputWrap.style.display = ”;
const sendBtnEl = qs(‘#cp-ai-send’);
const nextBtnEl = qs(‘#cp-ai-next’);
const ctaTextEl = qs(‘#cp-ai-cta-status’);
if (sendBtnEl) {
sendBtnEl.style.display = ”;
sendBtnEl.disabled = true;
}
if (nextBtnEl) {
nextBtnEl.style.display = ”;
nextBtnEl.hidden = false;
nextBtnEl.disabled = false;
nextBtnEl.removeAttribute(‘disabled’);
nextBtnEl.textContent = ‘End’;
}
if (ctaTextEl) {
ctaTextEl.textContent = ‘Awaiting your response…’;
}
clearNudge();
scheduleNudge();
});
}
if (nextInsightBtn) {
nextInsightBtn.addEventListener(‘click’, () => {
endReportShown = false;
triggerNextNavigation();
});
}
}
async function showEndOfInsightReport() {
endReportShown = true;
nextConfirmedOverride = false;
renderEndOfInsightReport(”, ”, { loading: true });
const summary = await summariseThread();
const endInsight = await generateEndOfInsightInsight(summary);
await saveConversationSummary({ summary });
await saveNow({
status: ‘Ended’,
summary
});
renderEndOfInsightReport(summary, endInsight);
const nextBtnEl = qs(‘#cp-ai-next’);
if (nextBtnEl) {
nextBtnEl.disabled = true;
nextBtnEl.style.display = ‘none’;
}
const inputWrap = qs(‘.cp-input’);
if (inputWrap) inputWrap.style.display = ‘none’;
}
function cpBaseMeta() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
return {
userpass: String(v.userpass || getParam(‘userpass’,”) || ”).trim(),
session: sessionId,
user_id: String(window.currentWpUserId || ”),
edition: String(v.edition || getParam(‘edition’,”) || ”),
kind: ‘insight’,
insight_id: String(getParam(‘insight’,”) || v.insightSequence || ”),
linked_insight_key: String(getParam(‘insight’,”) || v.insightSequence || ”)
};
}
async function postSaveMemory(payload) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.saveUrl || (!v.token && !v.restNonce)) return;
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
return fetch(v.saveUrl, {
method: ‘POST’,
headers,
body: JSON.stringify(payload),
keepalive: true
});
}
// —- 320 (Responses) saver (same purpose as your old saveNow) —-
async function saveNow(payload = {}) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.saveUrl || (!v.token && !v.restNonce)) return;
if (!hasUserText()) return;
const RESP_KEY = respKeyForThisInsight();
let responseId = sessionStorage.getItem(RESP_KEY);
if (!responseId) {
responseId = (crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()));
sessionStorage.setItem(RESP_KEY, responseId);
}
// derive last user / last AI
let lastUser = ”, lastAI = ”;
for (let i = thread.length – 1; i >= 0; i–) {
const m = thread[i];
if (!m || !m.content) continue;
if (!lastUser && m.role === ‘user’) {
lastUser = String(m.content || ”).trim();
}
if (!lastAI && m.role === ‘assistant’) {
if (typeof m.content === ‘object’) {
lastAI = String(m.content.question || ”).trim();
} else {
lastAI = String(m.content || ”).trim();
}
}
if (lastUser && lastAI) break;
}
const body = Object.assign({}, cpBaseMeta(), {
response_id: responseId,
dialogue: buildTranscript(),
response_sequence: thread.filter(m => m.role === ‘user’).length,
status: ‘Live’,
insight_sequence: v.insightSequence || getParam(‘insight’,”) || ”,
tone: v.tone || getParam(‘tone’,’direct’) || ‘direct’,
}, payload);
if (!(‘user_input’ in body) && lastUser) body.user_input = lastUser;
if (!(‘ai_reply’ in body) && lastAI) body.ai_reply = lastAI;
// strip empties
[‘ai_reply’,’summary’,’dialogue’,’user_input’].forEach(k => {
if (k in body && String(body[k]).trim() === ”) delete body[k];
});
await postSaveMemory(body);
}
// —- Summary generator (used to feed 329/338 + stored on 320) —-
async function summariseThread() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return ”;
// Use only the recent tail to keep it cheap + focused
const transcript = buildTranscript(2500);
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const payload = {
model: ‘gpt-4o’,
temperature: 0.2,
messages: [{
role: ‘user’,
content:
`Write a concise, Insight-specific coach summary of the conversation so far.\n` +
`\n` +
`OBJECTIVE:\n` +
`- State what you have noticed about the user, speaking directly to them.\n` +
`\n` +
`VOICE:\n` +
`- Write directly to the user using “you”.\n` +
`- Do NOT refer to “the user”.\n` +
`- Do NOT describe them from the outside.\n` +
`\n` +
`FORM:\n` +
`- 2–5 sentences.\n` +
`- Sound like a coach reflecting back what you have observed.\n` +
`\n` +
`CONSTRAINTS:\n` +
`- Stay grounded in what they actually said.\n` +
`- Focus on what they revealed, tensions, values, and motivations.\n` +
`- Do NOT generalise beyond the evidence.\n` +
`- Do NOT give generic productivity advice.\n` +
`- Do NOT use therapeutic or diagnostic language.\n` +
`\n` +
`TRANSCRIPT:\n${transcript}`
}]
};
const res = await fetch(v.aiUrl, { method: ‘POST’, headers, body: JSON.stringify(payload) });
const j = await res.json();
const text = (j && (j.text || j.reply || j.content)) ? String(j.text || j.reply || j.content) : ”;
return text.trim();
}
async function generateEndOfInsightInsight(summaryText) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return ”;
const transcript = buildTranscript(3000);
const insightQuestion = String(v.coachFields?.insightQuestion || v.insight?.question || ”).trim();
const insightText = String(v.insight?.text || ”).trim().slice(0, 1200);
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const payload = {
model: ‘gpt-4o’,
temperature: 0.2,
messages: [{
role: ‘user’,
content:
`You are deciding whether the coach has enough evidence to offer one insight at the end of this Insight.\n` +
`Return ONLY valid JSON in this exact form:\n` +
`{“should_offer_insight”:true|false,”insight”:”…”}\n\n` +
`Rules:\n` +
`- The insight is optional.\n` +
`- Only offer one if the conversation provides enough evidence.\n` +
`- Base it on repeated or reinforced patterns, not one isolated sentence.\n` +
`- Stay grounded in what the user has actually shared.\n` +
`- The insight MUST show how the user’s pattern affects their ability to engage with the core idea of this Insight.\n` +
`- The Insight idea must be part of the explanation, not mentioned separately at the end.\n` +
`- Explain whether the user’s behaviour supports, limits, or complicates that idea.\n` +
`- Keep it short and direct.\n` +
`- Do NOT introduce a new theory, diagnosis, or abstraction.\n` +
`- Keep it to 1–2 sentences max.\n` +
`- If there is not enough evidence, return {“should_offer_insight”:false,”insight”:””}\n\n` +
`CURRENT INSIGHT:\n` +
`Question: ${insightQuestion}\n` +
`Core idea: ${insightText}\n\n` +
`SUMMARY:\n${String(summaryText || ”).trim()}\n\n` +
`TRANSCRIPT:\n${transcript}`
}]
};
try {
const res = await fetch(v.aiUrl, {
method: ‘POST’,
headers,
body: JSON.stringify(payload)
});
const j = await res.json();
const raw = String(j.text || j.reply || j.content || ”).trim();
const cleaned = raw.replace(/“`json/g, ”).replace(/“`/g, ”).trim();
const parsed = JSON.parse(cleaned);
if (!parsed || !parsed.should_offer_insight) return ”;
return String(parsed.insight || ”).trim();
} catch (err) {
console.log(‘END OF INSIGHT INSIGHT ERROR:’, err);
return ”;
}
}
// —- 329 trait scorer (LLM JSON) —-
async function scoreTraitsWithLLM({ questionText, answerText, miniSummary }) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return { traits: [] };
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const system =
`You are scoring Change Pathway trait signals from ONE user answer.\n` +
`Traits: Awareness, Balance, Commitment, Drive, Engagement, Flexibility, Groundedness, Heart.\n` +
`Return ONLY valid JSON of the form:\n` +
`{“traits”:[{“trait”:”Awareness”,”quality”:”Positive|Neutral|Negative|Caution”,”confidence”:1-10,”rationale”:”…”,”quote”:”…”}]}\n` +
`Rules:\n` +
`- Max 3 traits.\n` +
`- Be conservative; if weak evidence, return []\n` +
`- quote must be exact user words (a short phrase).\n`;
const user =
`INSIGHT QUESTION:\n${questionText}\n\n` +
`USER ANSWER:\n${answerText}\n\n` +
`MINI SUMMARY:\n${miniSummary}\n`;
const payload = {
model: ‘gpt-4o’,
temperature: 0,
messages: [
{ role: ‘system’, content: system },
{ role: ‘user’, content: user }
]
};
const res = await fetch(v.aiUrl, { method: ‘POST’, headers, body: JSON.stringify(payload) });
const j = await res.json();
const raw = (j && (j.text || j.reply || j.content)) ? String(j.text || j.reply || j.content) : ”;
try {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.traits)) return parsed;
} catch {}
return { traits: [] };
}
// —- Chapter map + classifier (338) —-
async function ensureChapterMapLoaded() {
try {
if (window.cpChapterMap && Object.keys(window.cpChapterMap).length) return window.cpChapterMap;
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const headers = {};
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const res = await fetch(‘/wp-json/cp/v1/chapter-map’, { headers });
if (!res.ok) return null;
const json = await res.json();
const map = (json && json.map && typeof json.map === ‘object’) ? json.map : {};
window.cpChapterMap = map;
return map;
} catch {
return null;
}
}
function buildChapterCatalogue(map) {
if (!map) return ”;
const lines = [];
for (const [section, row] of Object.entries(map)) {
if (!row) continue;
const title = (row.title || ”).trim();
const summary = (row.summary || ”).trim();
const kws = (row.keywords || []).join(‘, ‘);
lines.push(
`${section} — ${title || ‘Untitled’}\n` +
` Summary: ${summary || ‘(no summary)’}\n` +
` Keywords: ${kws || ‘(none)’}`
);
}
return lines.join(‘\n\n’);
}
async function classifyChapterForAnswer({ questionText, answerText, summary }) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return null;
const ans = (answerText || ”).trim();
if (!ans) return null;
const map = await ensureChapterMapLoaded();
if (!map || !Object.keys(map).length) return null;
const catalogue = buildChapterCatalogue(map);
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const system =
`You are a conservative classifier.\n` +
`Pick ONE best-fitting chapter for the user’s answer, or NO_CHAPTER.\n` +
`Use chapter SUMMARY as the primary anchor; keywords are secondary.\n` +
`Return ONLY valid JSON:\n` +
`{“chapter_key”:”1.1|1.2|…|NO_CHAPTER”,”matched_terms”:[“…”],”confidence”:1-10,”quality”:”Positive|Neutral|Negative|Caution”}\n` +
`If confidence <= 6 you MUST return NO_CHAPTER.\n`;
const user =
`INSIGHT QUESTION:\n${questionText}\n\n` +
`USER ANSWER:\n${answerText}\n\n` +
`MINI SUMMARY:\n${summary}\n\n` +
`CHAPTER CATALOGUE:\n${catalogue}\n`;
const payload = {
model: 'gpt-4o',
temperature: 0,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user }
]
};
const res = await fetch(v.aiUrl, { method: 'POST', headers, body: JSON.stringify(payload) });
const j = await res.json();
const raw = (j && (j.text || j.reply || j.content)) ? String(j.text || j.reply || j.content) : '';
try {
const parsed = JSON.parse(raw);
if (!parsed || !parsed.chapter_key) return null;
if (parsed.chapter_key === 'NO_CHAPTER') return null;
return parsed;
} catch {
return null;
}
}
// ---- Main GTKY-style meta emitter: 329 + 338 (append-only), once per user turn ----
async function saveConversationSummary({ summary }) {
try {
if (metaCommitted) return;
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.saveUrl || (!v.token && !v.restNonce)) return;
// last user / last AI
let lastUser = '', lastAI = '';
for (let i = thread.length - 1; i >= 0; i–) {
const m = thread[i];
if (!m || !m.content) continue;
if (!lastUser && m.role === ‘user’) lastUser = m.content || ”;
if (!lastAI && m.role === ‘assistant’) lastAI = m.content || ”;
if (lastUser && lastAI) break;
}
const trimmedLastUser = (lastUser || ”).trim();
const trimmedSummary = (summary || ”).trim();
if (!trimmedLastUser && !trimmedSummary) return;
const RESP_KEY = respKeyForThisInsight();
let responseId = sessionStorage.getItem(RESP_KEY);
if (!responseId) {
responseId = (crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()));
sessionStorage.setItem(RESP_KEY, responseId);
}
const dialogueTail = (() => {
const txt = buildTranscript(1200);
return txt.length > 800 ? txt.slice(-800) : txt;
})();
const base = Object.freeze(Object.assign({}, cpBaseMeta(), {
response_sequence: thread.filter(m => m.role === ‘user’).length,
response_id: responseId,
user_input: lastUser || ”,
}));
// Use Insight question as the “prompt text”
const questionText = String(v.coachFields?.insightQuestion || v.insight?.question || ”).trim();
// (1) 329 traits (append-only)
const traitSignals = await scoreTraitsWithLLM({
questionText,
answerText: lastUser,
miniSummary: trimmedSummary || ”
});
const traits = Array.isArray(traitSignals?.traits) ? traitSignals.traits : [];
for (const t of traits) {
if (!t || !t.trait) continue;
// normalise confidence to 1–10 number
let conf = Number(t.confidence);
if (!Number.isFinite(conf)) conf = 7;
conf = Math.max(1, Math.min(10, Math.round(conf)));
const allowedQual = [‘Positive’,’Neutral’,’Negative’,’Caution’];
const q = String(t.quality || ‘Neutral’).trim();
const qNorm = q ? (q[0].toUpperCase() + q.slice(1).toLowerCase()) : ‘Neutral’;
const quality = allowedQual.includes(qNorm) ? qNorm : ‘Neutral’;
const traitPayload = Object.freeze({
…base,
append_only: true,
framework: ‘Change Pathway’,
trait: String(t.trait || ”).trim(),
quality,
confidence: conf,
rationale: String(t.rationale || ”).trim().slice(0, 300) || trimmedLastUser.slice(0, 300),
quote: String(t.quote || ”).trim() || trimmedLastUser,
dialogue_tail: dialogueTail
});
try { await postSaveMemory(traitPayload); } catch {}
}
// (2) 338 chapter (append-only, 0 or 1)
const chapterDecision = await classifyChapterForAnswer({
questionText,
answerText: lastUser,
summary: trimmedSummary || ”
});
if (chapterDecision) {
const selectedQuotes = (() => {
const s = (lastUser || ”).split(/(?<=[.!?])\s+/).map(x => x.trim()).filter(Boolean);
return s.slice(0, 3);
})();
const chapterPayload = Object.freeze({
…base,
append_only: true,
chapter_key: chapterDecision.chapter_key,
matched_terms: chapterDecision.matched_terms || [],
quality: chapterDecision.quality || ‘Neutral’,
confidence: Math.max(1, Math.min(10, Math.round(Number(chapterDecision.confidence) || 8))),
rationale: trimmedSummary
? trimmedSummary.replace(/^\s*[\u2022\-\*]+\s*/gm, ”).slice(0, 900)
: trimmedLastUser.slice(0, 300),
chapter_quote: selectedQuotes.join(‘ | ‘),
quotes: selectedQuotes,
dialogue_tail: dialogueTail
});
try { await postSaveMemory(chapterPayload); } catch {}
}
metaCommitted = true;
} catch {
// swallow: we never want meta emit failures to break the UI
}
}
function getStepConfig() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const cf = v.coachFields || {};
const rawSteps = String(cf.ai_coach_guide_steps || ”)
.split(/\s*\d+\.\s+/)
.map(s => s.trim())
.filter(Boolean);
const stepKey = ‘cp_step_’ + ((v.insightSequence || getParam(‘insight’,”) || ‘generic’).trim());
// 🔧 Reset step once per page load, BEFORE any read
if (getParam(‘reset’,”) === ‘1’ && !stepResetAppliedThisPage) {
sessionStorage.removeItem(stepKey);
stepResetAppliedThisPage = true;
}
console.log(‘STEP READ:’, {
key: stepKey,
stored: sessionStorage.getItem(stepKey),
reset: getParam(‘reset’,”)
});
let currentStep = Number(sessionStorage.getItem(stepKey) || 0);
if (!Number.isFinite(currentStep) || currentStep < 0) currentStep = 0;
if (rawSteps.length && currentStep >= rawSteps.length) currentStep = rawSteps.length – 1;
return {
stepKey,
steps: rawSteps,
currentStep,
completionCriteria: cf.step_completion_criteria || ”,
fallbackRoutes: cf.fallback_routes || ”,
allowedMoves: cf.allowed_moves || ”,
disallowedMoves: cf.disallowed_moves || ”,
closureRule: cf.closure_rule || ”
};
}
async function evaluateTurn(userText) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) {
return {
answered_question: false,
within_insight: true,
non_answer: false,
redirect_required: true,
advance: false,
fallback: false,
close: false
};
}
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const previousCoachQuestion = (() => {
for (let i = thread.length – 1; i >= 0; i–) {
const m = thread[i];
if (!m || m.role !== ‘assistant’) continue;
if (typeof m.content === ‘object’) {
const q = String(m.content.question || ”).trim();
if (q) return q;
} else {
const q = String(m.content || ”).trim();
if (q) return q;
}
}
return ”;
})();
const insightQuestion = String(v.coachFields?.insightQuestion || v.insight?.question || ”).trim();
const payload = {
model: ‘gpt-4o’,
temperature: 0,
messages: [
{
role: ‘system’,
content:
`You are the first-stage gate in a structured coaching dialogue.
Return ONLY valid JSON in this exact form:
{
“answered_question”: true|false,
“step_sufficient”: true|false,
“within_insight”: true|false,
“non_answer”: true|false,
“redirect_required”: true|false,
“advance”: true|false,
“fallback”: true|false,
“close”: true|false
}
Rules:
answered_question:
– true ONLY if the user directly answers the last AI Coach question.
– If the last AI Coach question asks for a specific example, answered_question must be true ONLY when the user gives a specific example of the thing asked for.
– If the last AI Coach question asks about interests, the answer must name or describe an interest.
– If the last AI Coach question asks about working style, the answer must describe how the user works, acts, approaches tasks, or behaves when engaged.
– A meaningful belief, pattern, or emotionally important statement does NOT count as answered_question unless it answers the actual question asked.
– Do NOT treat something as answered_question merely because it is relevant to the wider Insight theme.
– Statements about money, status, security, or practical choices do NOT count as answering an emotional resonance question unless the user explicitly describes their reaction to the phrase itself.
– false if the user:
– answers a different question
– switches to a different part of the Insight
– gives a belief instead of the requested example
– gives an example of the wrong thing
– gives filler with no direct answer
within_insight:
– true ONLY if the reply stays within the current Insight theme
– false if it drifts into a different topic, request, or task
non_answer:
– true only for replies that are vague, evasive, minimal, or non-committal AND do not add useful material to the current line of inquiry
– examples: “I don’t know”, “not sure”, “maybe”, obvious avoidance, or empty filler
– if the reply is relevant and adds evidence, example, consequence, or pattern, non_answer must be false
redirect_required:
– true if within_insight is false.
– true if step_sufficient is false and answered_question is false.
– false if step_sufficient is true, even if answered_question is false.
advance:
– true ONLY if step_sufficient is true and the reply gives enough substance to move to the next framework step.
– If step_sufficient is false, advance must be false.
– Do NOT advance just because the reply is emotionally interesting.
– Do advance if the core informational goal of the step has been met, even indirectly.
fallback:
– true only if the reply is relevant but thin, hesitant, or under-developed, and the coach should stay with the same line and gently deepen it
close:
– true ONLY if the current line of inquiry is clearly exhausted and no further depth is emerging
– otherwise false
Important:
– If redirect_required is true, then advance, fallback, and close must all be false.
– Be conservative about redirecting.
– If the reply is relevant to the same pattern or line of inquiry, do NOT redirect.
– If unsure whether the user answered the actual question, prefer redirect_required = true.`
},
{
role: ‘user’,
content:
`CURRENT INSIGHT QUESTION:
${insightQuestion}
LAST AI COACH QUESTION:
${previousCoachQuestion}
USER REPLY:
${String(userText || ”).trim()}`
}
]
};
try {
const res = await fetch(v.aiUrl, {
method:’POST’,
headers,
body: JSON.stringify(payload)
});
const j = await res.json();
const raw = j.text || j.reply || j.content || ”;
const parsed = JSON.parse(raw);
return {
answered_question: !!parsed.answered_question,
step_sufficient: !!parsed.step_sufficient,
within_insight: !!parsed.within_insight,
non_answer: !!parsed.non_answer,
redirect_required: !!parsed.redirect_required,
advance: !!parsed.advance,
fallback: !!parsed.fallback,
close: !!parsed.close
};
} catch {
return {
answered_question: false,
within_insight: true,
non_answer: false,
redirect_required: true,
advance: false,
fallback: false,
close: false
};
}
}
async function classifyTurnWithAI(userText) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) {
return { turnType: ‘partial’, reflectionMode: ‘none’, focus: ’cause’ };
}
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const transcript = buildTranscript(1800);
const previousCoachQuestion = (() => {
for (let i = thread.length – 1; i >= 0; i–) {
const m = thread[i];
if (m && m.role === ‘assistant’ && String(m.content || ”).trim()) {
return String(m.content || ”).trim();
}
}
return ”;
})();
const system =
`You are classifying the user’s latest coaching reply.\n` +
`Return ONLY valid JSON in this exact form:\n` +
`{“turnType”:”insight|example|preference|repeat|disengaged|partial”,”reflectionMode”:”none|full”,”focus”:”cause|belief|fear|tradeoff|implication”}\n` +
`\n` +
`Definitions:\n` +
`- insight: the user expresses a meaningful self-understanding AND it directly answers the current AI Coach question.
– A belief that ignores the actual question does NOT count as insight.
– If the coach asked for an example and the user gives a belief instead, classify as partial.\n` +
`- example: the user gives a concrete instance or past event.\n` +
`- preference: the user states what they prefer, tend to choose, value, or prioritise.\n` +
`- repeat: the user mainly repeats an earlier point without meaningful new information.\n` +
`- disengaged: the user avoids, refuses, shrugs off, or gives very low-engagement input.\n` +
`- partial: the user gives some relevant content but not enough to count as one of the above.\n` +
`\n` +
`REFLECTION CONTROL RULE (CRITICAL):\n` +
`- Reflection is rare. Most turns should NOT produce a reflection.\n` +
`- Only allow reflection when the user reveals a clear belief, internal logic, fear, trade-off, or meaningful implication.\n` +
`- Do NOT reflect simple preferences, behaviours, examples, or surface statements.\n` +
`- Do NOT reflect if the insight is already obvious from the user’s words.\n` +
`- Do NOT reflect if it would only restate or tidy what the user said.\n` +
`- If in doubt, choose “none”.\n` +
`- Reflection must only be used when it adds a genuinely new perspective.\n` +
`\n` +
`Examples where reflection MUST be “none”:\n` +
`- “I like business videos”\n` +
`- “I stayed in my job for the pay”\n` +
`- “I usually play it safe”\n` +
`- any simple example without interpretation\n` +
`\n` +
`Examples where reflection MAY be “full”:\n` +
`- the user links money to identity, respect, or worth\n` +
`- the user reveals a hidden assumption or belief\n` +
`- the user exposes a tension or internal conflict\n` +
`\n` +
`Examples where reflectionMode should usually be “none”:\n` +
`- “I like stability.”\n` +
`- “I usually avoid conflict.”\n` +
`- “I chose the safer option.”\n` +
`- “I tend to overthink things.”\n` +
`- a concrete example with no real interpretation\n` +
`\n` +
`Examples where reflectionMode may be “full”:\n` +
`- the user reveals a belief, fear, trade-off, or implication that was not previously explicit\n` +
`- the user links something meaningful with danger, guilt, pressure, or loss\n` +
`- the user exposes an internal conflict with real consequences\n` +
`\n`;
const user =
`PREVIOUS AI COACH QUESTION:\n${previousCoachQuestion}\n\n` +
`USER REPLY:\n${String(userText || ”).trim()}\n\n` +
`RECENT TRANSCRIPT:\n${transcript}\n`;
const payload = {
model: ‘gpt-4o’,
temperature: 0,
messages: [
{ role: ‘system’, content: system },
{ role: ‘user’, content: user }
]
};
try {
const res = await fetch(v.aiUrl, {
method: ‘POST’,
headers,
body: JSON.stringify(payload)
});
const j = await res.json();
const raw = (j && (j.text || j.reply || j.content)) ? String(j.text || j.reply || j.content) : ”;
console.log(‘TURN CLASSIFICATION RAW:’, raw);
const parsed = JSON.parse(raw);
const allowedTypes = [‘insight’, ‘example’, ‘preference’, ‘repeat’, ‘disengaged’, ‘partial’];
const allowedReflectionModes = [‘none’, ‘full’];
const allowedFocus = [’cause’, ‘belief’, ‘fear’, ‘tradeoff’, ‘implication’];
const turnType = allowedTypes.includes(parsed.turnType) ? parsed.turnType : ‘partial’;
const reflectionMode = allowedReflectionModes.includes(parsed.reflectionMode) ? parsed.reflectionMode : ‘none’;
const focus = allowedFocus.includes(parsed.focus) ? parsed.focus : ’cause’;
console.log(‘TURN CLASSIFICATION PARSED:’, { turnType, reflectionMode, focus });
return { turnType, reflectionMode, focus };
} catch (err) {
console.log(‘TURN CLASSIFICATION ERROR:’, err);
return { turnType: ‘partial’, reflectionMode: ‘none’, focus: ’cause’ };
}
}
async function planReflection(userText, replyDecision) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return null;
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const transcript = buildTranscript(2200);
const cleanUserText = String(userText || ”).trim();
const payload = {
model: ‘gpt-4o’,
temperature: 0,
messages: [
{
role: ‘system’,
content:
`You are planning a coaching reflection.
Return ONLY valid JSON in this exact form:
{
“should_reflect”: true|false,
“pattern”: “…”,
“subject_type”: “self|external”,
“lens”: “cost|shift|tension|self|behaviour”
}
Rules:
– Decide first whether a reflection is genuinely warranted.
– Only reflect if the user has revealed something meaningfully new.
– Do NOT approve a reflection if it would only paraphrase the user’s words.
– Do NOT write the reflection itself.
– Do NOT reflect tensions, contradictions, or trade-offs that are already fully explicit in the user’s own wording.
– Only reflect if the reflection would reveal something implied but not directly stated.
– If the user has already clearly named the pattern or tension themselves, reflection should usually be false.
– Reflections must create genuine lift or compression, not simply restate or tidy what the user already said.
– A reflection should help the user see something slightly more clearly than before.
– Use ONLY what the user has actually said.
– Do NOT infer causes, motives, or history that are not explicit.
– Preserve the direction of causality exactly as stated.
– Keep “pattern” short, literal, and concrete.
– Extract the user’s full pattern, not just a final cause-effect fragment.
– If the user expresses a sequence or contrast (e.g. “X but then Y, so Z”), keep all important parts.
– If reflection is not warranted, return:
{“should_reflect”:false,”pattern”:””,”subject_type”:”external”,”lens”:”behaviour”}
– subject_type must be:
– “self” if the user’s trust, doubt, or reaction is directed at themselves
– “external” if it is directed at a thing, feeling, reaction, or situation
LENS SELECTION RULE:
– Choose ONE lens that best fits the user’s response:
cost:
– use when something is being cut short, limited, or not followed through
shift:
– use when there is a clear change over time (e.g. start → drop off)
tension:
– use when two forces or positions are in conflict
self:
– use when the user is describing how they relate to themselves (e.g. trust, belief)
behaviour:
– use when the pattern is mainly about repeated actions (start → stop)
– Select the most natural fit.
– Do NOT return more than one lens.`
},
{
role: ‘user’,
content:
`USER REPLY:
${cleanUserText}
CLASSIFICATION:
– turnType = ${replyDecision.turnType}
– reflectionMode = ${replyDecision.reflectionMode}
– focus = ${replyDecision.focus}
RECENT TRANSCRIPT:
${transcript}`
}
]
};
try {
const res = await fetch(v.aiUrl, {
method: ‘POST’,
headers,
body: JSON.stringify(payload)
});
const j = await res.json();
const raw = String(j.text || j.reply || j.content || ”).trim();
console.log(‘REFLECTION PLAN RAW:’, raw);
const cleaned = raw.replace(/“`json/g, ”).replace(/“`/g, ”).trim();
const parsed = JSON.parse(cleaned);
return {
should_reflect: !!parsed.should_reflect,
pattern: String(parsed.pattern || ”).trim(),
subject_type: String(parsed.subject_type || ‘external’).trim(),
lens: String(parsed.lens || ‘behaviour’).trim()
};
} catch (err) {
console.log(‘REFLECTION PLAN ERROR:’, err);
return null;
}
}
async function writeReflection(reflectionPlan) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return ”;
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const pattern = String(reflectionPlan?.pattern || ”).trim();
const subjectType = String(reflectionPlan?.subject_type || ‘external’).trim();
const lens = String(reflectionPlan?.lens || ‘behaviour’).trim();
if (!pattern) return ”;
const payload = {
model: ‘gpt-4o’,
temperature: 0.2,
messages: [
{
role: ‘system’,
content: `You are writing a short coaching reflection from a fixed plan.
Write ONLY the reflection text.
Rules:
– Be concise: 1 sentence, maximum 2.
– Stay very close to the user’s actual words.
– Do NOT generalise beyond what is clearly implied.
– Do NOT give advice, warnings, reassurance, or moral judgement.
– Do NOT explain consequences or outcomes.
– Do NOT sound like an article, teacher, or therapist.
– State the pattern, belief, tension, or link plainly.
– Keep the tone neutral, direct, and observational.
– Do not soften or protect the user’s behaviour. If a pattern is uncomfortable or contradictory, state it plainly without judgement.
– Avoid abstract words like “tension”, “dynamic”, or “pattern of behaviour”. Use concrete, everyday language.
– Do NOT wrap the reflection in quotation marks.
– If the reflection only paraphrases or restates the user’s words without adding a new angle, return an empty string.
Good example:
“It sounds like respect is closely tied to money for you.”
Bad example:
“Equating financial success with respect can create a fragile foundation for your self-worth.”
Use the structured plan as the source of truth.
Stay within this pattern only.`
},
{
role: ‘user’,
content: `Write a reflection from this plan only:
subject_type: ${subjectType}
pattern: ${pattern}
lens: ${lens}`
}
]
};
try {
const res = await fetch(v.aiUrl, {
method: ‘POST’,
headers,
body: JSON.stringify(payload)
});
const j = await res.json();
let raw = String(j.text || j.reply || j.content || ”).trim();
// Remove wrapping quotes
raw = raw.replace(/^[““”’]+|[““”’]+$/g, ”).trim();
const lower = raw.toLowerCase();
const patternLower = pattern.toLowerCase();
// Reject weak/parrot reflections
if (
!raw ||
lower === patternLower ||
(
lower.includes(patternLower) &&
!lower.startsWith(‘it sounds like’) &&
!lower.startsWith(‘you seem to’)
)
) {
return ”;
}
console.log(‘REFLECTION WRITE RAW:’, raw);
return raw;
} catch (err) {
console.log(‘REFLECTION WRITE ERROR:’, err);
return ”;
}
}
async function generateReflection(userText, replyDecision, evalResult) {
if (!evalResult || !evalResult.advance) return ”;
if (
!replyDecision ||
replyDecision.reflectionMode !== ‘full’
) {
return ”;
}
// Never reflect during redirect/fallback loops
if (
evalResult.redirect_required ||
evalResult.fallback
) {
return ”;
}
try {
const reflectionPlan = await planReflection(userText, replyDecision);
if (!reflectionPlan || !reflectionPlan.should_reflect) return ”;
if (!reflectionPlan.pattern) return ”;
console.log(‘REFLECTION PLAN PARSED:’, reflectionPlan);
return await writeReflection(reflectionPlan);
} catch (err) {
console.log(‘REFLECTION GENERATION ERROR:’, err);
return ”;
}
}
async function callAI(
userText,
evalResult,
replyDecision,
priorSignal = ”,
forcedStepAdvance = false
) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const payload = {
kind: ‘insight’,
userpass: v.userpass,
edition: v.edition,
insight_sequence: v.insightSequence,
tone: v.tone || ‘direct’,
mode: v.mode || null,
insight: v.insight || null,
videos: v.videos || [],
messages: [
{
role: ‘system’,
content: (() => {
const cf = v.coachFields || {};
const stepCfg = getStepConfig();
const steps = stepCfg.steps;
const currentStep = stepCfg.currentStep;
const completionCriteria = stepCfg.completionCriteria;
const fallbackRoutes = stepCfg.fallbackRoutes;
const allowedMoves = stepCfg.allowedMoves;
const disallowedMoves = stepCfg.disallowedMoves;
const closureRule = stepCfg.closureRule;
console.log(‘STEP CONFIG AT CALLAI:’, {
steps,
currentStep,
currentStepInstruction: steps[currentStep],
completionCriteria,
fallbackRoutes,
allowedMoves,
disallowedMoves,
closureRule
});
return (
`You are the question generator in a structured AI Coach dialogue.\n` +
`Your job is ONLY to ask the next coaching question.\n` +
`\n` +
`AUTHORITY RULE:\n` +
`- Do not decide whether to progress, redirect, reflect, or close.\n` +
`- That has already been decided by the evaluation result and JS step logic.\n` +
`- Use the CURRENT framework step as the source of direction.\n` +
`- Use the user’s wording only for continuity, not for choosing the topic.\n` +
`\n` +
`CURRENT STEP:\n` +
`${steps[currentStep] || ‘No step defined’}\n` +
`\n` +
`EVALUATION RESULT:\n` +
`${JSON.stringify(evalResult)}\n` +
`\n` +
`FAILED REDIRECTS ON THIS STEP:\n` +
`${getRedirectCount(stepCfg)}\n` +
`\n` +
`FORCED STEP ADVANCE:\n` +
`${forcedStepAdvance ? ‘YES’ : ‘NO’}\n` +
`\n` +
`FRAMEWORK DATA:\n` +
`Completion criteria: ${completionCriteria}\n` +
`Fallback routes: ${fallbackRoutes}\n` +
`Allowed moves: ${allowedMoves}\n` +
`Disallowed moves: ${disallowedMoves}\n` +
`Closure rule: ${closureRule}\n` +
`\n` +
`QUESTION RULES:\n` +
`- Ask ONE question only.\n` +
`- The question must execute the current framework step.\n` +
`- If FORCED STEP ADVANCE is YES, treat the previous step as closed and ask from the new current step only.\n` +
`- Do not continue the user’s tangent.\n` +
`- Do not turn the exchange into advice, action-planning, career optimisation, therapy, or general problem-solving.\n` +
`- Do not ask about practical next steps unless the current framework step explicitly asks for them.\n` +
`- Do not explore consequences, impacts, or implications unless the current framework step explicitly asks for them.\n` +
`- If redirect_required is true, ask a simpler version of the current step question.\n` +
`- If fallback is true, make the current step easier or more concrete.\n` +
`- If advance is true, ask from the current framework step now shown above.\n` +
`- Keep the wording conversational, direct, and concise.\n` +
`- You may lightly use the user’s words, but the framework chooses the direction.\n` +
`- Do not praise, reassure, summarise, interpret, or explain.\n` +
`- Use UK English.\n`
);
})()
},
…thread.map(m => {
if (!m) return null;
if (m.role === ‘assistant’) {
if (typeof m.content === ‘object’) {
return {
role: ‘assistant’,
content: String(m.content.question || ”).trim()
};
}
return {
role: ‘assistant’,
content: String(m.content || ”).trim()
};
}
if (m.role === ‘user’) {
return {
role: ‘user’,
content: String(m.content || ”).trim()
};
}
return null;
}).filter(m => m && m.content),
{ role: ‘user’, content: String(userText || ”).trim() }
]
};
const res = await fetch(v.aiUrl, {
method: ‘POST’,
headers,
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(`AI error ${res.status}`);
const data = await res.json();
return String(data.text || data.reply || data.content || ”).trim();
}
async function callAI_redirectOnly(userText, evalResult) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const previousCoachQuestion = (() => {
for (let i = thread.length – 1; i >= 0; i–) {
const m = thread[i];
if (!m || m.role !== ‘assistant’) continue;
if (typeof m.content === ‘object’) {
const q = String(m.content.question || ”).trim();
if (q) return q;
} else {
const q = String(m.content || ”).trim();
if (q) return q;
}
}
return ”;
})();
const insightQuestion = String(v.coachFields?.insightQuestion || v.insight?.question || ”).trim();
const payload = {
model: ‘gpt-4o’,
temperature: 0.2,
messages: [
{
role: ‘system’,
content:
`You are redirecting a user back into a structured Insight coaching dialogue.
Return ONE question only.
Rules:
– Do NOT begin with “Please answer the question:”.
– If the user’s reply contains something meaningful or relevant, briefly acknowledge it in one short sentence.
– Do NOT explore the new topic or follow it into a new line of inquiry.
– Explain briefly why the current question is being asked.
– Clarify what kind of response would answer it.
– Re-ask the last AI Coach question in simpler, more specific wording.
– Keep it firm, calm, and conversational.
– Do NOT give advice.
– Do NOT generate a reflection.
– Keep the response within the current Insight.
– If the user is abusive, gibberish, or completely off-topic, skip the acknowledgement and calmly redirect back to the current question.`
},
{
role: ‘user’,
content:
`CURRENT INSIGHT QUESTION:
${insightQuestion}
LAST AI COACH QUESTION:
${previousCoachQuestion}
GATE RESULT:
${JSON.stringify(evalResult)}
USER REPLY:
${String(userText || ”).trim()}`
}
]
};
const res = await fetch(v.aiUrl, {
method: ‘POST’,
headers,
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(`AI error ${res.status}`);
const data = await res.json();
return String(data.text || data.reply || data.content || ”).trim();
}
async function buildCoachReply(userText, evalResult, replyDecision, forcedStepAdvance = false) {
// redirect path — no reflection, but use the normal step-aware generator
if (evalResult && evalResult.redirect_required) {
const question = await callAI(
userText,
evalResult,
replyDecision,
”,
forcedStepAdvance
);
return {
reflection: ”,
question: String(question || ”).trim()
};
}
const priorSignal = await decidePriorSignalMention(userText, evalResult);
console.log(‘PRIOR SIGNAL USED:’, priorSignal);
// Reflection = immediate, local, based on what the user has just said.
// It is allowed whenever the latest reply genuinely merits one.
const reflection = await generateReflection(userText, replyDecision, evalResult);
// Question generation remains separate.
const question = await callAI(
userText,
evalResult,
replyDecision,
priorSignal,
forcedStepAdvance
);
return {
reflection: String(reflection || ”).trim(),
question: String(question || ”).trim()
};
}
function renderQuoteBlocks(insight) {
const raw = (insight && insight.quote_html && String(insight.quote_html).trim() !== ”)
? String(insight.quote_html)
: (insight && insight.quote ? String(insight.quote) : ”);
if (!raw.trim()) return ”;
// normalise newlines to
so we can split consistently
const normalised = raw
.replace(/\r\n/g, ‘\n’)
.replace(/\n/g, ‘
‘);
// Split into multiple quotes on blank line / double
const parts = normalised
.split(/(?:
\s*){2,}/i)
.map(s => s.trim())
.filter(Boolean);
const blocks = parts.map(part => {
// Split the part into lines using
const lines = part
.split(/
/i)
.map(s => s.trim())
.filter(Boolean);
// Expect: [quoteHtml, name, role…]
const quoteHtmlRaw = lines[0] || ”;
const nameRaw = lines[1] || ”;
const roleRaw = lines.slice(2).join(‘
‘) || ”;
// Remove literal quote characters inside the first …
const quoteClean = quoteHtmlRaw
.replace(/(\s*)[“”‘]+/i, ‘$1’)
.replace(/[””‘]+(\s*<\/strong>)/i, ‘$1’);
// Allow only basic inline tags, convert any stray newlines to
const quoteHtml = safeBasicHtml(quoteClean);
const nameHtml = safeBasicHtml(nameRaw);
const roleHtml = safeBasicHtml(roleRaw);
return `
` : “}
${roleRaw ? `
` : “}