(() => {
// 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 v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return text;

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-4’,
temperature: 0,
messages: [{
role: ‘user’,
content:
`Correct ${langVariant} spelling and grammar only. ` +
`Do not change meaning or add content. Return only the corrected text.\n\n` +
String(text || ”)
}]
};

const res = await fetch(v.aiUrl, { method: ‘POST’, headers, body: JSON.stringify(payload) });
const j = await res.json();

// MU returns { ok:true, text:”…” }
if (j && typeof j.text === ‘string’ && j.text.trim() !== ”) return j.text.trim();

// fallback if MU ever returns OpenAI-shaped payload
const alt = j && j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content;
if (typeof alt === ‘string’ && alt.trim() !== ”) return alt.trim();

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

function pushMsg(role, content) {
thread.push({ role, content: String(content ?? ”) });

// Each new user turn re-opens the ability to emit 329/338 once.
if (role === ‘user’) metaCommitted = false;

renderThread();
}

function renderThread() {
const box = qs(‘#cp-ai-thread’);
if (!box) return;

box.innerHTML = ”;

// Match GTKY: nickname via ?nname=…, fallback “You”
const USER_NAME = getParam(‘nname’, ”) || ‘You’;
const COACH_NAME = ‘AI Coach’;

for (const m of thread) {
const isUser = (m.role === ‘user’);

const bubble = document.createElement(‘div’);
bubble.className = ‘cp-bubble ‘ + (isUser ? ‘cp-user’ : ‘cp-assistant’);

const head = document.createElement(‘div’);
head.className = ‘cp-msg-head ‘ + (isUser ? ‘cp-user-head’ : ‘cp-ai-head’);
head.innerHTML = isUser
? `👤 ${esc(USER_NAME)}`
: `⚡ ${esc(COACH_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);
}

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: ${m.content}`);
else if (m.role === ‘assistant’) lines.push(`AI Coach: ${m.content}`);
}
let t = lines.join(‘\n’);
if (t.length > maxChars) t = t.slice(t.length – maxChars);
return t;
}

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 = m.content.trim();
if (!lastAI && m.role === ‘assistant’) lastAI = 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-4’,
temperature: 0.2,
messages: [{
role: ‘user’,
content:
`Write a concise, Insight-specific coach summary of the conversation so far.\n` +
`- 2–5 sentences.\n` +
`- Focus on what the user revealed, tensions, values, motivations.\n` +
`- No generic productivity advice.\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();
}

// —- 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-4’,
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.length < 20) 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-4', 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
}
}

async function callAI(userText) {
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;

// Compose prompt context: Insight + mode + trait modulation + coach tone
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 || [],

// Dialogue so far
messages: [
{
role: ‘system’,
content: (() => {
const cf = v.coachFields || {};
const tone = v.tone || ‘direct’;

return (
`You are the AI Coach for a Change Pathway Insight conversation.\n` +
`This is a guided reflection, not open-ended chat.\n` +
`Your job is to guide the user through THIS Insight using the curated coaching rails below.\n` +
`\n` +
`Tone: ${tone}.\n` +
`- direct: concise, candid, no fluff, challenge assumptions.\n` +
`- warm: supportive but still honest; slightly softer wording.\n` +
`- light: friendly, lighter phrasing; still stays on-task.\n` +
`\n` +
`Mode: ${v.mode?.name || ‘Explore’} — ${v.mode?.definition || ”}\n` +
`Trait modulation (behavioural guidance): ${JSON.stringify(v.mode?.trait_modulation || {})}\n` +
`\n` +
`INSIGHT QUESTION (north star):\n` +
`${cf.insightQuestion || String(v.insight?.question || ”).trim()}\n` +
`\n` +
`INSIGHT SUMMARY (what this Insight is really doing):\n` +
`${cf.insightSummary}\n` +
`\n` +
`PRIMARY COACHING GUIDANCE (use this to decide what to ask next):\n` +
`${cf.coachGuidance}\n` +
`\n` +
`COACH STYLE (how to ask):\n` +
`${cf.coachStyle}\n` +
`\n` +
`COACH CHUNKING (pace/structure):\n` +
`${cf.coachChunking}\n` +
`\n` +
`BACKGROUND ONLY (do not quote; do not dump; use only to stay aligned):\n` +
`AI Insight Prompt:\n${cf.aiInsightPrompt}\n` +
`AI Key Points:\n${cf.aiKeyPoints}\n` +
`\n` +
(cf.coachAlignment
? `\nAI COACH ALIGNMENT RULE (must be applied before each question):\n${cf.coachAlignment}\n`
: “) +
(cf.guideSteps
? `\nAI COACH GUIDE STEPS (follow in order; do not skip unless already answered):\n${cf.guideSteps}\n`
: “) +
`\n` +
`TURN RULE:\n` +
`- If AI Coach Guide Steps are provided, each reply must advance exactly ONE step: ask one focused question for the next unmet step.\n` +
`- If the user says “I don’t know / can’t pin it down”, do not stay in avoidance—redirect to a concrete earlier step prompt.\n` +
`\n` +
`CRITICAL RULES:\n` +
`- Follow the Alignment Rule and Guide Steps above if provided.\n` +
`- Stay aligned to the Insight Question + Primary Coaching Guidance.\n` +
`- Do NOT drift into generic productivity/performance coaching unless it directly serves the Insight.\n` +
`- Ask ONE primary question that matches the next unmet guide step (if provided).\n` +
`\n` +
`ENDING / WRAP-UP RULES:\n` +
`- Do NOT over-assess outcomes (no “great that you achieved/identified…” unless the user explicitly claimed a concrete outcome).\n` +
`- Do NOT assign practical tasks/experiments unless the Insight explicitly asks for action.\n` +
`- Do NOT assume cadence or timing (never say “next week”, “tomorrow”, “this week”).\n` +
`- If the user indicates they want to end (e.g., “done”, “enough”, “next”), give a neutral 1–2 sentence wrap-up that reflects their words without judgement, and tell them to click Next.\n`
);
})()
},

…thread,
{ role:’user’, content: userText }
]
};

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();

// Expect your /ai proxy returns { ok:true, text:”…” } or similar; align if needed
return data.text || data.reply || data.content || ”;
}

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 `

${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(//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
const coachHeading = String(
ctx.coach_heading ||
ctx.coachHeading ||
ctx.tailored_coach_heading ||
ctx.tailoredCoachHeading ||
‘Your Turn.’
).trim() || ‘Your Turn.’;

const coachSub = String(
ctx.coach_sub ||
ctx.coachSub ||
ctx.tailored_coach_sub ||
ctx.tailoredCoachSub ||
‘Share your thoughts here and I (your AI Coach) will talk them through with you.’
).trim() || ‘Share your thoughts here and I (your AI Coach) will talk them through with you.’;

root.innerHTML = `

${banner}

${esc(insight.title || ”)}

${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);
})();

// 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’);

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

// Stage 1: users must not be able to skip an Insight
// (We still keep nextBtn in the DOM because existing navigation may be wired elsewhere,
// and we may still trigger it programmatically for explicit opt-out.)
nextBtn.style.display = ‘none’;
nextBtn.disabled = true;

// —- Coach gating state (per user draft) —-
let coachState = {
phase: ‘idle’, // ‘idle’ | ‘typing’ | ‘ready’
sufficient: false,
optOut: false,
reason: ‘insufficient’,
draft: ”
};

let evalTimer = null;

// You can expand these later; keep conservative and UK-friendly.
function isOptOut(text) {
// Examples: “I have nothing to say here”, “skip”, “move on”, “pass”, etc.
return /\b(nothing to say|no comment|skip( this)?|move on|pass|next( please)?|not sure what to say)\b/i.test(text);
}

function isBareMinimum(text) {
// Minimal “typed something relevant-ish” gate.
// Keep it simple for stage 1: length + at least 2 words.
const t = (text || ”).trim();
if (t.length < 12) return false; if ((t.match(/\S+/g) || []).length < 2) return false; return true; } // Update UI: italic status line + enabled/disabled buttons function renderCTA() { const disabled = !!(window.cpVars && window.cpVars.insightPage && window.cpVars.insightPage.active && window.cpVars.insightPage.active.entry_id); // Next is permanently hidden in Stage 1 (set once above), but keep this defensive. nextBtn.style.display = 'none'; nextBtn.disabled = true; if (disabled) { ctaText.textContent = 'Finish your active conversation before starting another Insight.'; sendBtn.disabled = true; return; } // Nothing typed yet if (!coachState.draft) { ctaText.textContent = 'Awaiting your response…'; sendBtn.textContent = 'Send'; sendBtn.disabled = true; return; } // Explicit opt-out if (coachState.optOut) { ctaText.textContent = 'When you’re ready:'; sendBtn.textContent = 'Send'; sendBtn.disabled = false; return; } // Sufficient response if (coachState.sufficient) { ctaText.textContent = 'When you’re ready:'; sendBtn.textContent = 'Send'; sendBtn.disabled = false; return; } // Draft exists but insufficient (we keep it simple here; your “Listening…” behaviour lives elsewhere) ctaText.textContent = 'Listening…'; sendBtn.textContent = 'Send'; sendBtn.disabled = true; } // Live monitoring while the user types const STOPWORDS = new Set([ 'i','me','my','mine','we','us','our','ours','you','your','yours', 'a','an','the','and','or','but','so','because','if','then','than', 'to','of','in','on','at','for','from','with','as','is','are','was','were','be','been','being', 'it','this','that','these','those','there','here', 'do','does','did','doing','done', 'have','has','had', 'can','could','should','would','will','might','may', 'just','really','very','quite','maybe','like' ]); function normaliseDraft(s) { return String(s || '') .replace(/\s+/g, ' ') .trim(); } // Expandable list; keep it forgiving. function isExplicitOptOut(draft) { const t = normaliseDraft(draft).toLowerCase(); if (!t) return false; // Common opt-outs / skips return ( t === 'skip' || t === 'next' || t.includes("nothing to say") || t.includes("no comment") || t.includes("not sure") || t.includes("don't know") || t.includes("do not know") || t.includes("can't answer") || t.includes("cannot answer") || t.includes("prefer not to") || t.includes("rather not") ); } // Heuristic “substance” check (NOT char-count): // - Extract content words (non-stopwords, >2 chars)
// – Require at least 2 content words OR a clear “because / so that / I want / I need / I’m trying” structure
function hasSubstance(draft) {
const t = normaliseDraft(draft);
if (!t) return false;

const lower = t.toLowerCase();

// If it’s basically only filler, treat as insufficient
const fillerOnly = /^(ok|okay|yes|no|maybe|fine|idk|dont know|don’t know|not sure)\.?$/i.test(lower);
if (fillerOnly) return false;

// Tokenise
const words = lower
.replace(/[^a-z0-9’\s-]/g, ‘ ‘)
.split(/\s+/)
.filter(Boolean);

const content = words.filter(w => w.length > 2 && !STOPWORDS.has(w));

// Signals that a thought is “complete enough” to respond to (not length-based)
const hasIntent =
/\b(i\s*(want|need|think|feel|believe|hope|am trying|i’m trying|struggle|find|am drawn|i’m drawn|i am drawn|i enjoy|i like|i love|i value))\b/.test(lower) ||
/\b(because|so that|so i can|in order to)\b/.test(lower);

const hasSentenceSignal = /[.!?]/.test(t) || t.includes(‘\n’) || t.includes(‘;’);

// Conservative rule:
// – 3+ content words is usually enough (even without punctuation), OR
// – 2 content words PLUS either intent framing or sentence signal
if (content.length >= 3) return true;
if (content.length >= 2 && (hasIntent || hasSentenceSignal)) return true;

return false;
}

// “Listening…” should not trigger on 2–3 generic words.
// Trigger it only when there’s a *hint* of substance:
// – either 6+ words, OR
// – at least 1 non-stopword content word (len>2) plus an intent/connector pattern.
function shouldShowListening(draft) {
return !!normaliseDraft(draft);
}

ta.addEventListener(‘input’, () => {
const text = (ta.value || ”).trim();
coachState.draft = text;

// Nothing typed
if (!text) {
coachState.phase = ‘idle’;
coachState.optOut = false;
coachState.sufficient = false;
coachState.reason = ‘insufficient’;
if (evalTimer) { clearTimeout(evalTimer); evalTimer = null; }
renderCTA();
return;
}

// While user is actively typing: ALWAYS “Listening…” (no sufficiency flips)
coachState.phase = ‘typing’;
coachState.optOut = false;
coachState.sufficient = false;
coachState.reason = ‘insufficient’;
renderCTA();

// After a short pause, evaluate whether it’s workable
if (evalTimer) clearTimeout(evalTimer);
evalTimer = setTimeout(() => {
const t = (ta.value || ”).trim();
coachState.draft = t;

coachState.optOut = isOptOut(t);
coachState.sufficient = coachState.optOut ? false : isBareMinimum(t);

coachState.phase = (coachState.optOut || coachState.sufficient) ? ‘ready’ : ‘typing’;

coachState.reason = coachState.optOut
? ‘explicit_opt_out’
: coachState.sufficient
? ‘sufficient_response’
: ‘insufficient’;

renderCTA();
}, 900);
});

// Send handler
sendBtn.addEventListener(‘click’, async () => {
if (sendBtn.disabled) return;

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

// Lock UI
sendBtn.disabled = true;
nextBtn.disabled = true;

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

// Clear input + commit user message to the thread
ta.value = ”;
coachState.draft = ”;
pushMsg(‘user’, text);

try {
const reply = await callAI(text);
pushMsg(‘assistant’, reply);

// 1) mini summary
const summary = await summariseThread();

// 2) append-only emits to 329 + 338 (once per user turn)
await saveConversationSummary({ summary });

// 3) store to 320 Responses (and include Coach decision fields)
// Use string fields so MU/Formidable don’t silently drop nested objects.
const coachDecision = {
sufficient: !!coachState.sufficient,
opt_out: !!coachState.optOut,
reason: coachState.reason
};

await saveNow({
status: coachState.optOut ? ‘Ended’ : ‘Live’,
summary,

// —- decision recording (safe fields) —-
coach_decision_json: JSON.stringify(coachDecision),
coach_decision_reason: coachDecision.reason,
coach_opt_out: coachDecision.opt_out ? 1 : 0,
coach_sufficient: coachDecision.sufficient ? 1 : 0
});

// Opt-out: auto-advance by triggering your existing Next logic
if (coachState.optOut) {
// If Next is wired elsewhere, click it.
try { nextBtn.click(); } catch {}
return;
}

} catch (e) {
pushMsg(‘assistant’, `Sorry — something went wrong. ${String(e.message || e)}`);
} finally {
// Reset coachState and re-render CTA
coachState = { phase: ‘idle’, sufficient: false, optOut: false, reason: ‘insufficient’, draft: ” };
if (evalTimer) { clearTimeout(evalTimer); evalTimer = null; }
renderCTA();
}
});

// Initial UI state (stage 1)
sendBtn.textContent = ‘Send’;
sendBtn.disabled = true;
nextBtn.style.display = ‘none’;
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 by forwarding the page querystring (tone/reset/etc),
// while guaranteeing required params are present.
const u = new URL(page.contextUrl, window.location.origin);
const sp = new URLSearchParams(window.location.search || ”);

sp.set(‘userpass’, userpass);
sp.set(‘edition’, edition);
sp.set(‘insight’, insight || ”);
sp.set(‘tone’, tone || ‘direct’); // forward tone
sp.set(‘reset’, getParam(‘reset’, ”) || ”); // forward reset if present

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.insightSequence = insight;
page.insight = ctx.insight;
page.videos = ctx.videos;
page.mode = ctx.mode;

// NEW: curated coaching rails (field names)
const i = ctx?.insight || {};
const i2 = ctx?.insight_fields || ctx?.insightFields || ctx?.fields || {}; // defensive: if the API nests fields elsewhere

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

const coachAlignment =
i?.ai_coach_alignment_rule ??
i?.[‘AI Coach Alignment Rule’] ??
i2?.ai_coach_alignment_rule ??
i2?.[‘AI Coach Alignment Rule’] ??
”;

const guideSteps =
i?.ai_coach_guide_steps ??
i?.[‘AI Coach Guide Steps’] ??
i2?.ai_coach_guide_steps ??
i2?.[‘AI Coach Guide Steps’] ??
”;

// Debug (only when missing): shows what keys are actually coming back from the endpoint
if (!coachAlignment || !guideSteps) {
console.log(‘[cp-insight] missing coach rails’, {
coachAlignmentPresent: !!coachAlignment,
guideStepsPresent: !!guideSteps,
insightKeys: Object.keys(i || {}).sort(),
altKeys: Object.keys(i2 || {}).sort()
});
}

page.coachFields = {
// Core framing (must)
insightQuestion: pick(i[‘Insight Question’], i.question, i2[‘Insight Question’], i2.question),
insightSummary: pick(i[‘Insight Summary’], i2[‘Insight Summary’]),

// Primary coaching guidance (must)
coachGuidance: pick(
i[‘AI Coach – Generic advice, customised for Insight’],
i[‘AI Coach Advice’],
i2[‘AI Coach – Generic advice, customised for Insight’],
i2[‘AI Coach Advice’]
),

// Insight-specific rails
coachAlignment,
guideSteps,

// Behaviour/process rails (must)
coachStyle: pick(i[‘Coach Style’], i2[‘Coach Style’]),
coachChunking: pick(i[‘Guide Chunking’], i[‘Coach Chunking’], i2[‘Guide Chunking’], i2[‘Coach Chunking’]),

// Background context (optional)
aiInsightPrompt: pick(i[‘AI Insight Prompt’], i2[‘AI Insight Prompt’]),
aiKeyPoints: pick(i[‘AI Insight Key Points’], i2[‘AI Insight Key Points’]),
};

// 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) {
const tone = (page.tone || ‘direct’).toLowerCase();
let prefix = ‘Answer this:’;

if (tone === ‘warm’) prefix = ‘When you’re ready, reflect on this:’;
if (tone === ‘light’) prefix = ‘Quick question:’;

pushMsg(‘assistant’, `${prefix} ${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();
}
})();