(() => {
// 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 = [];

/*
* Current-Insight evidence memory.
*
* Layer B owns:
* – evidence extraction
* – novelty assessment
*
* cp-insight.js only retains the evidence that Layer B has classified
* as new or materially developed, then supplies it on the next turn.
*
* This is intentionally in-memory only.
* Database persistence and reload restoration are separate changes.
*/
let acceptedEvidence = [];

function retainAcceptedEvidence(layerB) {
const evidenceItems = Array.isArray(layerB?.evidence?.evidence)
? layerB.evidence.evidence
: [];

const noveltyItems = Array.isArray(layerB?.novelty?.novelty)
? layerB.novelty.novelty
: [];

if (!evidenceItems.length || !noveltyItems.length) {
return;
}

const noveltyByIndex = new Map();

noveltyItems.forEach(item => {
const evidenceIndex = Number(item?.evidence_index);
const status = String(item?.status || ”).trim();

if (!Number.isInteger(evidenceIndex)) return;

if (
status !== ‘new’ &&
status !== ‘materially_developed’
) {
return;
}

noveltyByIndex.set(evidenceIndex, status);
});

evidenceItems.forEach((item, index) => {
if (!noveltyByIndex.has(index)) return;

const category = String(item?.category || ”).trim();
const statement = String(item?.statement || ”).trim();
const quote = String(item?.quote || ”).trim();

if (!category || !statement || !quote) return;

const duplicateExists = acceptedEvidence.some(existing =>
String(existing?.category || ”).trim().toLowerCase() ===
category.toLowerCase() &&
String(existing?.statement || ”).trim().toLowerCase() ===
statement.toLowerCase() &&
String(existing?.quote || ”).trim().toLowerCase() ===
quote.toLowerCase()
);

if (duplicateExists) return;

acceptedEvidence.push({
category,
statement,
quote
});
});
}

// 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;
}

function getOrCreateResponseId() {
const responseKey = respKeyForThisInsight();

let responseId = sessionStorage.getItem(responseKey);

if (!responseId) {
responseId = (
window.crypto &&
typeof window.crypto.randomUUID === ‘function’
)
? window.crypto.randomUUID()
: String(Date.now());

sessionStorage.setItem(responseKey, responseId);
}

return responseId;
}

// Prevent duplicate 329/338 emits for the same accepted user turn
let metaCommitted = false;
let endReportShown = false;
let aiTurnInProgress = false;
let stepResetAppliedThisPage = false;

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(),
debug_trace: String(content.debug_trace || ”).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);

if (c.debug_trace) {
const details = document.createElement(‘details’);
details.className = ‘cp-ai-decision-trace’;

const summary = document.createElement(‘summary’);
summary.textContent = ‘AI Decision Trace’;

const pre = document.createElement(‘pre’);
pre.textContent = c.debug_trace;

details.appendChild(summary);
details.appendChild(pre);

box.appendChild(details);
}
}

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;
}

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 = `

End of Insight
Please wait while AI Coach prepares a summary for you…

`;
box.style.display = ‘block’;
return;
}

box.innerHTML = `

End of Insight

${summary ? `

${esc(summary)}

` : “}
${insight ? `

