(() => {
// Minimal utilities
const qs = (sel, root=document) => root.querySelector(sel);
const esc = (s) => String((s === null || s === undefined) ? ” : s)
.replaceAll(‘&’,’&’).replaceAll(‘<','<')
.replaceAll('>‘,’>’).replaceAll(‘”‘,’"’)
.replaceAll(“‘”,”'”);
/**
* Allow ONLY basic inline formatting tags that you expect in Formidable content.
* Everything else remains escaped (so no scripts, no arbitrary HTML).
*/
function safeBasicHtml(raw) {
let h = esc(raw);
// allow a small whitelist of tags
h = h.replace(/<(\/?)(strong|em|b|i)>/gi, ‘<$1$2>‘);
h = h.replace(/<br\s*\/?>/gi, ‘
‘);
// preserve newlines as line breaks
h = h.replace(/\r\n|\r|\n/g, ‘
‘);
return h;
}
// Match GTKY behaviour: allow ?lang=en-GB, default en-GB
function getParam(name, fallback = ”) {
const p = new URLSearchParams(window.location.search);
const v = p.get(name);
return String(v == null ? fallback : v).trim();
}
const langVariant = (getParam(‘lang’, ”) || ‘en-GB’).trim() || ‘en-GB’;
// GTKY-equivalent spellcheck, but via your MU proxy (cpVars.insightPage.aiUrl)
async function spellcheckLocale(text) {
try {
const original = String(text || ”).trim();
if (!original) return text;
// Do not spellcheck very short replies like “no”, “yes”, “maybe”, etc.
if (original.length <= 3) return original;
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return original;
const headers = { 'Content-Type': 'application/json' };
if (v.token) headers['X-CP-Token'] = v.token;
else if (v.restNonce) headers['X-WP-Nonce'] = v.restNonce;
const payload = {
model: 'gpt-4o',
temperature: 0,
messages: [{
role: 'user',
content:
`Correct ${langVariant} spelling and grammar only. ` +
`Do not change meaning, do not answer the user, do not explain anything, and do not add content. ` +
`If the text is already fine, return it unchanged. ` +
`Return only the corrected text.\n\n` +
original
}]
};
const res = await fetch(v.aiUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload)
});
const j = await res.json();
let corrected = '';
if (j && typeof j.text === 'string') corrected = j.text.trim();
else {
const alt = j && j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content;
if (typeof alt === 'string') corrected = alt.trim();
}
if (!corrected) return original;
// Reject obvious meta/apology replies from the model
if (
/i need the text you want corrected/i.test(corrected) ||
/please provide the text/i.test(corrected) ||
/i'm sorry/i.test(corrected)
) {
return original;
}
// Reject wildly longer outputs; spellcheck should not rewrite a tiny answer into a sentence
if (corrected.length > original.length + 20) {
return original;
}
return corrected;
} catch (e) {
return text;
}
}
function youtubeEmbedUrl(urlOrId, start=null, end=null) {
if (!urlOrId) return ”;
let id = urlOrId.trim();
// Accept full URLs
const m = id.match(/(?:v=|youtu\.be\/|embed\/)([a-zA-Z0-9_-]{6,})/);
if (m) id = m[1];
let u = `https://www.youtube.com/embed/${encodeURIComponent(id)}`;
const params = new URLSearchParams();
if (start) params.set(‘start’, String(start));
if (end) params.set(‘end’, String(end));
// modest branding
params.set(‘rel’,’0′);
const qs = params.toString();
return qs ? `${u}?${qs}` : u;
}
// Thread state
const thread = [];
/*
* Current-Insight evidence memory.
*
* Layer B owns:
* – evidence extraction
* – novelty assessment
*
* cp-insight.js only retains the evidence that Layer B has classified
* as new or materially developed, then supplies it on the next turn.
*
* This is intentionally in-memory only.
* Database persistence and reload restoration are separate changes.
*/
let acceptedEvidence = [];
function retainAcceptedEvidence(layerB) {
const evidenceItems = Array.isArray(layerB?.evidence?.evidence)
? layerB.evidence.evidence
: [];
const noveltyItems = Array.isArray(layerB?.novelty?.novelty)
? layerB.novelty.novelty
: [];
if (!evidenceItems.length || !noveltyItems.length) {
return;
}
const noveltyByIndex = new Map();
noveltyItems.forEach(item => {
const evidenceIndex = Number(item?.evidence_index);
const status = String(item?.status || ”).trim();
if (!Number.isInteger(evidenceIndex)) return;
if (
status !== ‘new’ &&
status !== ‘materially_developed’
) {
return;
}
noveltyByIndex.set(evidenceIndex, status);
});
evidenceItems.forEach((item, index) => {
if (!noveltyByIndex.has(index)) return;
const category = String(item?.category || ”).trim();
const statement = String(item?.statement || ”).trim();
const quote = String(item?.quote || ”).trim();
if (!category || !statement || !quote) return;
const duplicateExists = acceptedEvidence.some(existing =>
String(existing?.category || ”).trim().toLowerCase() ===
category.toLowerCase() &&
String(existing?.statement || ”).trim().toLowerCase() ===
statement.toLowerCase() &&
String(existing?.quote || ”).trim().toLowerCase() ===
quote.toLowerCase()
);
if (duplicateExists) return;
acceptedEvidence.push({
category,
statement,
quote
});
});
}
// Keep a stable session id (GTKY style)
const SID_KEY = ‘cp_sid’;
let sessionId = getParam(‘sid’, ”) || sessionStorage.getItem(SID_KEY) || ”;
if (!sessionId) {
sessionId = (crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()));
sessionStorage.setItem(SID_KEY, sessionId);
}
// Ensure URL carries sid
if (!getParam(‘sid’,”)) {
const u = new URL(location.href);
u.searchParams.set(‘sid’, sessionId);
history.replaceState(null, ”, u);
}
// Per-Insight stable response_id (so each Insight keeps its own 320/329/338 chain)
function respKeyForThisInsight() {
const v = (window.cpVars && window.cpVars.insightPage)
? window.cpVars.insightPage
: {};
const base = (
getParam(‘insight’, ”) ||
v.insightSequence ||
‘generic’
).trim();
return ‘cp_resp_id_’ + base;
}
function getOrCreateResponseId() {
const responseKey = respKeyForThisInsight();
let responseId = sessionStorage.getItem(responseKey);
if (!responseId) {
responseId = (
window.crypto &&
typeof window.crypto.randomUUID === ‘function’
)
? window.crypto.randomUUID()
: String(Date.now());
sessionStorage.setItem(responseKey, responseId);
}
return responseId;
}
// Prevent duplicate 329/338 emits for the same accepted user turn
let metaCommitted = false;
let endReportShown = false;
let aiTurnInProgress = false;
let stepResetAppliedThisPage = false;
function pushMsg(role, content) {
const isAssistantObject =
role === ‘assistant’ &&
content &&
typeof content === ‘object’;
const safeContent = isAssistantObject
? {
reflection: String(content.reflection || ”).trim(),
question: String(content.question || ”).trim(),
debug_trace: String(content.debug_trace || ”).trim(),
control: String(content.control || ”).trim()
}
: String(content ?? ”);
thread.push({ role, content: safeContent });
if (role === ‘user’) metaCommitted = false;
renderThread();
window.cpVars = window.cpVars || {};
window.cpVars.conversationHistory = window.cpVars.conversationHistory || [];
window.cpVars.conversationHistory.push({
role,
content: isAssistantObject
? (safeContent.question || safeContent.reflection)
: safeContent
});
if (window.cpVars.conversationHistory.length > 20) {
window.cpVars.conversationHistory.shift();
}
}
function renderThread() {
const box = qs(‘#cp-ai-thread’);
if (!box) return;
box.innerHTML = ”;
const USER_NAME = getParam(‘nname’, ”) || ‘You’;
const COACH_NAME = ‘AI Coach’;
for (const m of thread) {
const isUser = (m.role === ‘user’);
if (isUser) {
const bubble = document.createElement(‘div’);
bubble.className = ‘cp-bubble cp-user’;
const head = document.createElement(‘div’);
head.className = ‘cp-msg-head cp-user-head’;
head.innerHTML = `👤 ${esc(USER_NAME)}`;
bubble.appendChild(head);
const body = document.createElement(‘div’);
body.className = ‘cp-msg-text’;
body.textContent = String(m.content ?? ”);
bubble.appendChild(body);
box.appendChild(bubble);
continue;
}
const c = (typeof m.content === ‘object’)
? m.content
: { reflection: ”, question: String(m.content ?? ”) };
// reflection block (outside bubble)
if (c.reflection) {
const r = document.createElement(‘div’);
r.className = ‘cp-reflection-block’;
const heading = document.createElement(‘div’);
heading.className = ‘cp-msg-head cp-ai-head cp-reflection-heading’;
heading.textContent = ‘Consider this…’;
const reflectionBody = document.createElement(‘div’);
reflectionBody.className = ‘cp-reflection-body’;
const icon = document.createElement(‘div’);
icon.className = ‘cp-reflection-icon’;
icon.textContent = ‘⚡’;
const text = document.createElement(‘div’);
text.className = ‘cp-reflection-text’;
text.textContent = c.reflection;
reflectionBody.appendChild(icon);
reflectionBody.appendChild(text);
r.appendChild(heading);
r.appendChild(reflectionBody);
box.appendChild(r);
}
// coach bubble (question only)
const bubble = document.createElement(‘div’);
bubble.className = ‘cp-bubble cp-assistant’;
const head = document.createElement(‘div’);
head.className = ‘cp-msg-head cp-ai-head’;
head.innerHTML = `⚡ ${esc(COACH_NAME)}`;
bubble.appendChild(head);
const body = document.createElement(‘div’);
body.className = ‘cp-msg-text’;
body.textContent = c.question || ”;
bubble.appendChild(body);
box.appendChild(bubble);
if (c.debug_trace) {
const details = document.createElement(‘details’);
details.className = ‘cp-ai-decision-trace’;
const summary = document.createElement(‘summary’);
summary.textContent = ‘AI Decision Trace’;
const pre = document.createElement(‘pre’);
pre.textContent = c.debug_trace;
details.appendChild(summary);
details.appendChild(pre);
box.appendChild(details);
}
}
box.scrollTop = box.scrollHeight;
}
function hasUserText() {
return thread.some(m => m.role === ‘user’ && (m.content || ”).trim().length > 0);
}
function buildTranscript(maxChars = 6000) {
const lines = [];
for (const m of thread) {
if (!m?.content) continue;
if (m.role === ‘user’) {
lines.push(`You: ${String(m.content)}`);
continue;
}
if (m.role === ‘assistant’) {
if (typeof m.content === ‘object’) {
if (m.content.reflection) {
lines.push(`Reflection: ${String(m.content.reflection)}`);
}
if (m.content.question) {
lines.push(`AI Coach: ${String(m.content.question)}`);
}
} else {
lines.push(`AI Coach: ${String(m.content)}`);
}
}
}
let t = lines.join(‘\n’);
if (t.length > maxChars) t = t.slice(t.length – maxChars);
return t;
}
function renderEndOfInsightReport(summaryText = ”, insightText = ”, opts = {}) { const box = qs(‘#cp-end-report’);
if (!box) return;
const summary = String(summaryText || ”).trim();
const insight = String(insightText || ”).trim();
const loading = !!opts.loading;
if (!summary && !insight && !loading) {
box.innerHTML = ”;
box.style.display = ‘none’;
return;
}
if (loading) {
box.innerHTML = `
`;
box.style.display = ‘block’;
return;
}
box.innerHTML = `
${summary ? `
` : “}
${insight ? `
` : “}
`;
box.style.display = ‘block’;
const resumeBtn = qs(‘#cp-end-report-resume’, box);
const nextInsightBtn = qs(‘#cp-end-report-next’, box);
if (resumeBtn) {
resumeBtn.addEventListener(‘click’, () => {
endReportShown = false;
nextConfirmedOverride = false;
renderEndOfInsightReport(”, ”);
const inputWrap = qs(‘.cp-input’);
if (inputWrap) inputWrap.style.display = ”;
const sendBtnEl = qs(‘#cp-ai-send’);
const nextBtnEl = qs(‘#cp-ai-next’);
const ctaTextEl = qs(‘#cp-ai-cta-status’);
if (sendBtnEl) {
sendBtnEl.style.display = ”;
sendBtnEl.disabled = true;
}
if (nextBtnEl) {
nextBtnEl.style.display = ”;
nextBtnEl.hidden = false;
nextBtnEl.disabled = false;
nextBtnEl.removeAttribute(‘disabled’);
nextBtnEl.textContent = ‘End’;
}
if (ctaTextEl) {
ctaTextEl.textContent = ‘Awaiting your response…’;
}
clearNudge();
scheduleNudge();
});
}
if (nextInsightBtn) {
nextInsightBtn.addEventListener(‘click’, () => {
endReportShown = false;
triggerNextNavigation();
});
}
}
async function showEndOfInsightReport() {
endReportShown = true;
nextConfirmedOverride = false;
renderEndOfInsightReport(”, ”, { loading: true });
const userTurns = thread
.filter(m => m.role === ‘user’)
.map(m => String(m.content || ”).trim())
.filter(Boolean);
const substantialTurns = userTurns.filter(t => t.length >= 20);
// No meaningful discussion
if (substantialTurns.length === 0) {
renderEndOfInsightReport(
‘This Insight did not generate enough discussion to produce a meaningful review.’,
”
);
return;
}
const summary = await summariseThread();
const endInsight = await generateEndOfInsightInsight(summary);
await saveConversationSummary({ summary });
await saveNow({
status: ‘Ended’,
summary
});
renderEndOfInsightReport(summary, endInsight);
const nextBtnEl = qs(‘#cp-ai-next’);
if (nextBtnEl) {
nextBtnEl.disabled = true;
nextBtnEl.style.display = ‘none’;
}
const inputWrap = qs(‘.cp-input’);
if (inputWrap) inputWrap.style.display = ‘none’;
}
function cpBaseMeta() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
return {
userpass: String(v.userpass || getParam(‘userpass’,”) || ”).trim(),
session: sessionId,
user_id: String(window.currentWpUserId || ”),
edition: String(v.edition || getParam(‘edition’,”) || ”),
kind: ‘insight’,
insight_id: String(getParam(‘insight’,”) || v.insightSequence || ”),
linked_insight_key: String(getParam(‘insight’,”) || v.insightSequence || ”)
};
}
async function postSaveMemory(payload) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.saveUrl || (!v.token && !v.restNonce)) return;
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
return fetch(v.saveUrl, {
method: ‘POST’,
headers,
body: JSON.stringify(payload),
keepalive: true
});
}
// —- 320 (Responses) saver (same purpose as your old saveNow) —-
async function saveNow(payload = {}) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.saveUrl || (!v.token && !v.restNonce)) return;
if (!hasUserText()) return;
const responseId = getOrCreateResponseId();
// derive last user / last AI
let lastUser = ”, lastAI = ”;
for (let i = thread.length – 1; i >= 0; i–) {
const m = thread[i];
if (!m || !m.content) continue;
if (!lastUser && m.role === ‘user’) {
lastUser = String(m.content || ”).trim();
}
if (!lastAI && m.role === ‘assistant’) {
if (typeof m.content === ‘object’) {
lastAI = String(m.content.question || ”).trim();
} else {
lastAI = String(m.content || ”).trim();
}
}
if (lastUser && lastAI) break;
}
const body = Object.assign({}, cpBaseMeta(), {
response_id: responseId,
dialogue: buildTranscript(),
response_sequence: thread.filter(m => m.role === ‘user’).length,
status: ‘Live’,
insight_sequence: v.insightSequence || getParam(‘insight’,”) || ”,
tone: v.tone || getParam(‘tone’,’direct’) || ‘direct’,
}, payload);
if (!(‘user_input’ in body) && lastUser) body.user_input = lastUser;
if (!(‘ai_reply’ in body) && lastAI) body.ai_reply = lastAI;
// strip empties
[‘ai_reply’,’summary’,’dialogue’,’user_input’].forEach(k => {
if (k in body && String(body[k]).trim() === ”) delete body[k];
});
await postSaveMemory(body);
}
// —- Summary generator (used to feed 329/338 + stored on 320) —-
async function summariseThread() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return ”;
// Use only the recent tail to keep it cheap + focused
const transcript = buildTranscript(2500);
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const payload = {
model: ‘gpt-4o’,
temperature: 0.2,
messages: [{
role: ‘user’,
content:
`Write a concise, Insight-specific coach summary of the conversation so far.\n` +
`\n` +
`OBJECTIVE:\n` +
`- State what you have noticed about the user, speaking directly to them.\n` +
`\n` +
`VOICE:\n` +
`- Write directly to the user using “you”.\n` +
`- Do NOT refer to “the user”.\n` +
`- Do NOT describe them from the outside.\n` +
`\n` +
`FORM:\n` +
`- 2–5 sentences.\n` +
`- Sound like a coach reflecting back what you have observed.\n` +
`\n` +
`CONSTRAINTS:\n` +
`- Stay grounded in what they actually said.\n` +
`- Focus on what they revealed, tensions, values, and motivations.\n` +
`- Do NOT generalise beyond the evidence.\n` +
`- Do NOT infer curiosity, desire, motivation, values, emotion, or internal conflict unless the user clearly expressed it.\n` +
`- Do NOT make a summary sound meaningful if the user’s replies were empty, minimal, vague, or disengaged.\n` +
`- If the conversation contains little or no meaningful user reflection, return exactly: NO_MEANINGFUL_DISCUSSION\n` +
`- Do NOT give generic productivity advice.\n` +
`- Do NOT use therapeutic or diagnostic language.\n` +
`\n` +
`TRANSCRIPT:\n${transcript}`
}]
};
const res = await fetch(v.aiUrl, { method: ‘POST’, headers, body: JSON.stringify(payload) });
const j = await res.json();
const text = (j && (j.text || j.reply || j.content)) ? String(j.text || j.reply || j.content) : ”;
return text.trim();
}
async function generateEndOfInsightInsight(summaryText) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return ”;
const transcript = buildTranscript(3000);
const insightQuestion = String(v.coachFields?.insightQuestion || v.insight?.question || ”).trim();
const insightText = String(v.insight?.text || ”).trim().slice(0, 1200);
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const payload = {
model: ‘gpt-4o’,
temperature: 0.2,
messages: [{
role: ‘user’,
content:
`You are deciding whether the coach has enough evidence to offer one insight at the end of this Insight.\n` +
`Return ONLY valid JSON in this exact form:\n` +
`{“should_offer_insight”:true|false,”insight”:”…”}\n\n` +
`Rules:\n` +
`- The insight is optional.\n` +
`- Only offer one if the conversation provides enough evidence.\n` +
`- Base it on repeated or reinforced patterns, not one isolated sentence.\n` +
`- Stay grounded in what the user has actually shared.\n` +
`- The insight MUST show how the user’s pattern affects their ability to engage with the core idea of this Insight.\n` +
`- The Insight idea must be part of the explanation, not mentioned separately at the end.\n` +
`- Explain whether the user’s behaviour supports, limits, or complicates that idea.\n` +
`- Keep it short and direct.\n` +
`- Do NOT introduce a new theory, diagnosis, or abstraction.\n` +
`- Keep it to 1–2 sentences max.\n` +
`- If there is not enough evidence, return {“should_offer_insight”:false,”insight”:””}\n` +
`- If SUMMARY is exactly NO_MEANINGFUL_DISCUSSION, return {“should_offer_insight”:false,”insight”:””}\n` +
`- Do NOT compensate for a weak summary by inventing an insight from the Insight text itself.\n` +
`- Do NOT infer curiosity, desire, motivation, values, emotion, or internal conflict unless the user clearly expressed it.\n\n` +
`CURRENT INSIGHT:\n` +
`Question: ${insightQuestion}\n` +
`Core idea: ${insightText}\n\n` +
`SUMMARY:\n${String(summaryText || ”).trim()}\n\n` +
`TRANSCRIPT:\n${transcript}`
}]
};
try {
const res = await fetch(v.aiUrl, {
method: ‘POST’,
headers,
body: JSON.stringify(payload)
});
const j = await res.json();
const raw = String(j.text || j.reply || j.content || ”).trim();
const cleaned = raw.replace(/“`json/g, ”).replace(/“`/g, ”).trim();
const parsed = JSON.parse(cleaned);
if (!parsed || !parsed.should_offer_insight) return ”;
return String(parsed.insight || ”).trim();
} catch (err) {
console.log(‘END OF INSIGHT INSIGHT ERROR:’, err);
return ”;
}
}
// —- 329 trait scorer (LLM JSON) —-
async function scoreTraitsWithLLM({ questionText, answerText, miniSummary }) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return { traits: [] };
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const system =
`You are scoring Change Pathway trait signals from ONE user answer.\n` +
`Traits: Awareness, Balance, Commitment, Drive, Engagement, Flexibility, Groundedness, Heart.\n` +
`Return ONLY valid JSON of the form:\n` +
`{“traits”:[{“trait”:”Awareness”,”quality”:”Positive|Neutral|Negative|Caution”,”confidence”:1-10,”rationale”:”…”,”quote”:”…”}]}\n` +
`Rules:\n` +
`- Max 3 traits.\n` +
`- Be conservative; if weak evidence, return []\n` +
`- quote must be exact user words (a short phrase).\n`;
const user =
`INSIGHT QUESTION:\n${questionText}\n\n` +
`USER ANSWER:\n${answerText}\n\n` +
`MINI SUMMARY:\n${miniSummary}\n`;
const payload = {
model: ‘gpt-4o’,
temperature: 0,
messages: [
{ role: ‘system’, content: system },
{ role: ‘user’, content: user }
]
};
const res = await fetch(v.aiUrl, { method: ‘POST’, headers, body: JSON.stringify(payload) });
const j = await res.json();
const raw = (j && (j.text || j.reply || j.content)) ? String(j.text || j.reply || j.content) : ”;
try {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.traits)) return parsed;
} catch {}
return { traits: [] };
}
// —- Chapter map + classifier (338) —-
async function ensureChapterMapLoaded() {
try {
if (window.cpChapterMap && Object.keys(window.cpChapterMap).length) return window.cpChapterMap;
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const headers = {};
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const res = await fetch(‘/wp-json/cp/v1/chapter-map’, { headers });
if (!res.ok) return null;
const json = await res.json();
const map = (json && json.map && typeof json.map === ‘object’) ? json.map : {};
window.cpChapterMap = map;
return map;
} catch {
return null;
}
}
function buildChapterCatalogue(map) {
if (!map) return ”;
const lines = [];
for (const [section, row] of Object.entries(map)) {
if (!row) continue;
const title = (row.title || ”).trim();
const summary = (row.summary || ”).trim();
const kws = (row.keywords || []).join(‘, ‘);
lines.push(
`${section} — ${title || ‘Untitled’}\n` +
` Summary: ${summary || ‘(no summary)’}\n` +
` Keywords: ${kws || ‘(none)’}`
);
}
return lines.join(‘\n\n’);
}
async function classifyChapterForAnswer({ questionText, answerText, summary }) {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return null;
const ans = (answerText || ”).trim();
if (!ans) return null;
const map = await ensureChapterMapLoaded();
if (!map || !Object.keys(map).length) return null;
const catalogue = buildChapterCatalogue(map);
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const system =
`You are a conservative classifier.\n` +
`Pick ONE best-fitting chapter for the user’s answer, or NO_CHAPTER.\n` +
`Use chapter SUMMARY as the primary anchor; keywords are secondary.\n` +
`Return ONLY valid JSON:\n` +
`{“chapter_key”:”1.1|1.2|…|NO_CHAPTER”,”matched_terms”:[“…”],”confidence”:1-10,”quality”:”Positive|Neutral|Negative|Caution”}\n` +
`If confidence <= 6 you MUST return NO_CHAPTER.\n`;
const user =
`INSIGHT QUESTION:\n${questionText}\n\n` +
`USER ANSWER:\n${answerText}\n\n` +
`MINI SUMMARY:\n${summary}\n\n` +
`CHAPTER CATALOGUE:\n${catalogue}\n`;
const payload = {
model: 'gpt-4o',
temperature: 0,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user }
]
};
const res = await fetch(v.aiUrl, { method: 'POST', headers, body: JSON.stringify(payload) });
const j = await res.json();
const raw = (j && (j.text || j.reply || j.content)) ? String(j.text || j.reply || j.content) : '';
try {
const parsed = JSON.parse(raw);
if (!parsed || !parsed.chapter_key) return null;
if (parsed.chapter_key === 'NO_CHAPTER') return null;
return parsed;
} catch {
return null;
}
}
// ---- Main GTKY-style meta emitter: 329 + 338 (append-only), once per user turn ----
async function saveConversationSummary({ summary }) {
try {
if (metaCommitted) return;
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.saveUrl || (!v.token && !v.restNonce)) return;
// last user / last AI
let lastUser = '', lastAI = '';
for (let i = thread.length - 1; i >= 0; i–) {
const m = thread[i];
if (!m || !m.content) continue;
if (!lastUser && m.role === ‘user’) lastUser = m.content || ”;
if (!lastAI && m.role === ‘assistant’) lastAI = m.content || ”;
if (lastUser && lastAI) break;
}
const trimmedLastUser = (lastUser || ”).trim();
const trimmedSummary = (summary || ”).trim();
if (!trimmedLastUser && !trimmedSummary) return;
const responseId = getOrCreateResponseId();
const dialogueTail = (() => {
const txt = buildTranscript(1200);
return txt.length > 800 ? txt.slice(-800) : txt;
})();
const base = Object.freeze(Object.assign({}, cpBaseMeta(), {
response_sequence: thread.filter(m => m.role === ‘user’).length,
response_id: responseId,
user_input: lastUser || ”,
}));
// Use Insight question as the “prompt text”
const questionText = String(v.coachFields?.insightQuestion || v.insight?.question || ”).trim();
// (1) 329 traits (append-only)
const traitSignals = await scoreTraitsWithLLM({
questionText,
answerText: lastUser,
miniSummary: trimmedSummary || ”
});
const traits = Array.isArray(traitSignals?.traits) ? traitSignals.traits : [];
for (const t of traits) {
if (!t || !t.trait) continue;
// normalise confidence to 1–10 number
let conf = Number(t.confidence);
if (!Number.isFinite(conf)) conf = 7;
conf = Math.max(1, Math.min(10, Math.round(conf)));
const allowedQual = [‘Positive’,’Neutral’,’Negative’,’Caution’];
const q = String(t.quality || ‘Neutral’).trim();
const qNorm = q ? (q[0].toUpperCase() + q.slice(1).toLowerCase()) : ‘Neutral’;
const quality = allowedQual.includes(qNorm) ? qNorm : ‘Neutral’;
const traitPayload = Object.freeze({
…base,
append_only: true,
framework: ‘Change Pathway’,
trait: String(t.trait || ”).trim(),
quality,
confidence: conf,
rationale: String(t.rationale || ”).trim().slice(0, 300) || trimmedLastUser.slice(0, 300),
quote: String(t.quote || ”).trim() || trimmedLastUser,
dialogue_tail: dialogueTail
});
try { await postSaveMemory(traitPayload); } catch {}
}
// (2) 338 chapter (append-only, 0 or 1)
const chapterDecision = await classifyChapterForAnswer({
questionText,
answerText: lastUser,
summary: trimmedSummary || ”
});
if (chapterDecision) {
const selectedQuotes = (() => {
const s = (lastUser || ”).split(/(?<=[.!?])\s+/).map(x => x.trim()).filter(Boolean);
return s.slice(0, 3);
})();
const chapterPayload = Object.freeze({
…base,
append_only: true,
chapter_key: chapterDecision.chapter_key,
matched_terms: chapterDecision.matched_terms || [],
quality: chapterDecision.quality || ‘Neutral’,
confidence: Math.max(1, Math.min(10, Math.round(Number(chapterDecision.confidence) || 8))),
rationale: trimmedSummary
? trimmedSummary.replace(/^\s*[\u2022\-\*]+\s*/gm, ”).slice(0, 900)
: trimmedLastUser.slice(0, 300),
chapter_quote: selectedQuotes.join(‘ | ‘),
quotes: selectedQuotes,
dialogue_tail: dialogueTail
});
try { await postSaveMemory(chapterPayload); } catch {}
}
metaCommitted = true;
} catch {
// swallow: we never want meta emit failures to break the UI
}
}
function getStepConfig() {
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
const cf = v.coachFields || {};
const rawSteps = String(cf.aiCoachFrameworkSteps || ”)
.split(/(?:^|\r?\n)\s*Step\s*\d+\.\s*/i)
.map(step => step.trim())
.filter(Boolean);
const completionCriteriaText = String(
cf.stepCompletionCriteria ||
cf.step_completion_criteria ||
”
).trim();
const completionCriteriaByStep = {};
const criterionPattern =
/Step\s*(\d+)\s*:\s*([\s\S]*?)(?=\s*Step\s*\d+\s*:|$)/gi;
let criterionMatch;
while (
(criterionMatch = criterionPattern.exec(completionCriteriaText)) !== null
) {
const stepNumber = Number(criterionMatch[1]);
const criterion = String(criterionMatch[2] || ”).trim();
if (Number.isFinite(stepNumber) && criterion) {
completionCriteriaByStep[stepNumber] = criterion;
}
}
const stepKey =
‘cp_step_’ +
(
v.insightSequence ||
getParam(‘insight’, ”) ||
‘generic’
).trim();
if (
getParam(‘reset’, ”) === ‘1’ &&
!stepResetAppliedThisPage
) {
sessionStorage.removeItem(stepKey);
stepResetAppliedThisPage = true;
}
console.log(‘STEP READ:’, {
key: stepKey,
stored: sessionStorage.getItem(stepKey),
reset: getParam(‘reset’, ”)
});
let currentStep =
Number(sessionStorage.getItem(stepKey) || 0);
if (!Number.isFinite(currentStep) || currentStep < 0) { currentStep = 0; } if (rawSteps.length && currentStep >= rawSteps.length) {
currentStep = rawSteps.length – 1;
}
const currentStepNumber = currentStep + 1;
const nextStepNumber = currentStepNumber + 1;
return {
stepKey,
steps: rawSteps,
currentStep,
currentStepNumber,
completionCriteria: completionCriteriaText,
completionCriteriaByStep,
currentStepObjective:
completionCriteriaByStep[currentStepNumber] || ”,
nextStepObjective:
completionCriteriaByStep[nextStepNumber] || ”,
fallbackRoutes:
String(cf.fallback_routes || ”).trim(),
allowedMoves:
String(cf.allowed_moves || ”).trim(),
disallowedMoves:
String(cf.disallowed_moves || ”).trim(),
closureRule:
String(cf.closure_rule || ”).trim()
};
}
function renderQuoteBlocks(insight) {
const raw = (insight && insight.quote_html && String(insight.quote_html).trim() !== ”)
? String(insight.quote_html)
: (insight && insight.quote ? String(insight.quote) : ”);
if (!raw.trim()) return ”;
// normalise newlines to
so we can split consistently
const normalised = raw
.replace(/\r\n/g, ‘\n’)
.replace(/\n/g, ‘
‘);
// Split into multiple quotes on blank line / double
const parts = normalised
.split(/(?:
\s*){2,}/i)
.map(s => s.trim())
.filter(Boolean);
const blocks = parts.map(part => {
// Split the part into lines using
const lines = part
.split(/
/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)}
` : ”}
`;
}).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;
nextBtn.style.display = ”;
nextBtn.textContent = ‘End’;
return;
}
const hasDraft = !!String((coachState && coachState.draft) || ”).trim();
// Whenever the end report is not showing, the End button must be visible
if (!endReportShown) {
nextBtn.style.display = ”;
nextBtn.textContent = ‘End’;
}
if (!hasDraft) {
// Awaiting response: End enabled, Send disabled
ctaText.textContent = ‘Awaiting your response…’;
sendBtn.disabled = true;
nextBtn.disabled = false;
scheduleNudge();
} else {
// Listening: Send enabled, End disabled
ctaText.textContent = ‘Listening…’;
sendBtn.disabled = false;
nextBtn.disabled = true;
clearNudge();
}
}
ta.addEventListener(‘input’, () => {
// Keep draft in sync (no meaning checks, no word/char counting)
coachState.draft = String(ta.value || ”);
renderCTA();
});
// —- AI check used ONLY when the user clicks Next —-
async function assessInsightCoveredForNextClick() {
const v = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
if (!v.aiUrl) return { covered: true, message: ” }; // fail-open: never block Next if AI is unavailable
const headers = { ‘Content-Type’: ‘application/json’ };
if (v.token) headers[‘X-CP-Token’] = v.token;
else if (v.restNonce) headers[‘X-WP-Nonce’] = v.restNonce;
const cf = v.coachFields || {};
const transcript = buildTranscript(3500);
// Provide the last two user turns explicitly to the monitor model
const lastTwoUser = thread
.filter(m => m.role === ‘user’)
.slice(-2)
.map(m => String(m.content || ”).trim());
const system =
`You are checking whether this Insight has been sufficiently covered to move on.\n` +
`Be conservative.\n` +
`Return ONLY valid JSON:\n` +
`{“covered”:true|false,”message”:”…”}\n` +
`- If covered=true: message may be empty.\n` +
`- If covered=false: message must briefly say what may still be worth exploring, and ask the user to confirm if they still want to move on.\n`;
const user =
`INSIGHT QUESTION:\n${cf.insightQuestion || String(v.insight?.question || ”).trim()}\n\n` +
`INSIGHT SUMMARY:\n${cf.insightSummary || ”}\n\n` +
`TRANSCRIPT:\n${transcript}\n`;
const payload = {
model: ‘gpt-4o’,
temperature: 0,
messages: [
{ role: ‘system’, content: system },
{ role: ‘user’, content: user }
]
};
const res = await fetch(v.aiUrl, { method: ‘POST’, headers, body: JSON.stringify(payload) });
const j = await res.json();
const raw = (j && (j.text || j.reply || j.content)) ? String(j.text || j.reply || j.content) : ”;
try {
const parsed = JSON.parse(raw);
if (typeof parsed.covered === ‘boolean’) {
return { covered: !!parsed.covered, message: String(parsed.message || ”).trim() };
}
} catch {}
// If the model returns anything unexpected, do not block Next
return { covered: true, message: ” };
}
// —- AI check used on SEND (before generating a reply) —-
// Goal: detect stalled / non-productive exchange and offer the SAME confirm as [Next].
// Rule: only act on TWO successive flagged user turns.
// Once offered and user declines, do NOT offer again for this Insight/session.
function showExampleNudges() {
if (!nudgeBox) return;
nudgeOpen = true;
if (nudgeTimer) { clearTimeout(nudgeTimer); nudgeTimer = null; }
const examples = [
“It feels inspiring, but also slightly unrealistic.”,
“I like the idea, but I’m not sure what my passion actually is.”,
“It sounds idealistic — life feels more complicated than that.”
];
nudgeBox.innerHTML = examples.map(e =>
`
`
).join(”);
nudgeBox.style.display = ‘flex’;
nudgeBox.querySelectorAll(‘.cp-nudge-option’).forEach(el => {
el.addEventListener(‘click’, () => {
ta.value = el.textContent;
coachState.draft = el.textContent;
clearNudge();
renderCTA();
});
});
}
if (nudgeBtn) {
nudgeBtn.addEventListener(‘click’, showExampleNudges);
}
// Try to hand off to whatever navigation is already wired.
// (We do not assume a specific navigation implementation in this file.)
function triggerNextNavigation() {
if (endReportShown) return;
// 1) Let any outer script hook it
try {
window.dispatchEvent(new CustomEvent(‘cp_ai_next’, { detail: { source: ‘cp-ai-next’ } }));
} catch {}
// 2) If we know the next Insight KEY (field 6897), construct the next URL
try {
const page = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const nextKey = String(page.nextInsightKey || ”).trim();
if (nextKey) {
const cur = new URL(window.location.href);
const u = new URL(‘/insight/’, window.location.origin);
const sp = new URLSearchParams(cur.search || ”);
// Keep existing params, but force the next Insight key
sp.set(‘userpass’, String(page.userpass || getParam(‘userpass’,”) || ”));
sp.set(‘edition’, String(page.edition || getParam(‘edition’,”) || ”));
sp.set(‘insight’, nextKey);
// Preserve these if present
const tone = String(page.tone || getParam(‘tone’,”) || ”);
const reset = String(getParam(‘reset’,”) || ”);
const sid = String(getParam(‘sid’,”) || ”);
if (tone) sp.set(‘tone’, tone);
if (reset !== ”) sp.set(‘reset’, reset);
if (sid) sp.set(‘sid’, sid);
u.search = sp.toString();
window.location.assign(u.toString());
return;
}
} catch {}
// 3) If we already know the next Insight URL, go there
try {
const page = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const nextUrl = String(page.nextInsightUrl || page.nextUrl || ”).trim();
if (nextUrl) {
window.location.assign(nextUrl);
return;
}
} catch {}
// 4) Try common “rel=next” hooks (anchor or link)
try {
const a = document.querySelector(‘a[rel=”next”][href]’);
if (a && a.href) { window.location.assign(a.href); return; }
} catch {}
try {
const l = document.querySelector(‘link[rel=”next”][href]’);
if (l && l.href) { window.location.assign(l.href); return; }
} catch {}
// 5) If we get here, we have no safe navigation target
try { window.alert(‘Next Insight link not found on this page.’); } catch {}
}
// Next handler
nextBtn.addEventListener(‘click’, async (e) => {
if (e && e.isTrusted === false) return;
if (nextBtn.disabled) return;
if (endReportShown) return;
sendBtn.disabled = true;
nextBtn.disabled = true;
ctaText.textContent = ‘Checking whether it’s a good point to move on…’;
try {
const verdict = await assessInsightCoveredForNextClick();
if (verdict.covered) {
triggerNextNavigation();
return;
}
// Hide the whole input area immediately
const inputWrap = qs(‘.cp-input’);
if (inputWrap) inputWrap.style.display = ‘none’;
// Hide the End button immediately
nextBtn.style.display = ‘none’;
await showEndOfInsightReport();
return;
} catch {
triggerNextNavigation();
} finally {
// Only restore the normal UI if we are NOT showing the end report
if (!endReportShown) {
sendBtn.disabled = false;
nextBtn.disabled = false;
renderCTA();
}
}
});
/**
* Return the most recent assistant message in the current thread.
*/
function getLastAssistantMessage() {
for (let i = thread.length – 1; i >= 0; i–) {
const message = thread[i];
if (message?.role === ‘assistant’) {
return message;
}
}
return null;
}
/**
* Find the coaching question which came before the continuation check.
*/
function getQuestionBeforeContinuationCheck() {
for (let i = thread.length – 1; i >= 0; i–) {
const message = thread[i];
if (message?.role !== ‘assistant’) {
continue;
}
if (
typeof message.content === ‘object’ &&
message.content?.control === ‘continuation_check’
) {
continue;
}
const question =
typeof message.content === ‘object’
? String(message.content?.question || ”).trim()
: String(message.content || ”).trim();
if (question) {
return question;
}
}
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
return String(
v.coachFields?.insightQuestion ||
v.insight?.question ||
”
).trim();
}
/**
* Handle the user’s answer to a coaching control question.
*
* Supported controls:
* – continuation_check
* – non_resonance_offer
*
* These replies are decisions about the coaching flow.
* They must never be passed to Layer A or Layer B as Insight evidence.
*/
async function handleContinuationCheckReply(userText, responseId) {
const lastAssistant = getLastAssistantMessage();
const control =
lastAssistant &&
typeof lastAssistant.content === ‘object’
? String(lastAssistant.content?.control || ”).trim()
: ”;
const isContinuationCheck =
control === ‘continuation_check’;
const isNonResonanceOffer =
control === ‘non_resonance_offer’;
if (!isContinuationCheck && !isNonResonanceOffer) {
return {
handled: false
};
}
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
/*
* NON-RESONANCE OFFER
*
* The user is choosing whether to leave the Insight or continue.
* This is not an Insight answer.
*/
if (isNonResonanceOffer) {
const aiResult = await window.cpAiService.callJson({
task: ‘non_resonance_offer_decision’,
responseId,
config: v,
payload: {
model: ‘gpt-4o’,
temperature: 0,
messages: [
{
role: ‘system’,
content:
`You classify the user’s answer to a coaching offer to leave or continue the current Insight.
Return ONLY valid JSON in this exact form:
{
“decision”: “continue|move_on|unclear”,
“reason”: “short reason”
}
Definitions:
– continue: the user clearly wants to continue exploring the current Insight.
– move_on: the user clearly wants to leave this Insight and move on.
– unclear: the user’s preference cannot be determined confidently.
Rules:
– This is a control decision, not an Insight answer.
– Do not analyse the user’s circumstances.
– Do not infer more than the user has said.
– “Continue”, “carry on”, “keep going”, “let’s continue” or similar means continue.
– “Move on”, “leave it”, “stop”, “that’s enough” or similar means move_on.`
},
{
role: ‘user’,
content:
`COACH OFFER:
${String(lastAssistant.content?.question || ”).trim()}
USER REPLY:
${String(userText || ”).trim()}`
}
]
}
});
const decision =
aiResult?.ok &&
[‘continue’, ‘move_on’, ‘unclear’].includes(
aiResult?.data?.decision
)
? aiResult.data.decision
: ‘unclear’;
/*
* User chooses to continue.
*
* The current line of enquiry has already reached non-resonance,
* so advance to the next framework step rather than restarting or
* repeating the current one.
*/
if (decision === ‘continue’) {
const stepCfg = getStepConfig();
const steps = stepCfg.steps || [];
const currentStepIndex = stepCfg.currentStep;
const hasNextStep =
steps.length > 0 &&
currentStepIndex < steps.length - 1;
let targetStepIndex = currentStepIndex;
if (hasNextStep) {
targetStepIndex = currentStepIndex + 1;
sessionStorage.removeItem(
`${stepCfg.stepKey}_follow_up_${currentStepIndex}`
);
sessionStorage.setItem(
stepCfg.stepKey,
String(targetStepIndex)
);
}
const rawTargetStep =
String(steps[targetStepIndex] || '').trim();
const targetStepObjective =
String(
stepCfg.completionCriteriaByStep?.[
targetStepIndex + 1
] ||
rawTargetStep ||
''
).trim();
/*
* Framework steps may contain internal labels such as
* "Qualities:" and may also assume material that the user
* never established, such as "that experience".
*
* After non-resonance, Continue means move forward through
* the framework — but the next question must still be written
* conversationally from what the user has actually established.
*/
let question = '';
if (
window.cpAiService &&
typeof window.cpAiService.callText === 'function' &&
v.aiUrl
) {
try {
const aiResult = await window.cpAiService.callText({
task: 'non_resonance_continue_writer',
responseId,
config: v,
payload: {
model: 'gpt-4o',
temperature: 0.3,
messages: [
{
role: 'system',
content:
`You are writing the next AI Coach question after the user has chosen Continue following a non-resonance offer.
The previous line of enquiry has been left behind. The coaching journey is now moving forward to the next framework step.
Your job is to write ONE natural coaching question that pursues the purpose of the target framework step.
Rules:
- Treat the target framework step as the intended coaching purpose, not as wording that must be copied.
- Do not expose framework labels, headings or configuration language such as "Qualities:", "Patterns:", "Experience:" or similar.
- Never prefix the question with a framework label followed by a colon.
- Use the conversation so far to understand what the user has actually established.
- Never assume an experience, example, feeling, belief or other premise that the user has not established.
- If the framework wording depends on missing information, reformulate it so the same step purpose can be explored from material the user has actually provided.
- Do not return to the line of enquiry that has just reached non-resonance.
- Do not try again to obtain an answer the user has repeatedly declined to provide.
- Move the coaching forward.
- Ask one question only.
- Do not write a reflection.
- Do not explain the framework or the transition.
- Use UK English.
- Return only the user-facing question.`
},
{
role: 'user',
content:
`TARGET FRAMEWORK STEP:
${rawTargetStep}
TARGET STEP OBJECTIVE:
${targetStepObjective}
RECENT CONVERSATION:
${buildTranscript(3000)}
ACCEPTED USER EVIDENCE:
${JSON.stringify(acceptedEvidence || [], null, 2)}`
}
]
}
});
if (aiResult?.ok) {
question =
String(aiResult.data || '').trim();
}
} catch (err) {
console.log(
'NON-RESONANCE CONTINUE WRITER ERROR:',
err
);
}
}
/*
* Safe fallback:
* remove an internal framework label if the AI writer
* was unavailable.
*/
if (!question) {
question = rawTargetStep
.replace(
/^[A-Za-z][A-Za-z\s/&-]{0,40}:\s*/,
''
)
.trim();
}
if (!question) {
question =
String(
v.coachFields?.insightQuestion ||
v.insight?.question ||
''
).trim();
}
/*
* Defensive clean-up in case a writer nevertheless returns
* a framework-style label.
*/
question = String(question || '')
.replace(
/^[A-Za-z][A-Za-z\s/&-]{0,40}:\s*/,
''
)
.trim();
await saveNow({
status: 'Live',
gate_result: JSON.stringify({
status: 'non_resonance_continued',
decision: 'continue'
}),
coach_decision: JSON.stringify({
next_model: 'next_framework_step',
control: 'non_resonance_offer'
})
});
pushMsg('assistant', {
reflection: '',
question,
debug_trace: '',
control: ''
});
return {
handled: true,
decision: 'continue'
};
}
/*
* User chooses to leave the Insight.
*
* Close the Insight properly:
* - let Layer D decide whether there is anything useful to reflect;
* - show that reflection only when there is meaningful material;
* - tell the user we are moving to the next Insight;
* - then navigate automatically.
*/
if (decision === 'move_on') {
const layerD =
await window.cpInsightLayerD.runLayerD({
config: v,
responseId,
thread,
insightQuestion: String(
v.coachFields?.insightQuestion ||
v.insight?.question ||
''
).trim(),
insightSummary: String(
v.coachFields?.insightSummary ||
v.insight?.summary ||
v.insight?.text ||
''
).trim(),
reflectionHistory:
v.coachMemory?.reflectionHistory || [],
previousInsightReviews:
v.coachMemory?.previousInsightReviews || [],
relevantTraitHistory:
v.coachMemory?.relevantTraitHistory || [],
traitUpdates: []
});
const hasReview =
layerD?.review_required === true &&
!!String(layerD.review || '').trim();
const review =
hasReview
? String(layerD.review || '').trim()
: '';
const closingDebugTrace =
`AI DECISION TRACE
Layered AI Coach
Control
Decision : non_resonance_offer
Result : end
Reason : The user chose to end the current Insight.
Layer D
Decision : ${layerD?.debug?.decision || 'D1'}
Result : ${
hasReview
? 'full_review'
: 'no_user_facing_review'
}
Reason : ${
layerD?.debug?.reason ||
layerD?.eligibility?.reason ||
'Layer D assessed whether the conversation contained enough meaningful material for a closing reflection.'
}`;
await saveNow({
status: 'Ended',
layer_d_result: JSON.stringify(layerD),
gate_result: JSON.stringify({
status: 'non_resonance_confirmed',
decision: 'end'
}),
coach_decision: JSON.stringify({
next_model:
hasReview
? 'review_then_next_insight'
: 'next_insight',
control: 'non_resonance_offer',
review_type:
hasReview
? layerD?.review_type || 'full'
: 'none'
})
});
const inputWrap = qs('.cp-input');
if (inputWrap) {
inputWrap.style.display = 'none';
}
/*
* If Layer D has nothing worthwhile to show the user,
* there is no reason to hold them on this Insight.
*/
if (!hasReview) {
endReportShown = false;
triggerNextNavigation();
return {
handled: true,
decision: 'move_on',
layerD
};
}
/*
* Layer D has produced a worthwhile closing reflection.
* Keep it visible until the user explicitly chooses Next Insight.
*/
pushMsg('assistant', {
reflection: '',
question: review,
debug_trace: closingDebugTrace,
control: ''
});
endReportShown = true;
const endReportBox = qs('#cp-end-report');
if (endReportBox) {
endReportBox.innerHTML = `
`;
endReportBox.style.display = ‘block’;
const nextInsightBtn =
qs(‘#cp-non-resonance-next’, endReportBox);
if (nextInsightBtn) {
nextInsightBtn.addEventListener(‘click’, () => {
endReportShown = false;
triggerNextNavigation();
});
}
}
return {
handled: true,
decision: ‘move_on’,
layerD
};
}
/*
* The reply was ambiguous, so ask only for the choice again.
*/
const question =
‘Would you like to continue with this Insight, or leave it here and move on?’;
await saveNow({
status: ‘Live’,
gate_result: JSON.stringify({
status: ‘non_resonance_choice_unclear’,
decision: ‘unclear’
}),
coach_decision: JSON.stringify({
next_model: ‘non_resonance_offer’,
control: ‘non_resonance_offer’
})
});
pushMsg(‘assistant’, {
reflection: ”,
question,
debug_trace: ”,
control: ‘non_resonance_offer’
});
return {
handled: true,
decision: ‘unclear’
};
}
/*
* EXISTING A5 CONTINUATION CHECK
*/
const fallbackQuestion = getQuestionBeforeContinuationCheck();
const aiResult = await window.cpAiService.callJson({
task: ‘continuation_check_decision’,
responseId,
config: v,
payload: {
model: ‘gpt-4o’,
temperature: 0,
messages: [
{
role: ‘system’,
content:
`You classify the user’s answer to a coaching continuation check.
Return ONLY valid JSON in this exact form:
{
“decision”: “continue|pause|unclear”,
“reason”: “short reason”
}
Definitions:
– continue: the user clearly wants to continue the current Insight now.
– pause: the user clearly wants to stop for now, pause, or return later.
– unclear: the user’s preference cannot be determined confidently.
Rules:
– This is not an Insight answer.
– Do not analyse the user’s circumstances.
– Do not infer more than the user has said.
– A simple yes, carry on, continue, or similar means continue.
– A simple no, not now, later, pause, or similar means pause.`
},
{
role: ‘user’,
content:
`CONTINUATION QUESTION:
${String(lastAssistant.content?.question || ”).trim()}
USER REPLY:
${String(userText || ”).trim()}`
}
]
}
});
const decision =
aiResult?.ok &&
[‘continue’, ‘pause’, ‘unclear’].includes(
aiResult?.data?.decision
)
? aiResult.data.decision
: ‘unclear’;
if (decision === ‘continue’) {
const question =
fallbackQuestion ||
‘Let us return to the Insight. What were you noticing in response to the question?’;
await saveNow({
status: ‘Live’,
gate_result: JSON.stringify({
status: ‘continuation_resumed’,
decision: ‘continue’
}),
coach_decision: JSON.stringify({
next_model: ‘resume_insight’
})
});
pushMsg(‘assistant’, {
reflection: ”,
question,
debug_trace: ”,
control: ”
});
return {
handled: true,
decision: ‘continue’
};
}
if (decision === ‘pause’) {
const question =
‘Of course. We can pause here, and you can return to this Insight when the time feels right.’;
await saveNow({
status: ‘Live’,
gate_result: JSON.stringify({
status: ‘continuation_paused’,
decision: ‘pause’
}),
coach_decision: JSON.stringify({
next_model: ‘pause_insight’
})
});
pushMsg(‘assistant’, {
reflection: ”,
question,
debug_trace: ”,
control: ”
});
return {
handled: true,
decision: ‘pause’
};
}
const question =
‘Would you like to continue with this Insight now, or pause and return another time?’;
await saveNow({
status: ‘Live’,
gate_result: JSON.stringify({
status: ‘continuation_unclear’,
decision: ‘unclear’
}),
coach_decision: JSON.stringify({
next_model: ‘continuation_check’
})
});
pushMsg(‘assistant’, {
reflection: ”,
question,
debug_trace: ”,
control: ‘continuation_check’
});
return {
handled: true,
decision: ‘unclear’
};
}
async function runLayeredInsightTurn(userText) {
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
if (
!window.cpAiService ||
typeof window.cpAiService.callJson !== ‘function’ ||
typeof window.cpAiService.callText !== ‘function’ ||
!window.cpInsightLayerA ||
!window.cpInsightLayerB ||
!window.cpInsightLayerC ||
!window.cpInsightLayerD ||
!window.cpInsightOrchestrator ||
typeof window.cpInsightOrchestrator.handleUserTurnLayered !== ‘function’
) {
throw new Error(‘Layered Insight architecture is not fully loaded.’);
}
const stepCfg = getStepConfig();
console.log(‘STEP CONFIG:’, stepCfg);
const steps = stepCfg.steps || [];
const currentStepIndex = stepCfg.currentStep;
const currentFrameworkStep =
steps[currentStepIndex] || ”;
const nextFrameworkStep =
steps[currentStepIndex + 1] || ”;
const isFinalFrameworkStep =
steps.length > 0 &&
currentStepIndex >= steps.length – 1;
const followUpKey =
`${stepCfg.stepKey}_follow_up_${currentStepIndex}`;
const followUpUsed =
sessionStorage.getItem(followUpKey) === ‘1’;
const responseId = getOrCreateResponseId();
/*
* cp-ai-service.js requires the current response record to exist
* before any AI request is made.
*/
await saveNow({
status: ‘Live’,
response_id: responseId,
user_input: userText
});
/*
* A reply to an A5 continuation check is a control decision,
* not evidence for the current Insight.
*/
const continuationResult =
await handleContinuationCheckReply(
userText,
responseId
);
if (continuationResult.handled) {
return {
stopped_at: ‘CONTINUATION_DECISION’,
continuation: continuationResult
};
}
const lastAssistantMessage = getLastAssistantMessage();
const lastCoachQuestion =
lastAssistantMessage?.role === ‘assistant’
? (
typeof lastAssistantMessage.content === ‘object’
? String(
lastAssistantMessage.content?.question || ”
).trim()
: String(lastAssistantMessage.content || ”).trim()
)
: ”;
console.log(‘LAYERED STEP CONTEXT:’, {
currentFrameworkStep,
currentStepObjective:
stepCfg.currentStepObjective || currentFrameworkStep || ”,
stepCompletionCriteria:
stepCfg.currentStepObjective ||
stepCfg.completionCriteria ||
currentFrameworkStep ||
”,
rawCompletionCriteria:
stepCfg.completionCriteria || ”,
completionCriteriaByStep:
stepCfg.completionCriteriaByStep || {}
});
const result =
await window.cpInsightOrchestrator.handleUserTurnLayered({
config: v,
responseId,
userText,
thread,
lastCoachQuestion,
insightQuestion: String(
v.coachFields?.insightQuestion ||
v.insight?.question ||
”
).trim(),
insightSummary: String(
v.coachFields?.insightSummary ||
v.insight?.summary ||
v.insight?.text ||
”
).trim(),
interventionScope: String(
v.coachFields?.interventionScope || ”
).trim(),
aiInsightPromptV3: String(
v.coachFields?.aiInsightPromptV3 || ”
).trim(),
currentFrameworkStep,
currentStepObjective: String(
stepCfg.currentStepObjective ||
currentFrameworkStep ||
”
).trim(),
nextFrameworkStep,
nextStepObjective: String(
stepCfg.nextStepObjective ||
nextFrameworkStep ||
”
).trim(),
stepCompletionCriteria: String(
stepCfg.currentStepObjective ||
stepCfg.completionCriteria ||
currentFrameworkStep ||
”
).trim(),
fallbackRoutes: String(
stepCfg.fallbackRoutes || ”
).trim(),
allowedMoves: String(
stepCfg.allowedMoves || ”
).trim(),
disallowedMoves: String(
stepCfg.disallowedMoves || ”
).trim(),
closureRule: String(
stepCfg.closureRule || ”
).trim(),
isFinalFrameworkStep,
followUpUsed,
aiStepPromptV3: String(
v.coachFields?.aiStepPromptV3 || ”
).trim(),
coachStyle: String(
v.coachFields?.coachStyle ||
v.tone ||
‘direct’
).trim(),
acceptedEvidence,
currentTraitStatus:
v.coachMemory?.currentTraitStatus || {},
futureChapterMap:
v.coachMemory?.futureChapterMap || {},
reflectionHistory:
v.coachMemory?.reflectionHistory || [],
previousInsightReviews:
v.coachMemory?.previousInsightReviews || [],
relevantTraitHistory:
v.coachMemory?.relevantTraitHistory || [],
pushAssistantMessage(message) {
pushMsg(‘assistant’, message);
},
async saveNow(payload) {
await saveNow(payload);
}
});
/*
* Retain only evidence that Layer B itself classified as:
* – new
* – materially_developed
*
* Repeated evidence is not added again.
* Unknown evidence is not treated as accepted.
*/
retainAcceptedEvidence(result?.layerB);
const strategy =
result?.layerB?.strategy?.response_strategy || ”;
if (strategy === ‘follow_up’) {
sessionStorage.setItem(followUpKey, ‘1’);
}
if (
strategy === ‘next_step’ &&
!isFinalFrameworkStep &&
steps.length
) {
sessionStorage.removeItem(followUpKey);
const nextStepIndex = Math.min(
currentStepIndex + 1,
steps.length – 1
);
sessionStorage.setItem(
stepCfg.stepKey,
String(nextStepIndex)
);
}
/*
* NON-RESONANCE OFFER
*
* C3 has NOT ended the Insight.
* Layer B has decided that the current exploration may no longer
* be useful, so give the user an explicit choice:
*
* – Continue → remain in this Insight and move beyond the
* stalled line of enquiry.
* – Next Insight → close this Insight properly through Layer D.
*
* Do not require the user to type this control decision.
*/
if (result?.stopped_at === ‘C3_NON_RESONANCE_OFFER’) {
const inputWrap = qs(‘.cp-input’);
if (inputWrap) {
inputWrap.style.display = ‘none’;
}
if (nextBtn) {
nextBtn.disabled = true;
nextBtn.style.display = ‘none’;
}
const choiceBox = qs(‘#cp-end-report’);
if (choiceBox) {
choiceBox.innerHTML = `
`;
choiceBox.style.display = ‘block’;
const continueBtn =
qs(‘#cp-non-resonance-continue’, choiceBox);
const moveOnBtn =
qs(‘#cp-non-resonance-move-on’, choiceBox);
/*
* Continue with this Insight.
*
* Feed the decision into the existing non_resonance_offer
* control route. That route already advances beyond the stalled
* framework line rather than asking the same question again.
*/
if (continueBtn) {
continueBtn.addEventListener(‘click’, async () => {
if (continueBtn.disabled) return;
continueBtn.disabled = true;
if (moveOnBtn) moveOnBtn.disabled = true;
choiceBox.innerHTML = ”;
choiceBox.style.display = ‘none’;
const continuationResult =
await handleContinuationCheckReply(
‘continue’,
responseId
);
if (
continuationResult?.handled &&
continuationResult?.decision === ‘continue’
) {
if (inputWrap) {
inputWrap.style.display = ”;
}
if (sendBtn) {
sendBtn.style.display = ”;
sendBtn.disabled = true;
}
if (nextBtn) {
nextBtn.style.display = ”;
nextBtn.hidden = false;
nextBtn.disabled = false;
nextBtn.removeAttribute(‘disabled’);
nextBtn.textContent = ‘End’;
}
if (ctaText) {
ctaText.textContent = ‘Awaiting your response…’;
}
clearNudge();
scheduleNudge();
}
});
}
/*
* Leave this Insight.
*
* Again use the existing control route. It already:
* – runs Layer D;
* – shows a closing reflection when warranted;
* – saves the Insight as Ended;
* – leaves the final Next Insight button visible.
*/
if (moveOnBtn) {
moveOnBtn.addEventListener(‘click’, async () => {
if (moveOnBtn.disabled) return;
moveOnBtn.disabled = true;
if (continueBtn) continueBtn.disabled = true;
choiceBox.innerHTML = ”;
choiceBox.style.display = ‘none’;
await handleContinuationCheckReply(
‘move on’,
responseId
);
});
}
}
return result;
}
/*
* ACTUAL END-OF-INSIGHT STATES
*
* Layer D has completed the closing process.
*/
if (
result?.stopped_at === ‘D_REVIEW’ ||
result?.stopped_at === ‘D_RELATIONSHIP_RESISTANCE’
) {
endReportShown = true;
const inputWrap = qs(‘.cp-input’);
if (inputWrap) inputWrap.style.display = ‘none’;
if (nextBtn) {
nextBtn.disabled = true;
nextBtn.style.display = ‘none’;
}
/*
* Resume is available only when framework steps genuinely remain
* and the user has not explicitly chosen to stop the conversation.
*/
const endReportBox = qs(‘#cp-end-report’);
const userExplicitlyStopped =
result?.stopped_at === ‘D_RELATIONSHIP_RESISTANCE’;
const canResume =
!userExplicitlyStopped &&
!isFinalFrameworkStep &&
steps.length > 0;
if (endReportBox) {
endReportBox.innerHTML = `
canResume
? `
`
: ”
}
`;
endReportBox.style.display = ‘block’;
const resumeBtn = qs(‘#cp-end-report-resume’, endReportBox);
const nextInsightBtn = qs(‘#cp-end-report-next’, endReportBox);
if (resumeBtn) {
resumeBtn.addEventListener(‘click’, async () => {
endReportShown = false;
nextConfirmedOverride = false;
endReportBox.innerHTML = ”;
endReportBox.style.display = ‘none’;
const nextStepIndex = Math.min(
currentStepIndex + 1,
steps.length – 1
);
sessionStorage.removeItem(followUpKey);
sessionStorage.setItem(
stepCfg.stepKey,
String(nextStepIndex)
);
const nextQuestion =
String(steps[nextStepIndex] || ”).trim();
await saveNow({
status: ‘Live’,
coach_decision: JSON.stringify({
next_model: ‘next_framework_step’,
control: ‘resume_after_review’
})
});
if (nextQuestion) {
pushMsg(‘assistant’, {
reflection: ”,
question: nextQuestion,
debug_trace: ”,
control: ”
});
}
if (inputWrap) {
inputWrap.style.display = ”;
}
if (sendBtn) {
sendBtn.style.display = ”;
sendBtn.disabled = true;
}
if (nextBtn) {
nextBtn.style.display = ”;
nextBtn.hidden = false;
nextBtn.disabled = false;
nextBtn.removeAttribute(‘disabled’);
nextBtn.textContent = ‘End’;
}
if (ctaText) {
ctaText.textContent = ‘Awaiting your response…’;
}
clearNudge();
scheduleNudge();
});
}
if (nextInsightBtn) {
nextInsightBtn.addEventListener(‘click’, () => {
endReportShown = false;
triggerNextNavigation();
});
}
}
}
return result;
}
// Send handler
sendBtn.addEventListener(‘click’, async () => {
if (aiTurnInProgress) {
console.log(‘SEND BLOCKED: already processing’);
return;
}
if (sendBtn.disabled) return;
let text = (ta.value || ”).trim();
if (!text) return;
aiTurnInProgress = true;
// Lock UI while AI Coach is working
sendBtn.disabled = true;
nextBtn.disabled = true;
ctaText.textContent = ‘AI Coach is updating your notes’;
// Spellcheck before submit
text = await spellcheckLocale(text);
// Clear input and commit the user message to the thread
ta.value = ”;
coachState.draft = ”;
nextConfirmedOverride = false;
endReportShown = false;
renderEndOfInsightReport(”, ”);
nextBtn.textContent = ‘End’;
nextBtn.style.display = ”;
pushMsg(‘user’, text);
try {
await runLayeredInsightTurn(text);
} catch (e) {
pushMsg(
‘assistant’,
`Sorry — something went wrong. ${String(e.message || e)}`
);
} finally {
aiTurnInProgress = false;
sendBtn.disabled = false;
nextBtn.disabled = false;
renderCTA();
}
});
// Initial UI state
sendBtn.textContent = ‘Send’;
nextBtn.textContent = ‘End’;
nextBtn.style.display = ”;
coachState.draft = String(ta.value || ”);
renderCTA();
}
async function init() {
const page = (window.cpVars && window.cpVars.insightPage) ? window.cpVars.insightPage : {};
const userpass = getParam(‘userpass’,”);
const edition = (getParam(‘edition’,’workplace’) || ‘workplace’).toLowerCase();
const insight = getParam(‘insight’,”);
const tone = (getParam(‘tone’,’direct’) || ‘direct’).toLowerCase();
page.tone = tone;
if (!userpass) {
const root = qs(‘#cp-insight-root’);
if (root) root.innerHTML = `
`;
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;
page.coachMemory =
ctx && ctx.coachMemory && typeof ctx.coachMemory === ‘object’
? ctx.coachMemory
: {};
// KEEP: entry-key (used for routing + saving)
page.insightSequence = insight;
// NEW: human-facing Insight Sequence number (Formidable field [6856])
// Prefer numeric key access because it’s the most reliable across payload variants.
const i = ctx?.insight || {};
const i2 = ctx?.insight_fields || ctx?.insightFields || ctx?.fields || {}; // defensive
// Helper: first non-empty value from a list of candidates
const pick = (…vals) => {
for (const v of vals) {
const s = (v === null || v === undefined) ? ” : String(v);
if (s.trim() !== ”) return s.trim();
}
return ”;
};
// NEW: sequence number from field [6856] (plus common named variants)
page.insightSequenceNumber = pick(
// numeric field key variants
i2?.[‘6856’], i?.[‘6856’],
i2?.[6856], i?.[6856],
// common human-name variants (depends how MU serialises the record)
i2?.[‘Insight Sequence’], i?.[‘Insight Sequence’],
i2?.[‘Insight Sequence field’], i?.[‘Insight Sequence field’],
i2?.insight_sequence, i?.insight_sequence,
i2?.insightSequence, i?.insightSequence
);
// Debug (only when missing): show which keys we actually got back
if (!page.insightSequenceNumber) {
console.log(‘[cp-insight] missing Insight Sequence [6856]’, {
insightKey: insight, // the entry-key from URL
insightKeys: Object.keys(i || {}).sort(),
altKeys: Object.keys(i2 || {}).sort(),
sample: {
i_6856: i?.[‘6856’] ?? i?.[6856],
i2_6856: i2?.[‘6856’] ?? i2?.[6856],
i_named: i?.[‘Insight Sequence’],
i2_named: i2?.[‘Insight Sequence’]
}
});
}
page.insight = ctx.insight;
page.videos = ctx.videos;
page.mode = ctx.mode;
// Prefer the MU-provided canonical field (ctx.insight.next).
page.nextInsightKey = pick(
i?.next,
i?.nextInsightKey,
i2?.[‘6897’], i?.[‘6897’],
i2?.[6897], i?.[6897]
);
// NEW: curated coaching rails (field names)
// (i, i2, pick already defined above)
const interventionScope =
i?.intervention_scope_v3 ??
i?.[‘Intervention Scope v3’] ??
i?.[‘7207’] ??
i?.[7207] ??
i2?.intervention_scope_v3 ??
i2?.[‘Intervention Scope v3’] ??
i2?.[‘7207’] ??
i2?.[7207] ??
”;
const aiInsightPromptV3 =
i?.ai_insight_prompt_v3 ??
i?.[‘AI Insight Prompt v3’] ??
i?.[‘7208’] ??
i?.[7208] ??
i2?.ai_insight_prompt_v3 ??
i2?.[‘AI Insight Prompt v3’] ??
i2?.[‘7208’] ??
i2?.[7208] ??
”;
const aiStepPromptV3 =
i?.ai_step_prompt_v3 ??
i?.[‘AI Step Prompt v3’] ??
i?.[‘7209’] ??
i?.[7209] ??
i2?.ai_step_prompt_v3 ??
i2?.[‘AI Step Prompt v3’] ??
i2?.[‘7209’] ??
i2?.[7209] ??
”;
// Debug (only when missing): shows what keys are actually coming back from the endpoint
// Debug (only when missing): shows what keys are actually coming back from the endpoint
if (!interventionScope || !aiInsightPromptV3 || !aiStepPromptV3) {
console.log(‘[cp-insight] missing coach rails (v3)’, {
interventionScopePresent: !!interventionScope,
aiInsightPromptV3Present: !!aiInsightPromptV3,
aiStepPromptV3Present: !!aiStepPromptV3,
insightKeys: Object.keys(i || {}).sort(),
altKeys: Object.keys(i2 || {}).sort()
});
}
console.log(‘Insight object keys:’, Object.keys(i || {}));
console.log(‘Alt insight object keys:’, Object.keys(i2 || {}));
const aiCoachFrameworkSteps = pick(
i?.[7202],
i?.[‘7202’],
i2?.[7202],
i2?.[‘7202’],
i?.ai_coach_framework_steps,
i2?.ai_coach_framework_steps,
i?.[‘AI Coach Framework Steps – KEY FIELD’],
i2?.[‘AI Coach Framework Steps – KEY FIELD’]
);
const stepCompletionCriteria = pick(
i?.[7217],
i?.[‘7217’],
i2?.[7217],
i2?.[‘7217’],
i?.step_completion_criteria,
i2?.step_completion_criteria,
i?.[‘step_completion_criteria’],
i2?.[‘step_completion_criteria’]
);
const fallbackRoutes = pick(
i?.[7218],
i?.[‘7218’],
i2?.[7218],
i2?.[‘7218’],
i?.fallback_routes,
i2?.fallback_routes,
i?.[‘fallback_routes’],
i2?.[‘fallback_routes’]
);
const allowedMoves = pick(
i?.[7219],
i?.[‘7219’],
i2?.[7219],
i2?.[‘7219’],
i?.allowed_moves,
i2?.allowed_moves,
i?.[‘allowed_moves’],
i2?.[‘allowed_moves’]
);
const disallowedMoves = pick(
i?.[7220],
i?.[‘7220’],
i2?.[7220],
i2?.[‘7220’],
i?.disallowed_moves,
i2?.disallowed_moves,
i?.[‘disallowed_moves’],
i2?.[‘disallowed_moves’]
);
const closureRule = pick(
i?.closure_rule,
i2?.closure_rule,
i?.[‘closure_rule’],
i2?.[‘closure_rule’],
i?.[‘Closure Rule’],
i2?.[‘Closure Rule’]
);
page.coachFields = {
insightQuestion: pick(
i?.[‘Insight Question’],
i?.question,
i2?.[‘Insight Question’],
i2?.question
),
insightSummary: pick(
i?.[‘Insight Summary’],
i2?.[‘Insight Summary’],
i?.insight_summary,
i2?.insight_summary
),
coachGuidance: pick(
i?.[‘AI Coach – Generic advice, customised for Insight’],
i2?.[‘AI Coach – Generic advice, customised for Insight’]
),
interventionScope,
aiInsightPromptV3,
aiStepPromptV3,
aiCoachFrameworkSteps,
step_completion_criteria: stepCompletionCriteria,
fallback_routes: fallbackRoutes,
allowed_moves: allowedMoves,
disallowed_moves: disallowedMoves,
closure_rule: closureRule,
coachStyle: pick(
i?.[‘Coach Style’],
i2?.[‘Coach Style’]
),
coachChunking: pick(
i?.[‘Coach Chunking’],
i2?.[‘Coach Chunking’]
)
};
// NEW: capture Insight Guide (WE Insight Guide)
page.insightGuide = String(
ctx?.insight?.[‘WE Insight Guide’] || ”
).trim();
renderInsight(ctx);
// Seed first AI prompt with the Insight question
if (ctx?.insight?.question) {
pushMsg(‘assistant’, ctx.insight.question);
}
// Optional resume fetch (if you later wire the MU resume payload properly)
// const r = await fetch(`${v.resumeUrl}?userpass=${encodeURIComponent(userpass)}&insight=${encodeURIComponent(insight)}`, { headers });
// const rd = await r.json();
// if (rd?.resume?.dialogue) { /* parse transcript back into thread if you want */ }
}
if (document.readyState === ‘loading’) {
document.addEventListener(‘DOMContentLoaded’, init);
} else {
init();
}
})();