(() => {
// 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
// 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-4o’,
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-4o’,
temperature: 0,
messages: [
{ role: ‘system’, content: system },
{ role: ‘user’, content: user }
]
};
const res = await fetch(v.aiUrl, { method: ‘POST’, headers, body: JSON.stringify(payload) });
const j = await res.json();
const raw = (j && (j.text || j.reply || j.content)) ? String(j.text || j.reply || j.content) : ”;
try {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.traits)) return parsed;
} catch {}
return { traits: [] };
}
// —- Chapter map + classifier (338) —-
async function ensureChapterMapLoaded() {
try {
if (window.cpChapterMap && Object.keys(window.cpChapterMap).length) return window.cpChapterMap;
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const headers = {};
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const res = await fetch(‘/wp-json/cp/v1/chapter-map’, { headers });
if (!res.ok) return null;
const json = await res.json();
const map = (json && json.map && typeof json.map === ‘object’) ? json.map : {};
window.cpChapterMap = map;
return map;
} catch {
return null;
}
}
function buildChapterCatalogue(map) {
if (!map) return ”;
const lines = [];
for (const [section, row] of Object.entries(map)) {
if (!row) continue;
const title = (row.title || ”).trim();
const summary = (row.summary || ”).trim();
const kws = (row.keywords || []).join(‘, ‘);
lines.push(
`${section} — ${title || ‘Untitled’}\n` +
` Summary: ${summary || ‘(no summary)’}\n` +
` Keywords: ${kws || ‘(none)’}`
);
}
return lines.join(‘\n\n’);
}
async function classifyChapterForAnswer({ questionText, answerText, summary }) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return null;
const ans = (answerText || ”).trim();
if (!ans) return null;
const map = await ensureChapterMapLoaded();
if (!map || !Object.keys(map).length) return null;
const catalogue = buildChapterCatalogue(map);
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const system =
`You are a conservative classifier.\n` +
`Pick ONE best-fitting chapter for the user’s answer, or NO_CHAPTER.\n` +
`Use chapter SUMMARY as the primary anchor; keywords are secondary.\n` +
`Return ONLY valid JSON:\n` +
`{“chapter_key”:”1.1|1.2|…|NO_CHAPTER”,”matched_terms”:[“…”],”confidence”:1-10,”quality”:”Positive|Neutral|Negative|Caution”}\n` +
`If confidence <= 6 you MUST return NO_CHAPTER.\n`;
const user =
`INSIGHT QUESTION:\n${questionText}\n\n` +
`USER ANSWER:\n${answerText}\n\n` +
`MINI SUMMARY:\n${summary}\n\n` +
`CHAPTER CATALOGUE:\n${catalogue}\n`;
const payload = {
model: 'gpt-4o',
temperature: 0,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user }
]
};
const res = await fetch(v.aiUrl, { method: 'POST', headers, body: JSON.stringify(payload) });
const j = await res.json();
const raw = (j && (j.text || j.reply || j.content)) ? String(j.text || j.reply || j.content) : '';
try {
const parsed = JSON.parse(raw);
if (!parsed || !parsed.chapter_key) return null;
if (parsed.chapter_key === 'NO_CHAPTER') return null;
return parsed;
} catch {
return null;
}
}
// ---- Main GTKY-style meta emitter: 329 + 338 (append-only), once per user turn ----
async function saveConversationSummary({ summary }) {
try {
if (metaCommitted) return;
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.saveUrl || (!v.token && !v.restNonce)) return;
// last user / last AI
let lastUser = '', lastAI = '';
for (let i = thread.length - 1; i >= 0; i–) {
const m = thread[i];
if (!m || !m.content) continue;
if (!lastUser && m.role === ‘user’) lastUser = m.content || ”;
if (!lastAI && m.role === ‘assistant’) lastAI = m.content || ”;
if (lastUser && lastAI) break;
}
const trimmedLastUser = (lastUser || ”).trim();
const trimmedSummary = (summary || ”).trim();
if (!trimmedLastUser && !trimmedSummary) return;
const RESP_KEY = respKeyForThisInsight();
let responseId = sessionStorage.getItem(RESP_KEY);
if (!responseId) {
responseId = (crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()));
sessionStorage.setItem(RESP_KEY, responseId);
}
const dialogueTail = (() => {
const txt = buildTranscript(1200);
return txt.length > 800 ? txt.slice(-800) : txt;
})();
const base = Object.freeze(Object.assign({}, cpBaseMeta(), {
response_sequence: thread.filter(m => m.role === ‘user’).length,
response_id: responseId,
user_input: lastUser || ”,
}));
// Use Insight question as the “prompt text”
const questionText = String(v.coachFields?.insightQuestion || v.insight?.question || ”).trim();
// (1) 329 traits (append-only)
const traitSignals = await scoreTraitsWithLLM({
questionText,
answerText: lastUser,
miniSummary: trimmedSummary || ”
});
const traits = Array.isArray(traitSignals?.traits) ? traitSignals.traits : [];
for (const t of traits) {
if (!t || !t.trait) continue;
// normalise confidence to 1–10 number
let conf = Number(t.confidence);
if (!Number.isFinite(conf)) conf = 7;
conf = Math.max(1, Math.min(10, Math.round(conf)));
const allowedQual = [‘Positive’,’Neutral’,’Negative’,’Caution’];
const q = String(t.quality || ‘Neutral’).trim();
const qNorm = q ? (q[0].toUpperCase() + q.slice(1).toLowerCase()) : ‘Neutral’;
const quality = allowedQual.includes(qNorm) ? qNorm : ‘Neutral’;
const traitPayload = Object.freeze({
…base,
append_only: true,
framework: ‘Change Pathway’,
trait: String(t.trait || ”).trim(),
quality,
confidence: conf,
rationale: String(t.rationale || ”).trim().slice(0, 300) || trimmedLastUser.slice(0, 300),
quote: String(t.quote || ”).trim() || trimmedLastUser,
dialogue_tail: dialogueTail
});
try { await postSaveMemory(traitPayload); } catch {}
}
// (2) 338 chapter (append-only, 0 or 1)
const chapterDecision = await classifyChapterForAnswer({
questionText,
answerText: lastUser,
summary: trimmedSummary || ”
});
if (chapterDecision) {
const selectedQuotes = (() => {
const s = (lastUser || ”).split(/(?<=[.!?])\s+/).map(x => x.trim()).filter(Boolean);
return s.slice(0, 3);
})();
const chapterPayload = Object.freeze({
…base,
append_only: true,
chapter_key: chapterDecision.chapter_key,
matched_terms: chapterDecision.matched_terms || [],
quality: chapterDecision.quality || ‘Neutral’,
confidence: Math.max(1, Math.min(10, Math.round(Number(chapterDecision.confidence) || 8))),
rationale: trimmedSummary
? trimmedSummary.replace(/^\s*[\u2022\-\*]+\s*/gm, ”).slice(0, 900)
: trimmedLastUser.slice(0, 300),
chapter_quote: selectedQuotes.join(‘ | ‘),
quotes: selectedQuotes,
dialogue_tail: dialogueTail
});
try { await postSaveMemory(chapterPayload); } catch {}
}
metaCommitted = true;
} catch {
// swallow: we never want meta emit failures to break the UI
}
}
const vStep = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const STEP_KEY = ‘cp_step_’ + ((vStep.insightSequence || getParam(‘insight’,”) || ‘generic’).trim());
const steps = String(vStep.coachFields?.aiCoachFrameworkSteps || ”)
.split(‘\n’)
.map(s => s.trim())
.filter(Boolean);
console.log(‘AI COACH STEPS RAW:’, vStep.coachFields?.aiCoachFrameworkSteps);
console.log(‘AI COACH STEPS PARSED:’, steps);
let currentStep = Number(sessionStorage.getItem(STEP_KEY) || 0);
if (!Number.isFinite(currentStep) || currentStep < 0) currentStep = 0;
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` +
`GLOBAL COACH LISTENING STANDARD (apply throughout):\n` +
`- Listen for structure, not just content.\n` +
`- Identify implicit assumptions or standards shaping the user’s interpretation.\n` +
`- Prefer precise questions over menu-style prompts.\n` +
`- Once there is enough evidence, name recurring patterns succinctly and clearly.\n` +
`- Use contrast to highlight mismatches between belief and lived experience.\n` +
`- Use brief declarative reflections when warranted, without over-interpreting.\n` +
`- Do not diagnose, label, or pathologise.\n` +
`- Depth comes from clarity, not complexity.\n` +
`- Aim for cognitive precision over reassurance or motivation.\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` +
`Intervention Scope v3:\n${cf.interventionScope}\n` +
`AI Insight Prompt v3:\n${cf.aiInsightPrompt}\n` +
`AI Step Prompt v3:\n${cf.aiStepPrompt}\n` +
`\n` +
`\n` +
`LANGUAGE RULE:\n` +
`- – You must use UK English spelling and phrasing throughout …
– Before responding, internally check and correct any US spelling. (e.g., prioritise, organise, recognise, behaviour, programme, centre).\n` +
`- Do not use US spellings (e.g., prioritize, organize, behavior, center).\n` +
`\n` +
`STEP EXECUTION RULE (CRITICAL):\n` +
`You are operating a structured coaching sequence.\n` +
`\n` +
`Current step index: ${steps.length ? (currentStep + 1) : 0} of ${steps.length}\n` +
`\n` +
`Current step instruction:\n` +
`${steps[currentStep] || ‘No step defined’}\n` +
`\n` +
`Rules:\n` +
`- You MUST follow the current step exactly.\n` +
`- Ask ONE question that directly executes this step.\n` +
`- Do NOT skip ahead.\n` +
`- Do NOT ask generic reflective questions.\n` +
`- Do NOT combine multiple steps.\n` +
`- If no steps are available, fall back to the Insight Question and Primary Coaching Guidance.\n` +
`\n` +
`If the user is vague, says “I don’t know / can’t pin it down”, or avoids giving an example:\n` +
`- Briefly acknowledge the difficulty (no praise, no judgement).\n` +
`- Offer ONE alternative way into the SAME current step.\n` +
`- Ask ONE focused question.\n` +
`- If they still refuse after two attempts, stop pushing and offer to move on.\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` +
`- The user controls pacing. Do NOT end based on turn count.\n` +
`- Continue exploring as long as the user is engaged and new depth is emerging.\n` +
`- Offer a clean transition ONLY if one of these conditions is met:\n` +
` (1) Clear Non-Resonance — the user explicitly rejects the Insight framing or consistently reframes away from it across two turns.\n` +
` (2) Natural Closure — the user has provided concrete examples, reflected on internal meaning, identified patterns relevant to the Insight, and no meaningful new depth is emerging.\n` +
` (3) Stuckness — the same requirement (e.g. a concrete example) has been gently requested twice with reframing, and the user continues to avoid or cannot provide it.\n` +
`- If one of these conditions is met, STOP probing. Do not deepen further.\n` +
`- Instead ask ONE simple choice question, neutral in tone:\n` +
` “Would you like to stay with this a little longer, or move on to the next Insight?”\n` +
`- Do NOT suggest applying insights, making plans, or taking action unless the Insight explicitly requires action.\n` +
`- Do NOT praise, evaluate, or label progress.\n` +
`- Do NOT assume timing or cadence (no “this week”, “next week”, etc.).\n` +
`- If the user explicitly asks to move on, provide a brief neutral reflection (1–2 sentences) and indicate they can 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();
const replyText = data.text || data.reply || data.content || ”;
if (steps.length && currentStep < steps.length - 1) {
currentStep++;
sessionStorage.setItem(STEP_KEY, String(currentStep));
}
return replyText;
}
function renderQuoteBlocks(insight) {
const raw = (insight && insight.quote_html && String(insight.quote_html).trim() !== '')
? String(insight.quote_html)
: (insight && insight.quote ? String(insight.quote) : '');
if (!raw.trim()) return '';
// normalise newlines to
so we can split consistently
const normalised = raw
.replace(/\r\n/g, ‘\n’)
.replace(/\n/g, ‘
‘);
// Split into multiple quotes on blank line / double
const parts = normalised
.split(/(?:
\s*){2,}/i)
.map(s => s.trim())
.filter(Boolean);
const blocks = parts.map(part => {
// Split the part into lines using
const lines = part
.split(/
/i)
.map(s => s.trim())
.filter(Boolean);
// Expect: [quoteHtml, name, role…]
const quoteHtmlRaw = lines[0] || ”;
const nameRaw = lines[1] || ”;
const roleRaw = lines.slice(2).join(‘
‘) || ”;
// Remove literal quote characters inside the first …
const quoteClean = quoteHtmlRaw
.replace(/(\s*)[“”‘]+/i, ‘$1’)
.replace(/[””‘]+(\s*<\/strong>)/i, ‘$1’);
// Allow only basic inline tags, convert any stray newlines to
const quoteHtml = safeBasicHtml(quoteClean);
const nameHtml = safeBasicHtml(nameRaw);
const roleHtml = safeBasicHtml(roleRaw);
return `
` : “}
${roleRaw ? `
` : “}
`;
}).join(”);
return `
`;
}
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,
`
`
);
}
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
? `
`
: ”;
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 ? `
` : ”}
`;
}).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 = `
${(() => {
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 `
`;
})()}
${(() => {
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 `
`;
})()}
${renderQuoteBlocks(insight)}
${tailoredHtml ? `
` : ”}
${videoHtml ? `
` : ”}
`;
// 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;
return;
}
const hasDraft = !!String(coachState.draft || ”).trim();
if (!hasDraft) {
// Awaiting response: Next enabled, Send disabled
ctaText.textContent = ‘Awaiting your response…’;
sendBtn.disabled = true;
nextBtn.disabled = false;
scheduleNudge();
} else {
// Listening: Send enabled, Next 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 =>
`
`
).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);
}
function stallFlagKeyForThisInsight() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const base = (getParam(‘insight’,”) || v.insightSequence || ‘generic’).trim();
return ‘cp_ai_stall_flag_’ + base;
}
function stallOfferShownKeyForThisInsight() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const base = (getParam(‘insight’,”) || v.insightSequence || ‘generic’).trim();
return ‘cp_ai_stall_offer_shown_’ + base;
}
async function assessStallOrNonresponseBeforeReply() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return { flagged: false }; // fail-open
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);
const lastTwoUser = thread
.filter(m => m.role === ‘user’)
.slice(-2)
.map(m => String(m.content || ”).trim());
const system =
`You are monitoring whether a guided Insight reflection should offer a “move on” confirmation.\n` +
`Return ONLY valid JSON:\n` +
`{“flagged”:true|false}\n` +
`\n` +
`Hard rule (must follow):\n` +
`- If BOTH of the last two user turns indicate they cannot think of anything to say (e.g. “nothing comes to mind”, “I don’t know”, “can’t think of anything”, “there’s nothing there”), you MUST return {“flagged”:true}.\n` +
`\n` +
`Otherwise, flag true only if one of these is clearly present across the most recent turns:\n` +
`A) The user is not engaged or is not responding properly to questions.\n` +
`B) The conversation is stalled/exhausted (looping, diminishing returns, no new depth).\n` +
`\n` +
`Do not include any other keys. Do not include commentary.\n`;
const user =
`INSIGHT QUESTION:\n${cf.insightQuestion || String(v.insight?.question || ”).trim()}\n\n` +
`INSIGHT SUMMARY:\n${cf.insightSummary || ”}\n\n` +
`LAST TWO USER TURNS:\n` +
`1) ${(lastTwoUser[0] || ”).slice(0, 500)}\n` +
`2) ${(lastTwoUser[1] || ”).slice(0, 500)}\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.flagged === ‘boolean’) return { flagged: !!parsed.flagged };
} catch {}
return { flagged: false }; // fail-open
}
// Try to hand off to whatever navigation is already wired.
// (We do not assume a specific navigation implementation in this file.)
function triggerNextNavigation() {
// 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) => {
// Prevent accidental double-binding loops if any external handler re-triggers click.
// If you remove this and your external wiring is clean, fine — but this prevents recursion.
if (e && e.isTrusted === false) return;
if (nextBtn.disabled) return;
// If user already confirmed override, move on immediately (no second AI call)
if (nextConfirmedOverride) {
triggerNextNavigation();
return;
}
// Lock UI (AI working)
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) {
nextConfirmedOverride = false;
triggerNextNavigation();
return;
}
// Not covered: ask confirmation WITHOUT adding a new coach message to the thread
const ok = window.confirm(
“Are you sure you’re ready to move on [OK], or would you like to continue exploring this Insight [Cancel]?”
);
if (ok) {
nextConfirmedOverride = true;
triggerNextNavigation();
} else {
nextConfirmedOverride = false;
return; // explicit: no further action
}
} catch {
// fail-open on errors
triggerNextNavigation();
} finally {
sendBtn.disabled = false;
nextBtn.disabled = false;
renderCTA();
}
});
// Send handler (adds a pre-reply stall/non-response check)
sendBtn.addEventListener(‘click’, async () => {
if (sendBtn.disabled) return;
let text = (ta.value || ”).trim();
if (!text) return;
// Lock UI (AI working)
sendBtn.disabled = true;
nextBtn.disabled = true;
ctaText.textContent = ‘AI Coach is updating your notes’;
// Spellcheck before submit (GTKY behaviour)
text = await spellcheckLocale(text);
// Clear input + commit user message to the thread
ta.value = ”;
coachState.draft = ”;
nextConfirmedOverride = false; // any new user turn resets the override
pushMsg(‘user’, text);
// — NEW: pre-reply stall/non-response check —
try {
const flagKey = stallFlagKeyForThisInsight();
const offerKey = stallOfferShownKeyForThisInsight();
const offerAlreadyShown = (sessionStorage.getItem(offerKey) === ‘1’);
const wasFlagged = (sessionStorage.getItem(flagKey) === ‘1’);
const verdict = await assessStallOrNonresponseBeforeReply();
const nowFlagged = !!verdict.flagged;
if (offerAlreadyShown) {
// Never offer again this Insight/session
if (!nowFlagged) sessionStorage.removeItem(flagKey);
else sessionStorage.setItem(flagKey, ‘1’);
} else {
if (!nowFlagged) {
sessionStorage.removeItem(flagKey);
} else if (!wasFlagged) {
// First successive flag: arm for next turn
sessionStorage.setItem(flagKey, ‘1’);
} else {
// Second successive flag: show SAME confirm as Next
sessionStorage.removeItem(flagKey);
const ok = window.confirm(
“Are you sure you’re ready to move on [OK], or would you like to continue exploring this Insight [Cancel]?”
);
if (ok) {
nextConfirmedOverride = true;
triggerNextNavigation();
return; // do NOT generate a coach reply if moving on
}
// User declined: mark so we never offer again this Insight/session
sessionStorage.setItem(offerKey, ‘1’);
// proceed with normal coach reply
}
}
} catch {
// fail-open: continue
}
try {
const reply = await callAI(text);
const cleanReply = String(reply || ”).trim();
pushMsg(‘assistant’, cleanReply);
// 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 (no advance gating)
const coachDecision = { next_model: ‘next_click_ai_check’ };
await saveNow({
status: ‘Live’,
summary,
‘7206’: JSON.stringify(coachDecision),
‘coach decision’: JSON.stringify(coachDecision)
});
} catch (e) {
pushMsg(‘assistant’, `Sorry — something went wrong. ${String(e.message || e)}`);
} finally {
sendBtn.disabled = false;
nextBtn.disabled = false;
renderCTA();
}
});
// Initial UI state
sendBtn.textContent = ‘Send’;
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 = `
`;
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 = `
`;
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;
// 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()
});
}
const aiCoachFrameworkSteps =
i?.ai_coach_framework_steps ??
i?.[‘AI Coach Framework Steps’] ??
i?.[‘7202’] ??
i?.[7202] ??
i2?.ai_coach_framework_steps ??
i2?.[‘AI Coach Framework Steps’] ??
i2?.[‘7202’] ??
i2?.[7202] ??
”;
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’],
i2[‘AI Coach – Generic advice, customised for Insight’]
),
// AI coaching rails (v3 only)
interventionScope: interventionScope,
aiInsightPrompt: aiInsightPromptV3,
aiStepPrompt: aiStepPromptV3,
aiCoachFrameworkSteps:
insight?.ai_coach_framework_steps ||
i?.ai_coach_framework_steps ||
i2?.ai_coach_framework_steps ||
”,
// Behaviour/process rails (must)
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();
}
})();