My insight on this: ${esc(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 userTurns = thread
.filter(m => m.role === ‘user’)
.map(m => String(m.content || ”).trim())
.filter(Boolean);

const substantialTurns = userTurns.filter(t => t.length >= 20);

// No meaningful discussion
if (substantialTurns.length === 0) {

renderEndOfInsightReport(
‘This Insight did not generate enough discussion to produce a meaningful review.’,

);

return;
}

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 responseId = getOrCreateResponseId();

// 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 infer curiosity, desire, motivation, values, emotion, or internal conflict unless the user clearly expressed it.\n` +
`- Do NOT make a summary sound meaningful if the user’s replies were empty, minimal, vague, or disengaged.\n` +
`- If the conversation contains little or no meaningful user reflection, return exactly: NO_MEANINGFUL_DISCUSSION\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` +
`- If SUMMARY is exactly NO_MEANINGFUL_DISCUSSION, return {“should_offer_insight”:false,”insight”:””}\n` +
`- Do NOT compensate for a weak summary by inventing an insight from the Insight text itself.\n` +
`- Do NOT infer curiosity, desire, motivation, values, emotion, or internal conflict unless the user clearly expressed it.\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 responseId = getOrCreateResponseId();

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.aiCoachFrameworkSteps || ”)
.split(/\s*\d+\.\s+/)
.map(step => step.trim())
.filter(Boolean);

const completionCriteriaText =
String(cf.step_completion_criteria || ”).trim();

const completionCriteriaByStep = {};

const criterionPattern =
/Step\s*(\d+)\s*:\s*([\s\S]*?)(?=\s*Step\s*\d+\s*:|$)/gi;

let criterionMatch;

while (
(criterionMatch = criterionPattern.exec(completionCriteriaText)) !== null
) {
const stepNumber = Number(criterionMatch[1]);
const criterion = String(criterionMatch[2] || ”).trim();

if (Number.isFinite(stepNumber) && criterion) {
completionCriteriaByStep[stepNumber] = criterion;
}
}

const stepKey =
‘cp_step_’ +
(
v.insightSequence ||
getParam(‘insight’, ”) ||
‘generic’
).trim();

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;
}

const currentStepNumber = currentStep + 1;
const nextStepNumber = currentStepNumber + 1;

return {
stepKey,
steps: rawSteps,
currentStep,
currentStepNumber,

completionCriteria: completionCriteriaText,
completionCriteriaByStep,

currentStepObjective:
completionCriteriaByStep[currentStepNumber] || ”,

nextStepObjective:
completionCriteriaByStep[nextStepNumber] || ”,

fallbackRoutes:
String(cf.fallback_routes || ”).trim(),

allowedMoves:
String(cf.allowed_moves || ”).trim(),

disallowedMoves:
String(cf.disallowed_moves || ”).trim(),

closureRule:
String(cf.closure_rule || ”).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(/<br\s*\/?>/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 `

${quoteHtml}
${nameRaw ? `

${nameHtml}

` : “}
${roleRaw ? `

${roleHtml}

` : “}

`;
}).join(”);

return `

${blocks}

`;
}

function buildInsightBodyHtml(insight) {
// Prefer text_html if present (Formidable may already provide basic HTML)
const rawHtml = (insight.text_html && String(insight.text_html).trim() !== ”)
? String(insight.text_html).trim()
: ”;

const rawText = (!rawHtml && insight.text) ? String(insight.text || ”).trim() : ”;

// Convert plain text into

paragraphs (split on blank lines)
function textToParagraphs(t) {
const paras = t
.replace(/\r\n/g, ‘\n’)
.split(/\n\s*\n+/)
.map(s => s.trim())
.filter(Boolean);

return paras.map(p => `

${esc(p).replace(/\n/g, ‘
‘)}

`).join(”);
}

// If HTML already contains

, keep it; otherwise wrap into

function normaliseHtmlToParagraphs(h) {
const hasP = /<\s*p[\s>]/i.test(h);
if (hasP) return h;

// If it’s HTML but not paragraphised, treat line breaks as paragraphs
const tmp = h
.replace(/\r\n/g, ‘\n’)
.replace(/<br\s*\/?>/gi, ‘\n’)
.split(/\n\s*\n+/)
.map(s => s.trim())
.filter(Boolean);

return tmp.map(p => `

${p}

`).join(”);
}

let bodyHtml = rawHtml ? normaliseHtmlToParagraphs(rawHtml) : (rawText ? textToParagraphs(rawText) : ”);
if (!bodyHtml) return ”;

// One restrained pull-quote (auto inserted after first paragraph).
// Prefer a dedicated field if you add one later; otherwise extract from this Insight’s own text.
function firstGoodSentenceFromText(t) {
const s = String(t || ”)
.replace(/\s+/g, ‘ ‘)
.trim();

if (!s) return ”;

// Split into sentences conservatively.
const parts = s.split(/(?<=[.!?])\s+/).map(x => x.trim()).filter(Boolean);

// Pick the first “good” sentence: not tiny, not massive, not a generic opener.
for (const p of parts) {
const clean = p.replace(/^[““”]+|[““”]+$/g, ”).trim();
if (clean.length < 35) continue; if (clean.length > 140) continue;
return clean;
}

// Fallback: take a clean slice.
return s.slice(0, 120).replace(/^[““”]+|[““”]+$/g, ”).trim();
}

const pullQuoteField =
insight?.pull_quote ??
insight?.pullQuote ??
insight?.[‘Pull quote’] ??
insight?.[‘Pull Quote’] ??
”;

// Use text_html stripped-ish fallback via rawText when available; if HTML-only, we still try via plain text.
const sourceTextForQuote = rawText || String(insight?.text || ”).trim();

const pullQuote = String(pullQuoteField || ”).trim() || firstGoodSentenceFromText(sourceTextForQuote);

if (pullQuote && /<\/p>/i.test(bodyHtml)) {
bodyHtml = bodyHtml.replace(
/<\/p>/i,
`

${esc(pullQuote)}

`
);
}

return bodyHtml;
}

function renderInsight(ctx) {
const root = qs(‘#cp-insight-root’);
if (!root) return;

const page = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const insight = ctx.insight || {};
const videos = ctx.videos || [];

const banner = ctx.active?.entry_id
? `

You have an active conversation open. Finish it before starting another Insight.

`
: ”;

const videoHtml = videos.map((v, idx) => {
const start = v.clip_start ? parseInt(v.clip_start, 10) : null;
const end = v.clip_end ? parseInt(v.clip_end, 10) : null;

const src = youtubeEmbedUrl(v.address, start, end);
if (!src) return ”;

const title = (v.clip_title || ”).trim();
const speaker = (v.heading || ”).trim();

// Use curated Tab invite text from Form 303 field [7205] when present
const tabInvite =
String(
v.tab_invite_text ??
v[‘Tab invite text’] ??
v[‘7205’] ??

).trim();

// Fallback to prior auto-text if the curated field is missing/blank
const affordance = tabInvite ||
((speaker && title) ? `Watch ${speaker} speak about “${title}”`
: (speaker) ? `Watch ${speaker} speak`
: (title) ? `Watch video about “${title}”`
: `Watch video`);

const detId = `cp-video-${idx}`;

return `



${esc(affordance)}

${title ? `

${esc(title)}

` : ”}

`;
}).join(”);

const tailoredHtml = ctx.tailored_html || ”;

// AI Coach heading/subheading: allow future per-user / per-insight overrides from the context API
// AI Coach heading/subheading: allow future per-user / per-insight overrides from the context API
const coachHeading = String(
ctx.coach_heading ||
ctx.coachHeading ||
ctx.tailored_coach_heading ||
ctx.tailoredCoachHeading ||
‘Your Turn.’
).trim() || ‘Your Turn.’;

// Show the default “Share your thoughts…” hint only once per browser session
const DEFAULT_COACH_SUB = ‘Share your thoughts here and I (your AI Coach) will talk them through with you.’;
const introKey = ‘cp_insight_intro_shown’;
const showIntroHint = !sessionStorage.getItem(introKey);
if (showIntroHint) sessionStorage.setItem(introKey, ‘1’);

// If the context API provides a coach subheading, always show it.
// Otherwise, show the default hint only once.
const coachSubFromCtx = String(
ctx.coach_sub ||
ctx.coachSub ||
ctx.tailored_coach_sub ||
ctx.tailoredCoachSub ||

).trim();

const coachSub = coachSubFromCtx || (showIntroHint ? DEFAULT_COACH_SUB : ”);
const coachSubIsIntro = (!coachSubFromCtx && showIntroHint);

root.innerHTML = `

${banner}${(() => {
const t =
ctx?.chapterMeta?.title ||
ctx?.chapter_meta?.title ||
ctx?.insight?.chapterMeta?.title ||
ctx?.insight?.chapter_meta?.title ||
ctx?.insight?.chapter_title ||
ctx?.insight?.chapterTitle ||
”;

// Prefer the explicit chapter number if present; otherwise derive from Insight sequence “1.1” → “1”
const n =
ctx?.chapterMeta?.chapter_number ||
ctx?.chapter_meta?.chapter_number ||
ctx?.insight?.chapterMeta?.chapter_number ||
ctx?.insight?.chapter_meta?.chapter_number ||
ctx?.insight?.chapter_number ||
”;

const title = String(t || ”).trim();
let num = String(n || ”).trim();

if (!num) {
const seq = String(page.insightSequenceNumber || ”).trim();
if (seq && seq.includes(‘.’)) num = seq.split(‘.’)[0].trim();
}

if (!title && !num) return ”;

return `

${num ? `Chapter ${esc(num)}${title ? `: ${esc(title)}` : “}` : `${esc(title)}`}

`;
})()}

${(() => {
const seq = String(page.insightSequenceNumber || ”).trim(); // e.g. “1.1”
const ttl = String(insight.title || ”).trim();
const full = (seq ? `${seq} ` : ”) + ttl; // “1.1 Follow Your Passion?”
return `

${esc(full)}

`;
})()}

${renderQuoteBlocks(insight)}

${tailoredHtml ? `

${tailoredHtml}

` : ”}
${videoHtml ? `

${videoHtml}

` : ”}

${buildInsightBodyHtml(insight)}
${esc(coachHeading)}
${esc(coachSub)}

Awaiting your response…

`;

// Video: only load iframe when opened; unload when closed (prevents “audio keeps playing”)
const videoDetails = root.querySelectorAll(‘.cp-video-details’);
videoDetails.forEach(det => {
const iframe = det.querySelector(‘iframe.cp-video’);
if (!iframe) return;

const load = () => {
if (!iframe.src) iframe.src = iframe.getAttribute(‘data-src’) || ”;
};
const unload = () => {
iframe.src = ”;
};

// If it starts open for any reason, load it
if (det.open) load();

det.addEventListener(‘toggle’, () => {
if (det.open) load();
else unload();
});
});

// Safety: if user navigates away mid-play, blank all iframes
window.addEventListener(‘beforeunload’, () => {
root.querySelectorAll(‘iframe.cp-video’).forEach(f => { f.src = ”; });
});

// — Wire collapsible videos (arrow state handled by CSS; this handles loading + stopping audio) —
(function wireVideos(){
const detailsEls = root.querySelectorAll(‘details.cp-video-details’);
if (!detailsEls || !detailsEls.length) return;

function closeAllIframes() {
detailsEls.forEach(d => {
const iframe = d.querySelector(‘iframe.cp-video’);
if (!iframe) return;
// remember src if it was ever set
if (!iframe.dataset.src && iframe.getAttribute(‘src’)) {
iframe.dataset.src = iframe.getAttribute(‘src’);
}
iframe.setAttribute(‘src’, ”);
});
}

detailsEls.forEach(d => {
const iframe = d.querySelector(‘iframe.cp-video’);
if (!iframe) return;

// If someone saved older HTML with src set, normalise into data-src
const existingSrc = iframe.getAttribute(‘src’);
if (existingSrc && existingSrc.trim() && (!iframe.dataset.src || !iframe.dataset.src.trim())) {
iframe.dataset.src = existingSrc.trim();
iframe.setAttribute(‘src’, ”);
}

// If it starts open, load immediately
if (d.open && iframe.dataset.src) {
iframe.setAttribute(‘src’, iframe.dataset.src);
}

d.addEventListener(‘toggle’, () => {
if (d.open) {
// opening: load this one
if (iframe.dataset.src && !iframe.getAttribute(‘src’)) {
iframe.setAttribute(‘src’, iframe.dataset.src);
}
} else {
// closing: stop audio by unloading
iframe.setAttribute(‘src’, ”);
}
});
});

// If the user navigates away / closes the tab, kill any running audio.
window.addEventListener(‘pagehide’, closeAllIframes);
window.addEventListener(‘beforeunload’, closeAllIframes);
})();

window.addEventListener(‘scroll’, () => {
// only try to schedule if we’re in “awaiting response” state
if (!String(coachState?.draft || ”).trim()) scheduleNudge();
}, { passive: true });

// Wire input (REPLACEMENT BLOCK)
const sendBtn = qs(‘#cp-ai-send’);
const nextBtn = qs(‘#cp-ai-next’);
const ta = qs(‘#cp-ai-input’);
const ctaText = qs(‘#cp-ai-cta-status’);

const nudgeBtn = qs(‘#cp-ai-nudge’);
const nudgeBox = qs(‘#cp-ai-nudge-options’);

let nudgeTimer = null;
let nudgeShown = false;
let nudgeOpen = false;

function clearNudge() {
if (nudgeTimer) clearTimeout(nudgeTimer);
nudgeTimer = null;

nudgeOpen = false;

if (nudgeBtn) nudgeBtn.style.display = ‘none’;
if (nudgeBox) nudgeBox.style.display = ‘none’;
}

function scheduleNudge() {
// If the options are currently visible, do not hide them on scroll.
if (nudgeOpen) return;

// Clear timer only (do not clear UI)
if (nudgeTimer) clearTimeout(nudgeTimer);
nudgeTimer = null;

if (!nudgeBtn) return;

// only if the user hasn’t started typing
if (String(coachState?.draft || ”).trim()) return;

// Only start the timer once the input area is visible on screen
const inputWrap = ta ? ta.closest(‘.cp-input’) : null;
if (!inputWrap) return;

const rect = inputWrap.getBoundingClientRect();
const inView = rect.top < (window.innerHeight * 0.85) && rect.bottom > 0;
if (!inView) return;

nudgeTimer = setTimeout(() => {
// still empty?
if (String(ta?.value || ”).trim()) return;

// still in view?
const r = inputWrap.getBoundingClientRect();
const stillInView = r.top < (window.innerHeight * 0.85) && r.bottom > 0;
if (!stillInView) return;

nudgeBtn.style.display = ”;
}, 15000);
}

if (!sendBtn || !nextBtn || !ta || !ctaText) return;

// Next is always visible from the start (your new decision)
nextBtn.style.display = ”;
nextBtn.disabled = false;

// Minimal draft state (no sufficiency/length gating)
let coachState = { draft: ” };

// When Next is clicked and the AI says “not really covered”, we require confirmation.
// This flag means “user already confirmed they want to move on anyway”.
let nextConfirmedOverride = false;

function renderCTA() {
const locked = !!(
window.cpVars &&
window.cpVars.insightPage &&
window.cpVars.insightPage.active &&
window.cpVars.insightPage.active.entry_id
);

if (locked) {
ctaText.textContent = ‘Finish your active conversation before starting another Insight.’;
sendBtn.disabled = true;
nextBtn.disabled = true;
nextBtn.style.display = ”;
nextBtn.textContent = ‘End’;
return;
}

const hasDraft = !!String((coachState && coachState.draft) || ”).trim();

// Whenever the end report is not showing, the End button must be visible
if (!endReportShown) {
nextBtn.style.display = ”;
nextBtn.textContent = ‘End’;
}

if (!hasDraft) {
// Awaiting response: End enabled, Send disabled
ctaText.textContent = ‘Awaiting your response…’;
sendBtn.disabled = true;
nextBtn.disabled = false;
scheduleNudge();
} else {
// Listening: Send enabled, End disabled
ctaText.textContent = ‘Listening…’;
sendBtn.disabled = false;
nextBtn.disabled = true;
clearNudge();
}
}

ta.addEventListener(‘input’, () => {
// Keep draft in sync (no meaning checks, no word/char counting)
coachState.draft = String(ta.value || ”);
renderCTA();
});

// —- AI check used ONLY when the user clicks Next —-
async function assessInsightCoveredForNextClick() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return { covered: true, message: ” }; // fail-open: never block Next if AI is unavailable

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 cf = v.coachFields || {};
const transcript = buildTranscript(3500);
// Provide the last two user turns explicitly to the monitor model
const lastTwoUser = thread
.filter(m => m.role === ‘user’)
.slice(-2)
.map(m => String(m.content || ”).trim());

const system =
`You are checking whether this Insight has been sufficiently covered to move on.\n` +
`Be conservative.\n` +
`Return ONLY valid JSON:\n` +
`{“covered”:true|false,”message”:”…”}\n` +
`- If covered=true: message may be empty.\n` +
`- If covered=false: message must briefly say what may still be worth exploring, and ask the user to confirm if they still want to move on.\n`;

const user =
`INSIGHT QUESTION:\n${cf.insightQuestion || String(v.insight?.question || ”).trim()}\n\n` +
`INSIGHT SUMMARY:\n${cf.insightSummary || ”}\n\n` +
`TRANSCRIPT:\n${transcript}\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 (typeof parsed.covered === ‘boolean’) {
return { covered: !!parsed.covered, message: String(parsed.message || ”).trim() };
}
} catch {}

// If the model returns anything unexpected, do not block Next
return { covered: true, message: ” };
}

// —- AI check used on SEND (before generating a reply) —-
// Goal: detect stalled / non-productive exchange and offer the SAME confirm as [Next].
// Rule: only act on TWO successive flagged user turns.
// Once offered and user declines, do NOT offer again for this Insight/session.

function showExampleNudges() {
if (!nudgeBox) return;
nudgeOpen = true;
if (nudgeTimer) { clearTimeout(nudgeTimer); nudgeTimer = null; }
const examples = [
“It feels inspiring, but also slightly unrealistic.”,
“I like the idea, but I’m not sure what my passion actually is.”,
“It sounds idealistic — life feels more complicated than that.”
];

nudgeBox.innerHTML = examples.map(e =>
`

${esc(e)}

`
).join(”);

nudgeBox.style.display = ‘flex’;

nudgeBox.querySelectorAll(‘.cp-nudge-option’).forEach(el => {
el.addEventListener(‘click’, () => {
ta.value = el.textContent;
coachState.draft = el.textContent;
clearNudge();
renderCTA();
});
});
}

if (nudgeBtn) {
nudgeBtn.addEventListener(‘click’, showExampleNudges);
}

// Try to hand off to whatever navigation is already wired.
// (We do not assume a specific navigation implementation in this file.)
function triggerNextNavigation() {
if (endReportShown) return;

// 1) Let any outer script hook it
try {
window.dispatchEvent(new CustomEvent(‘cp_ai_next’, { detail: { source: ‘cp-ai-next’ } }));
} catch {}

// 2) If we know the next Insight KEY (field 6897), construct the next URL
try {
const page = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const nextKey = String(page.nextInsightKey || ”).trim();
if (nextKey) {
const cur = new URL(window.location.href);

const u = new URL(‘/insight/’, window.location.origin);
const sp = new URLSearchParams(cur.search || ”);

// Keep existing params, but force the next Insight key
sp.set(‘userpass’, String(page.userpass || getParam(‘userpass’,”) || ”));
sp.set(‘edition’, String(page.edition || getParam(‘edition’,”) || ”));
sp.set(‘insight’, nextKey);

// Preserve these if present
const tone = String(page.tone || getParam(‘tone’,”) || ”);
const reset = String(getParam(‘reset’,”) || ”);
const sid = String(getParam(‘sid’,”) || ”);

if (tone) sp.set(‘tone’, tone);
if (reset !== ”) sp.set(‘reset’, reset);
if (sid) sp.set(‘sid’, sid);

u.search = sp.toString();
window.location.assign(u.toString());
return;
}
} catch {}

// 3) If we already know the next Insight URL, go there
try {
const page = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const nextUrl = String(page.nextInsightUrl || page.nextUrl || ”).trim();
if (nextUrl) {
window.location.assign(nextUrl);
return;
}
} catch {}

// 4) Try common “rel=next” hooks (anchor or link)
try {
const a = document.querySelector(‘a[rel=”next”][href]’);
if (a && a.href) { window.location.assign(a.href); return; }
} catch {}

try {
const l = document.querySelector(‘link[rel=”next”][href]’);
if (l && l.href) { window.location.assign(l.href); return; }
} catch {}

// 5) If we get here, we have no safe navigation target
try { window.alert(‘Next Insight link not found on this page.’); } catch {}
}

// Next handler

nextBtn.addEventListener(‘click’, async (e) => {
if (e && e.isTrusted === false) return;

if (nextBtn.disabled) return;
if (endReportShown) return;

sendBtn.disabled = true;
nextBtn.disabled = true;
ctaText.textContent = ‘Checking whether it’s a good point to move on…’;

try {
const verdict = await assessInsightCoveredForNextClick();

if (verdict.covered) {
triggerNextNavigation();
return;
}

// Hide the whole input area immediately
const inputWrap = qs(‘.cp-input’);
if (inputWrap) inputWrap.style.display = ‘none’;

// Hide the End button immediately
nextBtn.style.display = ‘none’;

await showEndOfInsightReport();
return;

} catch {
triggerNextNavigation();
} finally {
// Only restore the normal UI if we are NOT showing the end report
if (!endReportShown) {
sendBtn.disabled = false;
nextBtn.disabled = false;
renderCTA();
}
}
});

async function runLayeredInsightTurn(userText) {
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};

if (
!window.cpAiService ||
typeof window.cpAiService.callJson !== ‘function’ ||
typeof window.cpAiService.callText !== ‘function’ ||
!window.cpInsightLayerA ||
!window.cpInsightLayerB ||
!window.cpInsightLayerC ||
!window.cpInsightLayerD ||
!window.cpInsightOrchestrator ||
typeof window.cpInsightOrchestrator.handleUserTurnLayered !== ‘function’
) {
throw new Error(‘Layered Insight architecture is not fully loaded.’);
}

const stepCfg = getStepConfig();
const steps = stepCfg.steps || [];
const currentStepIndex = stepCfg.currentStep;

const currentFrameworkStep =
steps[currentStepIndex] || ”;

const nextFrameworkStep =
steps[currentStepIndex + 1] || ”;

const isFinalFrameworkStep =
steps.length > 0 &&
currentStepIndex >= steps.length – 1;

const followUpKey =
`${stepCfg.stepKey}_follow_up_${currentStepIndex}`;

const followUpUsed =
sessionStorage.getItem(followUpKey) === ‘1’;

const responseId = getOrCreateResponseId();

/*
* cp-ai-service.js requires the current response record to exist
* before any AI request is made.
*/
await saveNow({
status: ‘Live’,
response_id: responseId,
user_input: userText
});

const result =
await window.cpInsightOrchestrator.handleUserTurnLayered({
config: v,
responseId,
userText,
thread,

insightQuestion: String(
v.coachFields?.insightQuestion ||
v.insight?.question ||

).trim(),

insightSummary: String(
v.insight?.summary ||
v.insight?.text ||

).trim(),

currentFrameworkStep,

/*
* Temporary integration mapping:
* The current framework-step instruction is also being used as
* the step objective until the separate objective data is wired in.
*/
currentStepObjective: currentFrameworkStep,

nextFrameworkStep,

isFinalFrameworkStep,
followUpUsed,

aiStepPromptV3: String(
v.coachFields?.aiStepPromptV3 || ”
).trim(),

coachStyle: String(
v.tone || ‘direct’
).trim(),

acceptedEvidence,

currentTraitStatus:
v.coachMemory?.currentTraitStatus || {},

futureChapterMap:
v.coachMemory?.futureChapterMap || {},

reflectionHistory:
v.coachMemory?.reflectionHistory || [],

previousInsightReviews:
v.coachMemory?.previousInsightReviews || [],

relevantTraitHistory:
v.coachMemory?.relevantTraitHistory || [],

pushAssistantMessage(message) {
pushMsg(‘assistant’, message);
},

async saveNow(payload) {
await saveNow(payload);
}
});

/*
* Retain only evidence that Layer B itself classified as:
* – new
* – materially_developed
*
* Repeated evidence is not added again.
* Unknown evidence is not treated as accepted.
*/
retainAcceptedEvidence(result?.layerB);

const strategy =
result?.layerB?.strategy?.response_strategy || ”;

if (strategy === ‘follow_up’) {
sessionStorage.setItem(followUpKey, ‘1’);
}

if (
strategy === ‘next_step’ &&
!isFinalFrameworkStep &&
steps.length
) {
sessionStorage.removeItem(followUpKey);

const nextStepIndex = Math.min(
currentStepIndex + 1,
steps.length – 1
);

sessionStorage.setItem(
stepCfg.stepKey,
String(nextStepIndex)
);
}

if (
result?.stopped_at === ‘D_REVIEW’ ||
result?.stopped_at === ‘C3_NON_RESONANCE’
) {
endReportShown = true;

const inputWrap = qs(‘.cp-input’);
if (inputWrap) inputWrap.style.display = ‘none’;

if (nextBtn) {
nextBtn.disabled = true;
nextBtn.style.display = ‘none’;
}
}

return result;
}

// Send handler
sendBtn.addEventListener(‘click’, async () => {
if (aiTurnInProgress) {
console.log(‘SEND BLOCKED: already processing’);
return;
}

if (sendBtn.disabled) return;

let text = (ta.value || ”).trim();
if (!text) return;

aiTurnInProgress = true;

// Lock UI while AI Coach is working
sendBtn.disabled = true;
nextBtn.disabled = true;
ctaText.textContent = ‘AI Coach is updating your notes’;

// Spellcheck before submit
text = await spellcheckLocale(text);

// Clear input and commit the user message to the thread
ta.value = ”;
coachState.draft = ”;
nextConfirmedOverride = false;
endReportShown = false;
renderEndOfInsightReport(”, ”);
nextBtn.textContent = ‘End’;
nextBtn.style.display = ”;
pushMsg(‘user’, text);

try {
await runLayeredInsightTurn(text);
} catch (e) {
pushMsg(
‘assistant’,
`Sorry — something went wrong. ${String(e.message || e)}`
);
} finally {
aiTurnInProgress = false;
sendBtn.disabled = false;
nextBtn.disabled = false;
renderCTA();
}
});

// Initial UI state
sendBtn.textContent = ‘Send’;
nextBtn.textContent = ‘End’;
nextBtn.style.display = ”;
coachState.draft = String(ta.value || ”);
renderCTA();
}

async function init() {
const page = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const userpass = getParam(‘userpass’,”);
const edition = (getParam(‘edition’,’workplace’) || ‘workplace’).toLowerCase();
const insight = getParam(‘insight’,”);
const tone = (getParam(‘tone’,’direct’) || ‘direct’).toLowerCase();

page.tone = tone;

if (!userpass) {
const root = qs(‘#cp-insight-root’);
if (root) root.innerHTML = `

Missing userpass.

`;
return;
}

const headers = {};
if (page.token) headers[‘X-CP-Token’] = page.token;
else if (page.restNonce) headers[‘X-WP-Nonce’] = page.restNonce;

if (!page.contextUrl) {
const root = qs(‘#cp-insight-root’);
if (root) root.innerHTML = `

Missing cpVars.insightPage.contextUrl.

`;
return;
}

// Build context URL using ONLY the params the endpoint expects.
// (Do not forward arbitrary page params like “id” etc.)
const u = new URL(page.contextUrl, window.location.origin);
const sp = new URLSearchParams();

sp.set(‘userpass’, userpass);
sp.set(‘edition’, edition);
sp.set(‘insight’, insight || ”);
sp.set(‘tone’, tone || ‘direct’);

const reset = String(getParam(‘reset’, ”) || ”).trim();
const sid = String(getParam(‘sid’, ”) || ”).trim();

if (reset !== ”) sp.set(‘reset’, reset);
if (sid !== ”) sp.set(‘sid’, sid);

u.search = sp.toString();

const res = await fetch(u.toString(), { headers });
if (!res.ok) throw new Error(`Insight context error ${res.status}`);
const ctx = await res.json();

// store into cpVars for AI calls
page.userpass = userpass;
page.edition = edition;
page.coachMemory =
ctx && ctx.coachMemory && typeof ctx.coachMemory === ‘object’
? ctx.coachMemory
: {};

// KEEP: entry-key (used for routing + saving)
page.insightSequence = insight;

// NEW: human-facing Insight Sequence number (Formidable field [6856])
// Prefer numeric key access because it’s the most reliable across payload variants.
const i = ctx?.insight || {};
const i2 = ctx?.insight_fields || ctx?.insightFields || ctx?.fields || {}; // defensive

// Helper: first non-empty value from a list of candidates
const pick = (…vals) => {
for (const v of vals) {
const s = (v === null || v === undefined) ? ” : String(v);
if (s.trim() !== ”) return s.trim();
}
return ”;
};

// NEW: sequence number from field [6856] (plus common named variants)
page.insightSequenceNumber = pick(
// numeric field key variants
i2?.[‘6856’], i?.[‘6856’],
i2?.[6856], i?.[6856],

// common human-name variants (depends how MU serialises the record)
i2?.[‘Insight Sequence’], i?.[‘Insight Sequence’],
i2?.[‘Insight Sequence field’], i?.[‘Insight Sequence field’],
i2?.insight_sequence, i?.insight_sequence,
i2?.insightSequence, i?.insightSequence
);

// Debug (only when missing): show which keys we actually got back
if (!page.insightSequenceNumber) {
console.log(‘[cp-insight] missing Insight Sequence [6856]’, {
insightKey: insight, // the entry-key from URL
insightKeys: Object.keys(i || {}).sort(),
altKeys: Object.keys(i2 || {}).sort(),
sample: {
i_6856: i?.[‘6856’] ?? i?.[6856],
i2_6856: i2?.[‘6856’] ?? i2?.[6856],
i_named: i?.[‘Insight Sequence’],
i2_named: i2?.[‘Insight Sequence’]
}
});
}

page.insight = ctx.insight;
page.videos = ctx.videos;
page.mode = ctx.mode;

// Prefer the MU-provided canonical field (ctx.insight.next).
page.nextInsightKey = pick(
i?.next,
i?.nextInsightKey,
i2?.[‘6897’], i?.[‘6897’],
i2?.[6897], i?.[6897]
);

// NEW: curated coaching rails (field names)
// (i, i2, pick already defined above)

const interventionScope =
i?.intervention_scope_v3 ??
i?.[‘Intervention Scope v3’] ??
i?.[‘7207’] ??
i?.[7207] ??
i2?.intervention_scope_v3 ??
i2?.[‘Intervention Scope v3’] ??
i2?.[‘7207’] ??
i2?.[7207] ??
”;

const aiInsightPromptV3 =
i?.ai_insight_prompt_v3 ??
i?.[‘AI Insight Prompt v3’] ??
i?.[‘7208’] ??
i?.[7208] ??
i2?.ai_insight_prompt_v3 ??
i2?.[‘AI Insight Prompt v3’] ??
i2?.[‘7208’] ??
i2?.[7208] ??
”;

const aiStepPromptV3 =
i?.ai_step_prompt_v3 ??
i?.[‘AI Step Prompt v3’] ??
i?.[‘7209’] ??
i?.[7209] ??
i2?.ai_step_prompt_v3 ??
i2?.[‘AI Step Prompt v3’] ??
i2?.[‘7209’] ??
i2?.[7209] ??
”;

// Debug (only when missing): shows what keys are actually coming back from the endpoint
// Debug (only when missing): shows what keys are actually coming back from the endpoint
if (!interventionScope || !aiInsightPromptV3 || !aiStepPromptV3) {
console.log(‘[cp-insight] missing coach rails (v3)’, {
interventionScopePresent: !!interventionScope,
aiInsightPromptV3Present: !!aiInsightPromptV3,
aiStepPromptV3Present: !!aiStepPromptV3,
insightKeys: Object.keys(i || {}).sort(),
altKeys: Object.keys(i2 || {}).sort()
});
}
console.log(‘Insight object keys:’, Object.keys(i || {}));
console.log(‘Alt insight object keys:’, Object.keys(i2 || {}));

const aiCoachFrameworkSteps = pick(
i?.[7202],
i?.[‘7202’],
i2?.[7202],
i2?.[‘7202’],
i?.ai_coach_framework_steps,
i2?.ai_coach_framework_steps,
i?.[‘AI Coach Framework Steps – KEY FIELD’],
i2?.[‘AI Coach Framework Steps – KEY FIELD’]
);

const stepCompletionCriteria = pick(
i?.[7217],
i?.[‘7217’],
i2?.[7217],
i2?.[‘7217’],
i?.step_completion_criteria,
i2?.step_completion_criteria,
i?.[‘step_completion_criteria’],
i2?.[‘step_completion_criteria’]
);

const fallbackRoutes = pick(
i?.[7218],
i?.[‘7218’],
i2?.[7218],
i2?.[‘7218’],
i?.fallback_routes,
i2?.fallback_routes,
i?.[‘fallback_routes’],
i2?.[‘fallback_routes’]
);

const allowedMoves = pick(
i?.[7219],
i?.[‘7219’],
i2?.[7219],
i2?.[‘7219’],
i?.allowed_moves,
i2?.allowed_moves,
i?.[‘allowed_moves’],
i2?.[‘allowed_moves’]
);

const disallowedMoves = pick(
i?.[7220],
i?.[‘7220’],
i2?.[7220],
i2?.[‘7220’],
i?.disallowed_moves,
i2?.disallowed_moves,
i?.[‘disallowed_moves’],
i2?.[‘disallowed_moves’]
);

const closureRule = pick(
i?.closure_rule,
i2?.closure_rule,
i?.[‘closure_rule’],
i2?.[‘closure_rule’],
i?.[‘Closure Rule’],
i2?.[‘Closure Rule’]
);

page.coachFields = {
insightQuestion: pick(
i?.[‘Insight Question’],
i?.question,
i2?.[‘Insight Question’],
i2?.question
),

insightSummary: pick(
i?.[‘Insight Summary’],
i2?.[‘Insight Summary’],
i?.insight_summary,
i2?.insight_summary
),

coachGuidance: pick(
i?.[‘AI Coach – Generic advice, customised for Insight’],
i2?.[‘AI Coach – Generic advice, customised for Insight’]
),

interventionScope,

aiInsightPromptV3,
aiStepPromptV3,

aiCoachFrameworkSteps,

step_completion_criteria: stepCompletionCriteria,
fallback_routes: fallbackRoutes,
allowed_moves: allowedMoves,
disallowed_moves: disallowedMoves,
closure_rule: closureRule,

coachStyle: pick(
i?.[‘Coach Style’],
i2?.[‘Coach Style’]
),

coachChunking: pick(
i?.[‘Coach Chunking’],
i2?.[‘Coach Chunking’]
)
};

// NEW: capture Insight Guide (WE Insight Guide)
page.insightGuide = String(
ctx?.insight?.[‘WE Insight Guide’] || ”
).trim();

renderInsight(ctx);

// Seed first AI prompt with the Insight question
if (ctx?.insight?.question) {
pushMsg(‘assistant’, ctx.insight.question);
}

// Optional resume fetch (if you later wire the MU resume payload properly)
// const r = await fetch(`${v.resumeUrl}?userpass=${encodeURIComponent(userpass)}&insight=${encodeURIComponent(insight)}`, { headers });
// const rd = await r.json();
// if (rd?.resume?.dialogue) { /* parse transcript back into thread if you want */ }
}

if (document.readyState === ‘loading’) {
document.addEventListener(‘DOMContentLoaded’, init);
} else {
init();
}
})();