(() => {
// 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 payload = {
model: v.aiModel,
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 aiResult = await window.cpAiService.callText({
task: 'insight_spellcheck',
responseId: getOrCreateResponseId(),
config: v,
payload
});
if (!aiResult || !aiResult.ok) return original;
let corrected = String(aiResult.data || '').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 = [];
/*
* Restore a saved Form 320 dialogue transcript into the live thread.
*
* buildTranscript() persists dialogue using:
* – You:
* – AI Coach:
* – Reflection:
*
* This parser reverses that saved representation without generating
* any new coaching content.
*/
function restoreThreadFromTranscript(savedDialogue) {
const transcript = String(savedDialogue || ”).trim();
if (!transcript) return false;
const restoredThread = [];
transcript.split(/\r?\n/).forEach(line => {
const text = String(line || ”).trim();
if (!text) return;
if (text.startsWith(‘You: ‘)) {
restoredThread.push({
role: ‘user’,
content: text.slice(5).trim()
});
return;
}
if (text.startsWith(‘AI Coach: ‘)) {
restoredThread.push({
role: ‘assistant’,
content: text.slice(10).trim()
});
return;
}
if (text.startsWith(‘Reflection: ‘)) {
restoredThread.push({
role: ‘assistant’,
content: {
reflection: text.slice(12).trim(),
general_information: ”,
question: ”,
debug_trace: ”,
control: ”
}
});
}
});
if (!restoredThread.length) return false;
thread.splice(
0,
thread.length,
…restoredThread
);
return true;
}
/*
* 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.
*
* The current Insight’s accepted evidence is also persisted in the existing
* Form 320 payload so novelty comparison and Chapter-end accumulation survive
* a page reload.
*/
let acceptedEvidence = [];
/*
* Significant material explicitly parked for return before the
* current Insight review.
*
* This state is deliberately separate from framework progression.
* Parking does not alter, reorder or bypass any framework step.
*
* Shape:
* [
* {
* summary: “brief factual description of the parked point”
* }
* ]
*
* This is intentionally in-memory only for now.
* Persistence and reload restoration are separate changes.
*/
const parkedMaterial = [];
/*
* Cumulative Step Coverage Map for the current Insight.
*
* This is restored from Form 320 field 7234 and then kept current
* in memory during the live coaching turn.
*
* Shape:
* {
* “1”: “not_addressed|partially_addressed|left_incomplete|sufficiently_addressed”,
* “2”: “…”,
* …
* }
*/
const stepCoverageMap = {};
/*
* Semantic context for incomplete framework steps.
*
* Layer A owns this meaning. When a step is partially addressed or
* left incomplete, this map retains:
* – what is already established;
* – what worthwhile territory still remains.
*
* For left_incomplete the remaining gap is preserved as framework
* state, but it is no longer eligible for another substantive follow-up.
*
* Shape:
* {
* “2”: {
* established: “…”,
* remaining_gap: “…”
* }
* }
*
* Only partially_addressed or left_incomplete steps should retain an entry.
*/
const stepCoverageContext = {};
/*
* Response Session
*
* Temporary working memory for the response the user is currently
* composing.
*
* This state is deliberately separate from stepCoverageMap and
* stepCoverageContext:
*
* – stepCoverageMap / stepCoverageContext contain authoritative
* framework state from completed user responses.
*
* – responseSession contains provisional understanding gathered while
* the current response is still being composed.
*
* Nothing in responseSession is persisted to Form 320 merely because
* it has been heard during composition.
*
* IMPORTANT:
* Once meaningful material has been heard and incorporated into the
* provisional understanding, deleting those words from the textarea
* does not automatically remove that understanding.
*
* Later user material may, however, explicitly correct, qualify,
* contradict or supersede something heard earlier.
*
* The provisional evidence structure uses the same framework step
* numbers already defined for the current Insight. It does not create
* a second framework or semantic vocabulary.
*/
const responseSession = {
active: false,
/*
* Increment whenever the developing response becomes eligible for
* another progressive assessment.
*
* This will later let us distinguish current assessment results from
* stale results without making textarea edits erase heard evidence.
*/
version: 0,
/*
* Latest literal textarea contents.
*
* This is useful for detecting new material and deciding when another
* assessment may be worthwhile. It is NOT the source of truth for
* what the Coach has already heard.
*/
currentText: ”,
/*
* Meaningful material heard during this response session, organised
* against the existing Insight framework.
*
* Expected shape:
* {
* “1”: [“…”],
* “2”: [“…”],
* …
* }
*
* Later assessments may add to, qualify or supersede this material.
*/
heardEvidence: {},
/*
* Provisional framework judgement derived from heardEvidence.
*
* This is working state only. It must not overwrite the authoritative
* stepCoverageMap until the completed response has been finalised
* after Send.
*/
provisionalCoverage: {},
/*
* Provisional semantic understanding for framework steps where useful.
*
* This may later contain established material and a likely remaining
* gap, but remains non-authoritative until Send finalisation.
*/
provisionalStepContext: {},
/*
* Likely first unresolved framework step based on what has been heard
* so far. This is disposable working state and may change as the user
* continues responding.
*/
likelyNextStep: null,
/*
* Reserved for a likely next coaching question prepared before Send.
* A later assessment may replace or clear it.
*/
provisionalNextQuestion: ”,
/*
* Provisional results already produced by the shared A1/B1/B1a/B5
* intelligence at the latest progressive checkpoint.
*
* These remain non-authoritative until Send.
*/
provisionalGate: null,
provisionalLayerBEvidence: null,
provisionalLayerBEvidenceCheckpointText: ”,
handoverLayerBEvidence: null,
handoverLayerBEvidenceCheckpointText: ”,
provisionalLayerBNovelty: null,
provisionalStrategy: null,
/*
* Progressive B1 has its own concurrency control so slower evidence
* processing never holds up Layer A #1 destination processing.
*/
evidenceAssessmentInFlight: false,
evidenceAssessmentQueued: null,
/*
* Coherent result package from the most recently completed progressive
* checkpoint.
*
* Unlike heardEvidence, this represents one exact response state.
* It is committed only when all progressive processing required for
* that checkpoint has finished and the Response Session version is
* still current.
*
* Send may reuse this package only when checkpointText represents the
* complete submitted response.
*/
completedCheckpoint: null,
/*
* Live Layer A #1 destination batch for the latest progressive
* checkpoint.
*
* This object is deliberately retained by reference because it may
* contain an in-flight decisionPromise that Send can later adopt.
*/
destinationBatch: null,
/*
* Progressive assessment concurrency controls.
*
* We will allow only one progressive assessment to run at a time.
* Material arriving during that request can later cause one fresh
* assessment rather than creating overlapping requests.
*/
assessmentInFlight: false,
assessmentQueued: false,
/*
* Literal textarea state at the most recent progressive assessment
* checkpoint.
*
* This is used only to decide whether enough additional material has
* accumulated to make another assessment worthwhile.
*/
lastCheckpointText: ”,
/*
* Literal textarea state represented by the most recently completed
* progressive assessment.
*
* Unlike lastCheckpointText, this is updated only after the progressive
* processing for that checkpoint has finished successfully.
*/
lastCompletedCheckpointText: ”
};
/*
* Decide whether the developing response has reached a useful
* progressive-assessment checkpoint.
*
* Normal trigger:
* – the latest non-whitespace character completes a sentence.
*
* Fallback trigger:
* – substantial additional text has accumulated without punctuation.
*
* This function does NOT assess meaning and does NOT call the AI.
* It only decides whether the response is eligible for assessment.
*/
function responseSessionCheckpointEligible() {
if (!responseSession.active) return false;
const currentText =
String(responseSession.currentText || ”).trim();
if (!currentText) return false;
const checkpointText =
String(responseSession.lastCheckpointText || ”);
/*
* If the literal textarea is unchanged from the last checkpoint,
* there is nothing new to assess.
*/
if (currentText === checkpointText.trim()) {
return false;
}
/*
* Primary trigger: sentence boundary.
*
* This deliberately checks only the end of the developing response,
* so punctuation earlier in text that the user is still extending
* does not repeatedly retrigger assessment.
*/
if (/[.!?]$/.test(currentText)) {
return true;
}
/*
* Fallback for users who do not punctuate while composing.
*
* This is deliberately mechanical. It does not attempt to decide
* whether the new material is semantically important.
*/
const additionalLength =
Math.max(
0,
currentText.length – checkpointText.trim().length
);
return additionalLength >= 160;
}
/*
* Run one silent progressive evidence assessment.
*
* This updates Response Session working memory only.
*
* It does NOT:
* – update authoritative framework coverage;
* – decide progression;
* – save to Form 320;
* – generate a visible Coach response.
*
* Only one request may run at a time. If another checkpoint is reached
* while a request is in flight, assessmentQueued records that the newest
* developing response should be assessed once the current request ends.
*/
async function runProgressiveResponseAssessment() {
if (!responseSession.active) return;
if (
!window.cpInsightLayerA ||
typeof window.cpInsightLayerA.assessProgressiveResponseEvidence !==
‘function’ ||
typeof window.cpInsightLayerA.assessProgressiveFrameworkCoverage !==
‘function’
) {
return;
}
if (responseSession.assessmentInFlight) {
responseSession.assessmentQueued = true;
return;
}
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
/*
* Use the same framework parser as the existing live coaching path.
*/
const stepConfig = getStepConfig();
const frameworkSteps =
Array.isArray(stepConfig.steps)
? stepConfig.steps
: [];
const completionCriteriaByStep =
stepConfig.completionCriteriaByStep || {};
const responseText =
String(responseSession.currentText || ”).trim();
if (!responseText) return;
/*
* Capture the version and literal text represented by this checkpoint.
*
* lastCheckpointText remains mechanical checkpoint state only.
*/
const assessedVersion =
responseSession.version;
responseSession.lastCheckpointText =
responseSession.currentText;
responseSession.assessmentInFlight = true;
responseSession.assessmentQueued = false;
try {
/*
* Checkpoint-specific results are kept local while this assessment
* is running. They will only be committed to Response Session state
* once the whole checkpoint has completed and is still current.
*
* heardEvidence is deliberately excluded because it is cumulative
* Response Session memory rather than exact-checkpoint state.
*/
let checkpointCoverage = {};
let checkpointStepContext = {};
let checkpointGate = null;
let checkpointLayerBEvidence = null;
let checkpointLayerBNovelty = null;
let checkpointStrategy = null;
let checkpointLikelyNextStep = null;
let checkpointNextQuestion = ”;
/*
* PHASE 1
*
* Accumulate meaningful evidence from the unfinished response.
*/
const evidenceResult =
await window.cpInsightLayerA.assessProgressiveResponseEvidence({
config: v,
responseId: getOrCreateResponseId(),
frameworkSteps,
completionCriteriaByStep,
heardEvidence:
responseSession.heardEvidence,
responseText
});
if (
evidenceResult &&
evidenceResult.heard_evidence &&
typeof evidenceResult.heard_evidence === ‘object’ &&
!Array.isArray(evidenceResult.heard_evidence)
) {
responseSession.heardEvidence =
evidenceResult.heard_evidence;
}
/*
* Send may have taken ownership while Progressive Evidence was
* awaiting its AI result.
*
* Do not continue into A2 once the complete response has been
* submitted. The Send path must not wait for or compete with
* speculative background processing.
*/
if (!responseSession.active) {
return;
}
/*
* PHASE 2
*
* Reuse the existing Layer A A2a/A2b framework semantics to form a
* provisional whole-framework judgement.
*
* Authoritative completed-response state is supplied as the baseline.
* heardEvidence is additional provisional evidence from the unfinished
* response.
*
* Nothing returned here is written into stepCoverageMap or
* stepCoverageContext.
*/
const frameworkResult =
await window.cpInsightLayerA.assessProgressiveFrameworkCoverage({
config: v,
responseId: getOrCreateResponseId(),
frameworkSteps,
completionCriteriaByStep,
stepCoverageMap,
stepCoverageContext,
acceptedEvidence,
heardEvidence:
responseSession.heardEvidence,
userText: responseText
});
if (
frameworkResult &&
frameworkResult.step_coverage &&
typeof frameworkResult.step_coverage === ‘object’ &&
!Array.isArray(frameworkResult.step_coverage)
) {
checkpointCoverage = {
…frameworkResult.step_coverage
};
}
if (
frameworkResult &&
frameworkResult.step_context &&
typeof frameworkResult.step_context === ‘object’ &&
!Array.isArray(frameworkResult.step_context)
) {
checkpointStepContext = {
…frameworkResult.step_context
};
}
/*
* PHASE 3
*
* Progressive processing stops once the developing response has been
* understood against the framework.
*
* A1, Layer B, next-step resolution, Layer C and Layer D belong to
* the authoritative Send path.
*
* Commit the completed Evidence + A2 result only when this checkpoint
* still represents the current Response Session state.
*/
if (
responseSession.active &&
assessedVersion === responseSession.version
) {
const completedCheckpoint = {
checkpointText: responseText,
version: assessedVersion,
provisionalCoverage: {
…checkpointCoverage
},
provisionalStepContext: {
…checkpointStepContext
},
/*
* These fields remain present for compatibility with the existing
* Send reconciliation structure, but progressive processing no
* longer calculates them.
*/
provisionalGate: null,
provisionalLayerBEvidence: null,
provisionalLayerBNovelty: null,
provisionalStrategy: null,
likelyNextStep: null,
provisionalNextQuestion: ”
};
responseSession.provisionalCoverage = {
…completedCheckpoint.provisionalCoverage
};
responseSession.provisionalStepContext = {
…completedCheckpoint.provisionalStepContext
};
responseSession.provisionalGate = null;
responseSession.provisionalLayerBEvidence = null;
responseSession.provisionalLayerBNovelty = null;
responseSession.provisionalStrategy = null;
responseSession.likelyNextStep = null;
responseSession.provisionalNextQuestion = ”;
responseSession.completedCheckpoint =
completedCheckpoint;
responseSession.lastCompletedCheckpointText =
responseText;
}
console.log(
‘RESPONSE SESSION PROGRESSIVE FRAMEWORK:’,
{
assessedVersion,
currentVersion: responseSession.version,
heardEvidence:
responseSession.heardEvidence,
provisionalCoverage:
responseSession.provisionalCoverage,
provisionalStepContext:
responseSession.provisionalStepContext
}
);
} catch (err) {
console.log(
‘RESPONSE SESSION PROGRESSIVE ASSESSMENT ERROR:’,
err
);
} finally {
responseSession.assessmentInFlight = false;
/*
* If another eligible checkpoint arrived while either progressive
* phase was running, assess the newest eligible textarea state now.
*/
if (
responseSession.active &&
responseSession.assessmentQueued
) {
responseSession.assessmentQueued = false;
if (responseSessionCheckpointEligible()) {
responseSession.assessmentInFlight = false;
void runProgressiveResponseAssessment();
return; }
}
/*
* No further progressive assessment is waiting.
* Ask the UI layer to render the new idle state.
*/
window.dispatchEvent(
new CustomEvent(‘cp_ai_progressive_status_changed’)
);
}
}
/*
* Start one Layer A #1 destination batch for an exact response
* checkpoint.
*
* Candidate step assessments are launched concurrently, but the batch
* resolves its decision as soon as the earliest framework candidate is
* logically known.
*
* Later candidate calls may continue quietly after that decision. They
* update only this batch object and never mutate authoritative coverage.
*/
function startFrameworkDestinationBatch({
config,
responseId,
version,
checkpointText,
frameworkSteps,
completionCriteriaByStep,
currentStepIndex,
coverageMap,
coverageContext,
acceptedEvidence,
heardEvidence,
thread,
insightQuestion,
insightSummary
}) {
const steps =
Array.isArray(frameworkSteps)
? frameworkSteps
: [];
const effectiveCoverage =
coverageMap &&
typeof coverageMap === ‘object’ &&
!Array.isArray(coverageMap)
? coverageMap
: {};
const effectiveContext =
coverageContext &&
typeof coverageContext === ‘object’ &&
!Array.isArray(coverageContext)
? coverageContext
: {};
const startIndex =
Number.isInteger(Number(currentStepIndex))
? Math.max(0, Number(currentStepIndex))
: 0;
const candidates = [];
for (
let index = startIndex;
index < steps.length;
index++
) {
const stepNumber = String(index + 1);
const rawStatus = String(
effectiveCoverage[stepNumber] ||
'not_addressed'
).trim();
const coverageStatus =
rawStatus === 'partially_addressed'
? 'partially_addressed'
: rawStatus === 'sufficiently_addressed'
? 'sufficiently_addressed'
: rawStatus === 'left_incomplete'
? 'left_incomplete'
: 'not_addressed';
if (
coverageStatus !== 'not_addressed' &&
coverageStatus !== 'partially_addressed'
) {
continue;
}
const stepContext =
coverageStatus === 'partially_addressed' &&
effectiveContext[stepNumber] &&
typeof effectiveContext[stepNumber] === 'object' &&
!Array.isArray(effectiveContext[stepNumber])
? effectiveContext[stepNumber]
: {};
candidates.push({
index,
step_number: stepNumber,
framework_step:
String(steps[index] || '').trim(),
objective:
String(
completionCriteriaByStep?.[index + 1] ||
steps[index] ||
''
).trim(),
coverage_status: coverageStatus,
step_context: {
...stepContext
}
});
}
let resolveDecision;
const decisionPromise =
new Promise(resolve => {
resolveDecision = resolve;
});
const batch = {
version,
checkpointText,
candidates,
results: {},
selected: null,
complete: false,
decisionResolved: false,
decision: null,
decisionPromise,
submitted: false
};
const settleDecision = decision => {
if (batch.decisionResolved) return;
batch.decisionResolved = true;
batch.decision = decision;
resolveDecision(decision);
};
const tryResolveInFrameworkOrder = () => {
if (batch.decisionResolved) return;
for (const candidate of candidates) {
const stepNumber =
candidate.step_number;
if (
!Object.prototype.hasOwnProperty.call(
batch.results,
stepNumber
)
) {
return;
}
const result =
batch.results[stepNumber];
/*
* An unusable earlier result blocks a safe decision about any
* later step. Send can then rerun the exact-current assessment
* rather than guessing.
*/
if (!result || result.usable !== true) {
settleDecision({
usable: false,
selected: null,
all_clear: false,
blocked_step_number: stepNumber
});
return;
}
if (
result.needs_further_coaching === true
) {
batch.selected = {
…candidate,
established:
String(result.established || ”).trim(),
remaining_gap:
String(result.remaining_gap || ”).trim()
};
settleDecision({
usable: true,
selected: batch.selected,
all_clear: false
});
return;
}
}
settleDecision({
usable: true,
selected: null,
all_clear: true
});
};
if (!candidates.length) {
batch.complete = true;
tryResolveInFrameworkOrder();
return batch;
}
const calls = candidates.map(candidate =>
Promise.resolve(
window.cpInsightLayerA.assessFrameworkStepNeed({
config,
responseId,
frameworkStep:
candidate.framework_step,
objective:
candidate.objective,
coverageStatus:
candidate.coverage_status,
stepContext:
candidate.step_context,
acceptedEvidence:
Array.isArray(acceptedEvidence)
? acceptedEvidence
: [],
heardEvidence:
heardEvidence &&
typeof heardEvidence === ‘object’ &&
!Array.isArray(heardEvidence)
? heardEvidence
: {},
thread:
Array.isArray(thread)
? thread
: [],
insightQuestion,
insightSummary,
userText: checkpointText
})
)
.then(result => {
batch.results[candidate.step_number] =
result && typeof result === ‘object’
? result
: {
usable: false,
needs_further_coaching: false,
established: ”,
remaining_gap: ”,
reason:
‘The framework-step assessment returned no usable result.’
};
tryResolveInFrameworkOrder();
})
.catch(err => {
console.log(
‘PROGRESSIVE DESTINATION CANDIDATE ERROR:’,
err
);
batch.results[candidate.step_number] = {
usable: false,
needs_further_coaching: false,
established: ”,
remaining_gap: ”,
reason:
‘The framework-step assessment failed.’
};
tryResolveInFrameworkOrder();
})
);
/*
* Trailing calls are deliberately allowed to finish quietly.
* allSettled prevents unhandled rejections.
*/
void Promise.allSettled(calls)
.then(() => {
batch.complete = true;
tryResolveInFrameworkOrder();
});
return batch;
}
/*
* New progressive destination path.
*
* This is deliberately separate from the previous progressive
* Evidence + whole-framework A2 function above. That existing function
* is left intact while this refactor is completed.
*/
async function runProgressiveDestinationAssessment() {
if (!responseSession.active) return;
if (
!window.cpInsightLayerA ||
typeof window.cpInsightLayerA.assessFrameworkStepNeed !==
‘function’
) {
return;
}
if (responseSession.assessmentInFlight) {
responseSession.assessmentQueued = true;
return;
}
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
const stepConfig = getStepConfig();
const frameworkSteps =
Array.isArray(stepConfig.steps)
? stepConfig.steps
: [];
const completionCriteriaByStep =
stepConfig.completionCriteriaByStep || {};
const responseText =
String(responseSession.currentText || ”).trim();
if (!responseText) return;
const assessedVersion =
responseSession.version;
responseSession.lastCheckpointText =
responseSession.currentText;
responseSession.assessmentInFlight = true;
responseSession.assessmentQueued = false;
try {
const insightQuestion = String(
v.coachFields?.insightQuestion ||
v.insight?.question ||
”
).trim();
const insightSummary = String(
v.coachFields?.insightSummary ||
v.insight?.summary ||
v.insight?.text ||
”
).trim();
const destinationBatch =
startFrameworkDestinationBatch({
config: v,
responseId: getOrCreateResponseId(),
version: assessedVersion,
checkpointText: responseText,
frameworkSteps,
completionCriteriaByStep,
currentStepIndex:
stepConfig.currentStep,
coverageMap:
stepCoverageMap,
coverageContext:
stepCoverageContext,
acceptedEvidence,
heardEvidence:
responseSession.heardEvidence,
thread,
insightQuestion,
insightSummary
});
/*
* Keep the exact batch object. Send may adopt this same promise while
* it is still in flight.
*/
responseSession.destinationBatch =
destinationBatch;
const destinationDecision =
await destinationBatch.decisionPromise;
/*
* Commit only if the user has not changed this response checkpoint.
*
* If Send took ownership while the decision was in flight, the
* submitted snapshot already holds destinationBatch by reference.
*/
if (
responseSession.active &&
assessedVersion === responseSession.version
) {
const selectedDestination =
destinationDecision?.usable === true &&
destinationDecision?.selected
? {
…destinationDecision.selected,
step_context: {
…(
destinationDecision.selected
.step_context || {}
)
}
}
: null;
responseSession.provisionalCoverage = {};
responseSession.provisionalStepContext = {};
responseSession.provisionalGate = null;
responseSession.provisionalLayerBEvidence = null;
responseSession.provisionalLayerBNovelty = null;
responseSession.provisionalStrategy = null;
responseSession.likelyNextStep =
selectedDestination;
responseSession.provisionalNextQuestion = ”;
responseSession.completedCheckpoint = {
checkpointText: responseText,
version: assessedVersion,
destinationBatch,
destinationDecision,
provisionalCoverage: {},
provisionalStepContext: {},
provisionalGate: null,
provisionalLayerBEvidence: null,
provisionalLayerBNovelty: null,
provisionalStrategy: null,
likelyNextStep:
selectedDestination,
provisionalNextQuestion: ”
};
responseSession.lastCompletedCheckpointText =
responseText;
}
console.log(
‘RESPONSE SESSION PROGRESSIVE DESTINATION:’,
{
assessedVersion,
currentVersion:
responseSession.version,
checkpointText:
responseText,
destinationDecision,
batchComplete:
destinationBatch.complete
}
);
} catch (err) {
console.log(
‘RESPONSE SESSION PROGRESSIVE DESTINATION ERROR:’,
err
);
} finally {
responseSession.assessmentInFlight = false;
if (
responseSession.active &&
responseSession.assessmentQueued
) {
responseSession.assessmentQueued = false;
if (responseSessionCheckpointEligible()) {
void runProgressiveDestinationAssessment();
return;
}
}
window.dispatchEvent(
new CustomEvent(
‘cp_ai_progressive_status_changed’
)
);
}
}
function cloneProgressiveLayerBEvidence(result) {
const evidence =
Array.isArray(result?.evidence)
? result.evidence
.filter(item =>
item &&
String(item.category || ”).trim() &&
String(item.statement || ”).trim() &&
String(item.quote || ”).trim()
)
.map(item => ({
category: String(item.category || ”).trim(),
statement: String(item.statement || ”).trim(),
quote: String(item.quote || ”).trim()
}))
: [];
return { evidence };
}
/*
* Progressive B1 runs independently of Layer A #1.
*
* It incrementally updates provisional evidence while the user composes.
* Nothing here becomes authoritative or is persisted merely because it was
* processed during typing.
*/
async function runProgressiveEvidenceAssessment(
checkpointText,
assessedVersion
) {
if (!responseSession.active) return;
if (
!window.cpInsightLayerB ||
typeof window.cpInsightLayerB.updateProgressiveEvidence !==
‘function’
) {
return;
}
const responseText =
String(checkpointText || ”).trim();
if (!responseText) return;
const liveText =
String(responseSession.currentText || ”).trim();
if (
liveText !== responseText &&
!liveText.startsWith(responseText)
) {
return;
}
if (responseSession.evidenceAssessmentInFlight) {
responseSession.evidenceAssessmentQueued = {
checkpointText: responseText,
version: assessedVersion
};
return;
}
responseSession.evidenceAssessmentInFlight = true;
responseSession.evidenceAssessmentQueued = null;
try {
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
const stepConfig = getStepConfig();
const frameworkSteps =
Array.isArray(stepConfig.steps)
? stepConfig.steps
: [];
const completionCriteriaByStep =
stepConfig.completionCriteriaByStep || {};
const previousCheckpointText =
String(
responseSession.provisionalLayerBEvidenceCheckpointText || ”
).trim();
const previousEvidence =
previousCheckpointText &&
responseText.startsWith(previousCheckpointText)
? cloneProgressiveLayerBEvidence(
responseSession.provisionalLayerBEvidence
)
: { evidence: [] };
let lastAssistantMessage = null;
for (let i = thread.length – 1; i >= 0; i–) {
if (thread[i]?.role === ‘assistant’) {
lastAssistantMessage = thread[i];
break;
}
}
const lastCoachQuestion =
lastAssistantMessage
? (
typeof lastAssistantMessage.content === ‘object’
? String(
lastAssistantMessage.content?.question || ”
).trim()
: String(
lastAssistantMessage.content || ”
).trim()
)
: ”;
const progressiveEvidence =
await window.cpInsightLayerB.updateProgressiveEvidence(
{
config: v,
responseId: getOrCreateResponseId(),
userText: responseText,
currentFrameworkStep:
String(
frameworkSteps[stepConfig.currentStep] || ”
).trim(),
currentStepObjective:
String(
completionCriteriaByStep[
stepConfig.currentStep + 1
] ||
frameworkSteps[stepConfig.currentStep] ||
”
).trim(),
lastCoachQuestion
},
previousEvidence,
previousCheckpointText
);
const currentLiveText =
String(responseSession.currentText || ”).trim();
/*
* Appending later material does not invalidate an earlier checkpoint.
* Editing or replacing material inside that checkpoint does.
*/
if (
responseSession.active &&
(
currentLiveText === responseText ||
currentLiveText.startsWith(responseText)
)
) {
responseSession.provisionalLayerBEvidence =
cloneProgressiveLayerBEvidence(progressiveEvidence);
responseSession.provisionalLayerBEvidenceCheckpointText =
responseText;
/*
* A completed sentence becomes Send-handover evidence only after the
* user has begun later material. That guarantees the handover evidence
* is from before the final sentence, even if final-sentence B1 happens
* to finish before Send is clicked.
*/
if (
/[.!?]$/.test(responseText) &&
currentLiveText.length > responseText.length &&
currentLiveText.startsWith(responseText)
) {
responseSession.handoverLayerBEvidence =
cloneProgressiveLayerBEvidence(
responseSession.provisionalLayerBEvidence
);
responseSession.handoverLayerBEvidenceCheckpointText =
responseText;
}
console.log(
‘RESPONSE SESSION PROGRESSIVE B1:’,
{
assessedVersion,
checkpointText: responseText,
evidenceCount:
responseSession.provisionalLayerBEvidence
.evidence.length
}
);
}
} catch (err) {
console.log(
‘RESPONSE SESSION PROGRESSIVE B1 ERROR:’,
err
);
} finally {
responseSession.evidenceAssessmentInFlight = false;
const queued =
responseSession.evidenceAssessmentQueued;
responseSession.evidenceAssessmentQueued = null;
if (
responseSession.active &&
queued?.checkpointText
) {
void runProgressiveEvidenceAssessment(
queued.checkpointText,
queued.version
);
}
}
}
function findFirstForwardStep(
currentStepIndex,
steps,
completionCriteriaByStep,
coverageMap = stepCoverageMap,
coverageContext = stepCoverageContext
) {
const frameworkSteps =
Array.isArray(steps) ? steps : [];
const effectiveCoverageMap =
coverageMap &&
typeof coverageMap === ‘object’ &&
!Array.isArray(coverageMap)
? coverageMap
: {};
const effectiveCoverageContext =
coverageContext &&
typeof coverageContext === ‘object’ &&
!Array.isArray(coverageContext)
? coverageContext
: {};
for (
let index = currentStepIndex + 1;
index < frameworkSteps.length;
index++
) {
const stepNumber = String(index + 1);
const status = String(
effectiveCoverageMap[stepNumber] ||
'not_addressed'
).trim();
if (
status !== 'sufficiently_addressed' &&
status !== 'left_incomplete'
) {
const semanticContext =
status === 'partially_addressed' &&
effectiveCoverageContext[stepNumber] &&
typeof effectiveCoverageContext[stepNumber] === 'object'
? effectiveCoverageContext[stepNumber]
: {};
return {
index,
step: String(
frameworkSteps[index] || ''
).trim(),
objective: String(
completionCriteriaByStep?.[index + 1] ||
frameworkSteps[index] ||
''
).trim(),
coverage_status:
status === 'partially_addressed'
? 'partially_addressed'
: 'not_addressed',
established:
status === 'partially_addressed'
? String(
semanticContext.established || ''
).trim()
: '',
remaining_gap:
status === 'partially_addressed'
? String(
semanticContext.remaining_gap || ''
).trim()
: ''
};
}
}
return null;
}
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,
novelty_status: noveltyByIndex.get(index)
});
});
}
// 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;
}
let endReportShown = false;
let aiTurnInProgress = false;
let stepResetAppliedThisPage = false;
/*
* True only while we are waiting for the user’s final reflection
* after Layer D has shown the Insight Review.
*
* This response is not another framework turn and must not be sent
* back through Layers A/B/C.
*/
let finalInsightReflectionPending = false;
/*
* True only while we are waiting for the user’s one response to the
* combined parked-material invitation shown after framework completion.
*
* This response belongs to the completed Insight and must be available
* to Layer D, but it must NOT reopen framework progression or be sent
* back through Layers A/B/C.
*/
let parkedMaterialResponsePending = false;
function pushMsg(role, content) {
const isAssistantObject =
role === ‘assistant’ &&
content &&
typeof content === ‘object’;
/*
* Once the Coach’s next genuine question is available, let the user
* start composing their response immediately.
*
* The current turn may still be completing background processing and
* persistence, so Send remains locked until runLayeredInsightTurn()
* has completely returned.
*/
const isNextCoachQuestion =
isAssistantObject &&
String(content.question || ”).trim() !== ” &&
!String(content.control || ”).trim();
if (isNextCoachQuestion && aiTurnInProgress) {
const inputWrap = qs(‘.cp-input’);
const sendBtnEl = qs(‘#cp-ai-send’);
if (inputWrap) {
inputWrap.style.display = ”;
}
if (sendBtnEl) {
sendBtnEl.disabled = true;
sendBtnEl.textContent = ‘■’;
}
}
const safeContent = isAssistantObject
? {
reflection: String(content.reflection || ”).trim(),
general_information: String(
content.general_information || ”
).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 });
renderThread();
if (
isNextCoachQuestion &&
window.cpAiService &&
typeof window.cpAiService.markTimingEvent === ‘function’
) {
window.cpAiService.markTimingEvent(
‘coach_question_displayed’,
{
response_id: getOrCreateResponseId()
}
);
}
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 while I prepare my next message for you…’;
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);
}
// General information block.
//
// This is intentionally separate from the AI Coach bubble.
// It contains brief factual/explanatory information requested by
// the user in connection with the current Insight.
if (c.general_information) {
const info = document.createElement(‘div’);
info.className = ‘cp-general-information-block’;
const heading = document.createElement(‘div’);
heading.className =
‘cp-msg-head cp-general-information-heading’;
heading.textContent = ‘General information’;
const body = document.createElement(‘div’);
body.className = ‘cp-general-information-text’;
body.textContent = c.general_information;
info.appendChild(heading);
info.appendChild(body);
box.appendChild(info);
}
// coach bubble (question only)
//
// A reflection or General information response may exist without a
// coaching question, so do not create an empty Coach bubble.
if (c.question) {
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’;
/*
* TEMPORARY DEBUG ALERT:
* Make the Decision Trace heading flicker whenever Layer A records
* at least one partially_addressed framework step.
*
* UI/debug only. Remove once natural partial cases have been verified.
*/
const hasPartialStep =
/\bStep\s+\d+\s+partially_addressed\b/i.test(
String(c.debug_trace || ”)
);
if (hasPartialStep) {
summary.style.animation =
‘cp-partial-step-alert 0.7s step-end infinite’;
}
const pre = document.createElement(‘pre’);
pre.textContent = c.debug_trace;
details.appendChild(summary);
details.appendChild(pre);
box.appendChild(details);
}
}
box.scrollTop = box.scrollHeight;
}
/*
* Temporary AI Coach thinking indicator.
*
* This is UI-only:
* – it is NOT added to the thread;
* – it is NOT added to conversationHistory;
* – it is NOT saved to the database.
*/
function showCoachThinkingIndicator() {
const box = qs(‘#cp-ai-thread’);
if (!box) return;
removeCoachThinkingIndicator();
const bubble = document.createElement(‘div’);
bubble.id = ‘cp-ai-thinking-indicator’;
bubble.className = ‘cp-bubble cp-assistant cp-ai-thinking’;
const head = document.createElement(‘div’);
head.className = ‘cp-msg-head cp-ai-head’;
head.innerHTML =
‘⚡ AI Coach’;
const thinkingIntroKey = ‘cp_ai_thinking_intro_shown’;
const showThinkingIntro = !sessionStorage.getItem(thinkingIntroKey);
bubble.appendChild(head);
if (showThinkingIntro) {
const body = document.createElement(‘div’);
body.className = ‘cp-msg-text’;
body.textContent = ‘🤔 Considering your response…’;
bubble.appendChild(body);
sessionStorage.setItem(thinkingIntroKey, ‘1’);
}
box.appendChild(bubble);
box.scrollTop = box.scrollHeight;
}
function removeCoachThinkingIndicator() {
const indicator = qs(‘#cp-ai-thinking-indicator’);
if (indicator) {
indicator.remove();
}
}
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;
}
// 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 normal journey parameters.
// reset=1 is a deliberate test/reset instruction for the current Insight
// and must never propagate into the next Insight.
const tone = String(page.tone || getParam(‘tone’,”) || ”);
const sid = String(getParam(‘sid’,”) || ”);
if (tone) sp.set(‘tone’, tone);
if (sid) sp.set(‘sid’, sid);
// The query string was copied from the current URL above, so explicitly
// remove any reset instruction before navigating to the next Insight.
sp.delete(‘reset’);
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 {}
}
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 saveNow({
status: ‘Ended’,
summary
});
await runEndOfChapterPersonalCoach();
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
});
}
/*
* Layer A Persistent Coach Context
*
* Layer A decides whether something from the user’s response is useful
* beyond the current Insight.
*
* The browser does not merge or deduplicate persistent memory.
* It sends only the note selected by Layer A; the server owns storage.
*/
async function savePersistentCoachContext(layerA) {
const additionalContext =
layerA?.additional_context &&
typeof layerA.additional_context === ‘object’
? layerA.additional_context
: null;
if (!additionalContext) return;
if (additionalContext.persistent !== true) return;
const persistentNote =
String(additionalContext.persistent_note || ”).trim();
if (!persistentNote) return;
await postSaveMemory({
…cpBaseMeta(),
persistent_coach_context: persistentNote
});
}
// —- 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 stepCoverageState = {
coverage: { …stepCoverageMap },
context: {}
};
Object.entries(stepCoverageContext).forEach(
([stepNumber, semanticContext]) => {
const coverageStatus =
stepCoverageMap[String(stepNumber)];
if (
(
coverageStatus !== ‘partially_addressed’ &&
coverageStatus !== ‘left_incomplete’
) ||
!semanticContext ||
typeof semanticContext !== ‘object’
) {
return;
}
const established =
String(semanticContext.established || ”).trim();
const remainingGap =
String(semanticContext.remaining_gap || ”).trim();
if (!established && !remainingGap) return;
stepCoverageState.context[String(stepNumber)] = {
established,
remaining_gap: remainingGap
};
}
);
const currentAiUsage =
window.cpAiService &&
typeof window.cpAiService.getUsage === ‘function’
? window.cpAiService.getUsage()
: null;
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’,
step_coverage_map: JSON.stringify(stepCoverageState),
ai_usage: currentAiUsage,
accepted_evidence: acceptedEvidence.map(item => ({
category: String(item?.category || ”).trim(),
statement: String(item?.statement || ”).trim(),
quote: String(item?.quote || ”).trim(),
novelty_status:
String(item?.novelty_status || ”).trim() ===
‘materially_developed’
? ‘materially_developed’
: ‘new’
})),
}, 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];
});
return postSaveMemory(body);
}
/*
* END-OF-CHAPTER PERSONAL COACH
*
* B3 trait assessment and B4 Future Chapter assessment run once, after the
* final Insight in a Chapter has ended. They use accumulated accepted evidence
* from earlier completed Insights in this Chapter plus the current Insight.
*
* The existing Layer B B3/B4 intelligence remains authoritative; this helper
* only prepares Chapter-scoped evidence and persists approved recommendations.
*/
let endOfChapterPersonalCoachPromise = null;
function collectChapterAcceptedEvidence() {
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
const priorEvidence =
Array.isArray(v.coachMemory?.chapterPriorEvidence)
? v.coachMemory.chapterPriorEvidence
: [];
const combined = [
…priorEvidence,
…acceptedEvidence
];
const seen = new Set();
return combined.reduce((items, item) => {
if (!item || typeof item !== ‘object’) return items;
const category =
String(item.category || ”).trim();
const statement =
String(item.statement || ”).trim();
const quote =
String(item.quote || ”).trim();
if (!category || !statement || !quote) {
return items;
}
const duplicateKey = [
category.toLowerCase(),
statement.toLowerCase(),
quote.toLowerCase()
].join(‘|’);
if (seen.has(duplicateKey)) return items;
seen.add(duplicateKey);
items.push({
category,
statement,
quote,
novelty_status:
String(item.novelty_status || ”).trim() ===
‘materially_developed’
? ‘materially_developed’
: ‘new’
});
return items;
}, []);
}
async function runEndOfChapterPersonalCoach() {
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
const hasPersonalCoach =
String(v.aiCoachLevel || ”)
.trim()
.toLowerCase() === ‘personal’;
const currentChapterKey =
String(v.insight?.section_chapter || ”).trim();
if (
!hasPersonalCoach ||
String(v.insight?.record_type || ”)
.trim()
.toLowerCase() === ‘feedback’ ||
v.insight?.is_chapter_end !== true ||
!currentChapterKey
) {
return;
}
if (
!window.cpInsightLayerB ||
typeof window.cpInsightLayerB
.assessChapterPersonalCoach !== ‘function’
) {
return;
}
const completedChapterKey =
String(
v.coachMemory?.chapterPersonalCoachCompleted || ”
).trim();
if (completedChapterKey === currentChapterKey) {
return;
}
if (endOfChapterPersonalCoachPromise) {
return endOfChapterPersonalCoachPromise;
}
async function markChapterPersonalCoachComplete() {
const response =
await saveNow({
status: ‘Ended’,
chapter_personal_coach_completed:
currentChapterKey
});
if (!response || !response.ok) {
throw new Error(
‘End-of-Chapter completion marker storage failed.’
);
}
v.coachMemory =
v.coachMemory &&
typeof v.coachMemory === ‘object’
? v.coachMemory
: {};
v.coachMemory.chapterPersonalCoachCompleted =
currentChapterKey;
}
endOfChapterPersonalCoachPromise =
(async () => {
try {
const chapterEvidence =
collectChapterAcceptedEvidence();
if (!chapterEvidence.length) {
await markChapterPersonalCoachComplete();
return;
}
const responseId = getOrCreateResponseId();
const result =
await window.cpInsightLayerB
.assessChapterPersonalCoach(
{
config: v,
responseId,
currentTraitStatus:
v.coachMemory?.currentTraitStatus || {},
futureChapterMap:
v.coachMemory?.futureChapterMap || {},
currentChapterKey
},
chapterEvidence
);
const traitUpdates =
Array.isArray(result?.traits?.trait_updates)
? result.traits.trait_updates
: [];
for (const item of traitUpdates) {
if (item?.store_recommendation !== true) continue;
const quality =
item.direction === ‘increase’
? ‘Positive’
: item.direction === ‘decrease’
? ‘Negative’
: ‘Caution’;
/*
* B3 deliberately retains its existing low/medium/high confidence
* judgement. Convert that storage representation deterministically
* to the numeric Form 329 confidence scale.
*/
const confidenceByLevel = {
low: 4,
medium: 7,
high: 9
};
const confidence =
confidenceByLevel[
String(item.confidence || ”)
.trim()
.toLowerCase()
] || 4;
const response = await postSaveMemory({
…cpBaseMeta(),
append_only: true,
response_id: responseId,
response_sequence:
thread.filter(m => m.role === ‘user’).length,
framework: ‘Change Pathway’,
trait: String(item.trait || ”).trim(),
direction: String(item.direction || ”).trim(),
quality,
confidence,
rationale: String(item.evidence || ”).trim(),
quote: String(item.quote || ”).trim()
});
if (!response || !response.ok) {
throw new Error(
‘End-of-Chapter trait storage failed.’
);
}
}
const futureChapterItems =
Array.isArray(
result?.futureChapters?.future_chapter_items
)
? result.futureChapters.future_chapter_items
: [];
/*
* Save Chapter recommendations sequentially and omit
* response_sequence. The server assigns the next available sequence
* for each Form 338 row, avoiding its triplet de-duplication guard
* when B4 legitimately returns more than one item.
*/
for (const item of futureChapterItems) {
if (item?.store_recommendation !== true) continue;
const chapterKey =
String(item.chapter_key || ”).trim();
if (!chapterKey || chapterKey === currentChapterKey) {
continue;
}
const response = await postSaveMemory({
…cpBaseMeta(),
append_only: true,
response_id: responseId,
chapter_key: chapterKey,
matched_terms: [],
quality: ‘Neutral’,
rationale:
String(item.reason || item.evidence || ”).trim(),
chapter_quote: String(item.quote || ”).trim(),
chapter_evidence:
String(item.evidence || ”).trim()
});
if (!response || !response.ok) {
throw new Error(
‘End-of-Chapter Future Chapter storage failed.’
);
}
}
await markChapterPersonalCoachComplete();
} catch (err) {
console.log(
‘END OF CHAPTER PERSONAL COACH ERROR:’,
err
);
} finally {
endOfChapterPersonalCoachPromise = null;
}
})();
return endOfChapterPersonalCoachPromise;
}
// —- Summary generator (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 payload = {
model: v.aiModel,
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 aiResult = await window.cpAiService.callText({
task: ‘insight_summary’,
responseId: getOrCreateResponseId(),
config: v,
payload
});
if (!aiResult || !aiResult.ok) return ”;
return String(aiResult.data || ”).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 payload = {
model: v.aiModel,
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 aiResult = await window.cpAiService.callText({
task: ‘insight_end_insight’,
responseId: getOrCreateResponseId(),
config: v,
payload
});
if (!aiResult || !aiResult.ok) return ”;
const raw = String(aiResult.data || ”).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 ”;
}
}
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);
})();
// Wire input
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’);
/*
* NUDGE FEATURE DISABLED
*
* Keep these harmless shims because existing resume, continuation and
* end-of-Insight flows still call clearNudge() / scheduleNudge().
*
* No timer is created, no nudge UI is shown and no nudge AI call can
* be reached through the normal interface.
*/
const nudgeBtn = null;
const nudgeBox = null;
function clearNudge() {}
function scheduleNudge() {}
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;
window.addEventListener(
‘cp_ai_progressive_status_changed’,
() => {
renderCTA();
}
);
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.
// Ellipsis appears only while progressive assessment is actively running.
ctaText.textContent =
responseSession.assessmentInFlight
? ‘Listening…’
: ‘Listening’;
sendBtn.disabled = false;
nextBtn.disabled = true;
clearNudge();
}
}
ta.addEventListener(‘input’, () => {
// Keep the existing draft/UI behaviour unchanged.
const currentText = String(ta.value || ”);
coachState.draft = currentText;
/*
* Response Session
*
* The first user input starts a temporary response session.
* From this point onwards, version identifies successive states of
* the developing response.
*
* IMPORTANT:
* currentText is only the latest textarea contents. Future progressive
* assessments will add meaning to heardEvidence separately, so deleting
* text here will not itself erase anything the Coach has already heard.
*/
if (!responseSession.active && currentText.length > 0) {
responseSession.active = true;
/*
* Start each user response with fresh progressive working state.
* Authoritative framework state lives separately.
*/
responseSession.heardEvidence = {};
responseSession.provisionalCoverage = {};
responseSession.provisionalStepContext = {};
responseSession.likelyNextStep = null;
responseSession.provisionalNextQuestion = ”;
responseSession.provisionalGate = null;
responseSession.provisionalLayerBEvidence = null;
responseSession.provisionalLayerBEvidenceCheckpointText = ”;
responseSession.handoverLayerBEvidence = null;
responseSession.handoverLayerBEvidenceCheckpointText = ”;
responseSession.provisionalLayerBNovelty = null;
responseSession.provisionalStrategy = null;
responseSession.evidenceAssessmentQueued = null;
responseSession.completedCheckpoint = null;
responseSession.destinationBatch = null;
responseSession.assessmentQueued = false;
responseSession.lastCheckpointText = ”;
responseSession.lastCompletedCheckpointText = ”;
}
if (
responseSession.active &&
currentText !== responseSession.currentText
) {
const trimmedCurrentText =
String(currentText || ”).trim();
const provisionalEvidenceCheckpointText =
String(
responseSession.provisionalLayerBEvidenceCheckpointText || ”
).trim();
/*
* Once the user starts a later sentence, the evidence from the preceding
* completed sentence becomes the fixed Send-handover evidence.
*/
if (
provisionalEvidenceCheckpointText &&
/[.!?]$/.test(provisionalEvidenceCheckpointText) &&
trimmedCurrentText.length >
provisionalEvidenceCheckpointText.length &&
trimmedCurrentText.startsWith(
provisionalEvidenceCheckpointText
)
) {
responseSession.handoverLayerBEvidence =
cloneProgressiveLayerBEvidence(
responseSession.provisionalLayerBEvidence
);
responseSession.handoverLayerBEvidenceCheckpointText =
provisionalEvidenceCheckpointText;
}
/*
* If earlier text is edited, evidence derived from that old prefix can no
* longer be handed to Send. Clear it rather than guessing.
*/
const handoverCheckpointText =
String(
responseSession.handoverLayerBEvidenceCheckpointText || ”
).trim();
if (
handoverCheckpointText &&
!trimmedCurrentText.startsWith(handoverCheckpointText)
) {
responseSession.handoverLayerBEvidence = null;
responseSession.handoverLayerBEvidenceCheckpointText = ”;
}
responseSession.version += 1;
responseSession.currentText = currentText;
}
/*
* Check whether the developing response has reached a useful
* progressive-assessment checkpoint.
*
* Assessment is silent and updates Response Session working memory
* only. It does not affect authoritative coverage or persistence.
*/
if (responseSessionCheckpointEligible()) {
const checkpointText =
String(responseSession.currentText || ”).trim();
const checkpointVersion =
responseSession.version;
/*
* Destination and evidence processing are deliberately independent.
* A slower progressive B1 call must never delay Layer A #1.
*/
void runProgressiveDestinationAssessment();
void runProgressiveEvidenceAssessment(
checkpointText,
checkpointVersion
);
}
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 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: v.aiModel,
messages: [
{ role: ‘system’, content: system },
{ role: ‘user’, content: user }
]
};
const aiResult = await window.cpAiService.callText({
task: ‘insight_next_coverage_check’,
responseId: getOrCreateResponseId(),
config: v,
payload
});
if (!aiResult || !aiResult.ok) {
return { covered: true, message: ” };
}
const raw = String(aiResult.data || ”);
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.
async function showExampleNudges() {
if (!nudgeBox) return;
nudgeOpen = true;
if (nudgeTimer) {
clearTimeout(nudgeTimer);
nudgeTimer = null;
}
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
const lastAssistant = getLastAssistantMessage();
const currentCoachQuestion =
lastAssistant?.role === ‘assistant’
? (
typeof lastAssistant.content === ‘object’
? String(lastAssistant.content?.question || ”).trim()
: String(lastAssistant.content || ”).trim()
)
: ”;
let examples = [];
/*
* Generate nudges from the question the user is actually being asked,
* rather than using fixed examples tied to Step 1.
*/
if (
currentCoachQuestion &&
window.cpAiService &&
typeof window.cpAiService.callJson === ‘function’ &&
v.aiUrl
) {
try {
const aiResult = await window.cpAiService.callJson({
task: ‘insight_nudge_writer’,
responseId: getOrCreateResponseId(),
config: v,
payload: {
model: v.aiModel,
messages: [
{
role: ‘system’,
content:
`You are generating three short example responses to help a user answer the AI Coach’s CURRENT question.
Return ONLY valid JSON in this exact form:
{“examples”:[“…”,”…”,”…”]}
Rules:
– Each example must directly answer the CURRENT COACH QUESTION.
– Use the recent conversation so the examples fit what has actually been discussed.
– Do not return to an earlier framework step unless the current Coach question does so.
– Do not introduce facts, experiences, feelings or conclusions the user has not established.
– The examples are prompts the user may choose or adapt, not statements about what is true for them.
– Keep each example natural and conversational.
– Keep each example to one short sentence.
– Make the three examples meaningfully different.
– Use UK English.`
},
{
role: ‘user’,
content:
`CURRENT COACH QUESTION:
${currentCoachQuestion}
RECENT CONVERSATION:
${buildTranscript(2200)}`
}
]
}
});
if (
aiResult?.ok &&
Array.isArray(aiResult.data?.examples)
) {
examples = aiResult.data.examples
.map(example => String(example || ”).trim())
.filter(Boolean)
.slice(0, 3);
}
} catch (err) {
console.log(‘NUDGE WRITER ERROR:’, err);
}
}
/*
* Safe generic fallback if the nudge writer is unavailable.
* These do not assume any particular framework step.
*/
if (examples.length !== 3) {
examples = [
“One thing that comes to mind is…”,
“I’m not completely sure, but I think…”,
“I suppose part of it might be…”
];
}
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);
}
// 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.textContent = ‘Send’;
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: v.aiModel,
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 forwardTarget =
findFirstForwardStep(
currentStepIndex,
steps,
stepCfg.completionCriteriaByStep || {}
);
const hasNextStep =
!!forwardTarget;
let targetStepIndex = currentStepIndex;
if (hasNextStep) {
targetStepIndex =
forwardTarget.index;
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: v.aiModel,
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: v.aiModel,
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,
submittedResponseSession = null
) {
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
/*
* Determine whether the coherent completed progressive checkpoint
* represents the complete response that the user actually submitted.
*
* Compare the pre-spellcheck submitted text with checkpointText from the
* completed checkpoint package. Spellcheck happens only after Send, so
* any later spelling correction must not make otherwise-current
* progressive work stale.
*
* This establishes checkpoint completeness only. It does not make any
* provisional judgement authoritative.
*/
const submittedResponseText =
String(
submittedResponseSession?.currentText || ”
).trim();
const progressiveCompletedCheckpointText =
String(
submittedResponseSession
?.completedCheckpoint
?.checkpointText || ”
).trim();
const progressiveResponseIsCurrent =
Boolean(
submittedResponseSession?.active &&
submittedResponseText &&
progressiveCompletedCheckpointText &&
submittedResponseText === progressiveCompletedCheckpointText
);
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
});
let result = null;
/*
* PARKED-MATERIAL RESPONSE
*
* C5 has already returned to every point the Coach explicitly promised
* to revisit. The user’s response now belongs to the completed Insight,
* but it must not reopen framework progression.
*
* The response is already present in the live thread, so Layer D can
* review the complete conversation directly.
*/
if (parkedMaterialResponsePending) {
parkedMaterialResponsePending = false;
/*
* Every explicit parking commitment has now been fulfilled.
* Clear the in-memory list so it cannot trigger another C5 return.
*/
parkedMaterial.length = 0;
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 || [],
/*
* The parked response is not a new framework turn, so no new
* Layer B trait assessment is run here.
*/
traitUpdates: []
});
await saveNow({
status: ‘Ended’,
summary: String(layerD?.review || ”).trim(),
layer_d_result: JSON.stringify(layerD),
coach_decision: JSON.stringify({
route: ‘layer_d’,
next_model: ‘review’,
control: ‘parked_material_response_complete’,
review_type: layerD?.review_type || ”
})
});
pushMsg(‘assistant’, {
reflection: ”,
question: layerD?.review || ”,
debug_trace: ”,
control: ”
});
result = {
stopped_at: ‘D_REVIEW’,
layerD
};
} else {
/*
* 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 || {}
});
result =
await window.cpInsightOrchestrator.handleUserTurnLayered({
config: v,
responseId,
userText,
thread,
/*
* Hand the completed progressive work to the authoritative orchestrator.
*
* progressiveResponseIsCurrent says only whether that work represents
* the complete pre-spellcheck response. The orchestrator remains
* responsible for deciding what can safely become authoritative.
*/
progressiveResponseIsCurrent,
submittedResponseSession,
/*
* Give the orchestrator access to the same Layer A #1 batch launcher
* used by typing-time background processing.
*
* This avoids creating a second implementation of destination selection.
*/
createFrameworkDestinationBatch(args) {
return startFrameworkDestinationBatch(args);
},
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(),
frameworkSteps: steps,
currentStepIndex,
completionCriteriaByStep:
stepCfg.completionCriteriaByStep || {},
stepCoverageMap,
stepCoverageContext,
parkedMaterial,
resolveNextFrameworkStep() {
return findFirstForwardStep(
currentStepIndex,
steps,
stepCfg.completionCriteriaByStep || {}
);
},
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 || [],
persistentCoachContext:
String(
v.coachMemory?.persistentCoachContext || ”
).trim(),
pushAssistantMessage(message) {
pushMsg(‘assistant’, message);
},
async saveNow(payload) {
await saveNow(payload);
}
});
}
/*
* Layer A may identify context that should survive beyond this Insight.
*
* Persist it before continuing with the current-Insight evidence
* handling. The PHP endpoint owns lookup, deduplication and merging
* into Form 313 [7236].
*/
console.log(
‘PERSISTENT CONTEXT DEBUG:’,
result?.gate?.additional_context
);
try {
await savePersistentCoachContext(result?.gate);
} catch (err) {
console.log(
‘PERSISTENT COACH CONTEXT SAVE ERROR:’,
err
);
}
/*
* 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’ &&
Number.isInteger(result?.nextStepIndex)
) {
sessionStorage.removeItem(followUpKey);
sessionStorage.setItem(
stepCfg.stepKey,
String(result.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’;
}
/*
* Continue is meaningful only when a forward framework step
* remains that has not already been sufficiently addressed.
*/
const forwardTarget =
findFirstForwardStep(
currentStepIndex,
steps,
stepCfg.completionCriteriaByStep || {}
);
const canContinue =
!!forwardTarget;
const choiceBox = qs(‘#cp-end-report’);
if (choiceBox) {
choiceBox.innerHTML = `
canContinue
? `
`
: ”
}
`;
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 advances to the first forward
* framework step that has not already been sufficiently addressed.
*/
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;
}
if (result?.stopped_at === ‘RELATIONSHIP_TERMINATED’) {
endReportShown = true;
const inputWrap = qs(‘.cp-input’);
if (inputWrap) inputWrap.style.display = ‘none’;
if (nextBtn) {
nextBtn.disabled = true;
nextBtn.style.display = ‘none’;
}
clearNudge();
return result;
}
/*
* PARKED MATERIAL RETURN
*
* The framework is complete, but the Coach has explicitly promised
* to return to one or more significant points before the Insight review.
*
* C5 has now issued the single combined invitation. The next user
* response must go directly to Layer D rather than back through
* framework progression.
*/
if (
result?.stopped_at ===
‘C5_PARKED_MATERIAL_RETURN’
) {
parkedMaterialResponsePending = true;
endReportShown = false;
const inputWrap = qs(‘.cp-input’);
if (inputWrap) {
inputWrap.style.display = ”;
}
if (nextBtn) {
nextBtn.disabled = true;
nextBtn.style.display = ‘none’;
}
if (sendBtn) {
sendBtn.style.display = ”;
sendBtn.disabled = true;
}
if (ctaText) {
ctaText.textContent = ‘Awaiting your response…’;
}
clearNudge();
scheduleNudge();
return result;
}
/*
* NORMAL END-OF-INSIGHT REVIEW
*
* Layer D has completed its review, but before closing the Insight
* give the user one final opportunity to say where the Insight has
* left them and whether their thinking has shifted.
*
* This is deliberately outside the framework step architecture.
*/
if (result?.stopped_at === ‘D_REVIEW’) {
finalInsightReflectionPending = true;
endReportShown = false;
const inputWrap = qs(‘.cp-input’);
const endReportBox = qs(‘#cp-end-report’);
if (endReportBox) {
endReportBox.innerHTML = ”;
endReportBox.style.display = ‘none’;
}
if (nextBtn) {
nextBtn.disabled = true;
nextBtn.style.display = ‘none’;
}
const v =
window.cpVars && window.cpVars.insightPage
? window.cpVars.insightPage
: {};
const insightFocus = String(
v.coachFields?.insightFocus || ”
).trim();
const finalInsightFocus =
insightFocus || ‘this Insight’;
pushMsg(‘assistant’, {
reflection: ”,
question:
`Having read that reflection, what are your final thoughts on ${finalInsightFocus}? ` +
`Has your thinking about it shifted or developed in any way through our conversation? ` +
`And if there’s anything you’d change or add to my reflection, please do. ` +
`Take as much space as you like.`,
debug_trace: ”,
control: ‘final_insight_reflection’
});
if (inputWrap) {
inputWrap.style.display = ”;
}
if (sendBtn) {
sendBtn.style.display = ”;
sendBtn.disabled = true;
}
if (ctaText) {
ctaText.textContent = ‘Awaiting your response…’;
}
clearNudge();
scheduleNudge();
/*
* pushMsg() redraws the thread and normally leaves the newest
* message at the bottom.
*
* Here the user needs to start with the Layer D review, not the
* final-reflection question underneath it.
*
* scrollIntoView() handles both the conversation’s own scroll
* container and the surrounding browser page.
*/
requestAnimationFrame(() => {
const threadBox = qs(‘#cp-ai-thread’);
if (!threadBox) return;
const coachBubbles =
threadBox.querySelectorAll(‘.cp-bubble.cp-assistant’);
if (coachBubbles.length >= 2) {
const reviewBubble =
coachBubbles[coachBubbles.length – 2];
const y =
reviewBubble.getBoundingClientRect().top +
window.scrollY –
140;
window.scrollTo({
top: Math.max(0, y),
behavior: ‘auto’
});
}
});
return result;
}
/*
* RELATIONSHIP RESISTANCE END STATE
*
* This remains an immediate close. Do not ask for a final Insight
* reflection when the user has explicitly rejected the conversation.
*/
if (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’;
}
const endReportBox = qs(‘#cp-end-report’);
const userExplicitlyStopped = true;
const resumeTarget =
findFirstForwardStep(
currentStepIndex,
steps,
stepCfg.completionCriteriaByStep || {}
);
const canResume = false;
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 =
resumeTarget.index;
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();
});
}
}
}
/*
* Persist the completed turn’s framework coverage.
* The earlier save creates the response record before the AI calls;
* this save records the coverage resulting from those calls.
*/
await saveNow({
status: ‘Live’,
response_id: responseId,
user_input: userText
});
return result;
}
async function generateFinalInsightAcknowledgement(userText) {
const v =
(window.cpVars && window.cpVars.insightPage)
? window.cpVars.insightPage
: {};
if (!v.aiUrl) {
return ‘Thank you for reflecting on this Insight.’;
}
const insightTopic = String(
v.coachFields?.insightFocus || ”
).trim();
const payload = {
model: v.aiModel,
messages: [{
role: ‘user’,
content:
`Write the AI Coach’s brief closing acknowledgement after the user has given their final reflection on an Insight.\n\n` +
`Rules:\n` +
`- Acknowledge what the user actually said.\n` +
`- If their response contains a meaningful concluding thought, briefly recognise it.\n` +
`- If their response is minimal or contains no meaningful new thought, simply thank them for reflecting.\n` +
`- Do not ask a question.\n` +
`- Do not continue coaching.\n` +
`- Do not give advice.\n` +
`- Do not introduce a new interpretation or insight.\n` +
`- Do not overstate what the user has concluded.\n` +
`- Do not repeat their response back to them.\n` +
`- Use UK English.\n` +
`- Maximum 2 short sentences.\n` +
`- This is a natural closing acknowledgement immediately before the user moves to the next Insight.\n\n` +
`INSIGHT TOPIC:\n${insightTopic}\n\n` +
`USER’S FINAL REFLECTION:\n${String(userText || ”).trim()}`
}]
};
try {
const aiResult = await window.cpAiService.callText({
task: ‘insight_final_acknowledgement’,
responseId: getOrCreateResponseId(),
config: v,
payload
});
if (!aiResult || !aiResult.ok) {
return ‘Thank you for reflecting on this Insight.’;
}
const acknowledgement =
String(aiResult.data || ”).trim();
return acknowledgement ||
‘Thank you for reflecting on this Insight.’;
} catch (err) {
console.log(
‘FINAL INSIGHT ACKNOWLEDGEMENT ERROR:’,
err
);
return ‘Thank you for reflecting on this Insight.’;
}
}
// 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;
if (
window.cpAiService &&
typeof window.cpAiService.markTimingEvent === ‘function’
) {
window.cpAiService.markTimingEvent(
‘send_pressed’,
{
response_id: getOrCreateResponseId(),
response_session_version:
responseSession.version,
progressive_in_flight:
responseSession.assessmentInFlight,
progressive_queued:
responseSession.assessmentQueued,
completed_checkpoint_current:
Boolean(
responseSession.completedCheckpoint &&
String(
responseSession.completedCheckpoint
.checkpointText || ”
).trim() === text
)
}
);
}
/*
* Freeze the provisional Response Session at the moment Send is
* pressed.
*
* This is a snapshot only. Nothing here becomes authoritative merely
* because it was prepared while the user was typing.
*
* The completed-response path can now reconcile the submitted text
* against the exact progressive work that existed for this response,
* without later Response Session changes altering that evidence.
*/
/*
* SEND HANDOVER RULE
*
* Layer C may receive only B1 evidence represented by text that is
* strictly before the final submitted material.
*
* This protects against a user beginning another sentence, causing the
* preceding sentence to become handover evidence, and then deleting that
* later sentence before pressing Send.
*/
const submittedResponseTextForHandover =
String(responseSession.currentText || ”).trim();
const handoverCheckpointTextForSend =
String(
responseSession.handoverLayerBEvidenceCheckpointText || ”
).trim();
const safeHandoverLayerBEvidence =
handoverCheckpointTextForSend &&
submittedResponseTextForHandover.length >
handoverCheckpointTextForSend.length &&
submittedResponseTextForHandover.startsWith(
handoverCheckpointTextForSend
)
? cloneProgressiveLayerBEvidence(
responseSession.handoverLayerBEvidence
)
: { evidence: [] };
const submittedResponseSession = {
active: responseSession.active,
version: responseSession.version,
currentText: responseSession.currentText,
/*
* Preserve the exact destination batch by reference.
*
* Do not clone it: an exact-current batch may still contain the live
* decisionPromise that the Send path should adopt rather than restart.
*/
destinationBatch:
responseSession.destinationBatch || null,
heardEvidence: {
…responseSession.heardEvidence
},
provisionalCoverage: {
…responseSession.provisionalCoverage
},
provisionalStepContext: {
…responseSession.provisionalStepContext
},
likelyNextStep:
responseSession.likelyNextStep
? { …responseSession.likelyNextStep }
: null,
provisionalNextQuestion:
String(
responseSession.provisionalNextQuestion || ”
).trim(),
provisionalGate:
responseSession.provisionalGate || null,
/*
* Deliberately hand over only evidence from before the final sentence.
* Layer C receives the complete submitted response separately.
*/
provisionalLayerBEvidence:
safeHandoverLayerBEvidence,
provisionalLayerBNovelty:
responseSession.provisionalLayerBNovelty || null,
provisionalStrategy:
responseSession.provisionalStrategy || null,
completedCheckpoint:
responseSession.completedCheckpoint
? {
…responseSession.completedCheckpoint,
provisionalCoverage: {
…(
responseSession.completedCheckpoint
.provisionalCoverage || {}
)
},
provisionalStepContext: {
…(
responseSession.completedCheckpoint
.provisionalStepContext || {}
)
},
likelyNextStep:
responseSession.completedCheckpoint.likelyNextStep
? {
…responseSession.completedCheckpoint
.likelyNextStep
}
: null
}
: null,
lastCheckpointText:
String(
responseSession.lastCheckpointText || ”
),
lastCompletedCheckpointText:
String(
responseSession.lastCompletedCheckpointText || ”
)
};
/*
* Send now owns the complete response.
*
* Preserve everything captured in submittedResponseSession, but stop
* the live Response Session from launching or committing any further
* progressive work.
*
* Send never waits for unfinished progressive processing.
*/
responseSession.active = false;
responseSession.assessmentQueued = false;
responseSession.evidenceAssessmentQueued = null;
const inputWrap = qs(‘.cp-input’);
/*
* FINAL INSIGHT REFLECTION
*
* This is the user’s response to the completed Layer D review.
* It is their closing position on the Insight, not another framework
* answer, so do not send it through Layers A/B/C.
*/
if (finalInsightReflectionPending) {
aiTurnInProgress = true;
if (inputWrap) {
inputWrap.style.display = ‘none’;
}
sendBtn.disabled = true;
nextBtn.disabled = true;
ctaText.textContent = ‘Saving your final reflection…’;
ta.value = ”;
coachState.draft = ”;
/*
* Display what the user submitted immediately, then quietly apply
* the normal spellcheck in place.
*/
pushMsg(‘user’, text);
const correctedText = await spellcheckLocale(text);
if (correctedText && correctedText !== text) {
const latestUserMessage =
[…thread]
.reverse()
.find(message => message?.role === ‘user’);
if (latestUserMessage) {
latestUserMessage.content = correctedText;
renderThread();
}
text = correctedText;
}
finalInsightReflectionPending = false;
endReportShown = true;
/*
* Generate a brief closing acknowledgement of the user’s
* final reflection before showing Next Insight.
*/
ctaText.textContent = ‘AI Coach is considering your final reflection…’;
showCoachThinkingIndicator();
const finalAcknowledgement =
await generateFinalInsightAcknowledgement(text);
removeCoachThinkingIndicator();
if (finalAcknowledgement) {
pushMsg(‘assistant’, {
reflection: ”,
question: finalAcknowledgement,
debug_trace: ”,
control: ‘final_insight_acknowledgement’
});
}
/*
* Save after adding the acknowledgement so the stored dialogue
* contains the complete closing exchange.
*/
await saveNow({
status: ‘Ended’,
user_input: text,
coach_decision: JSON.stringify({
next_model: ‘next_insight’,
control: ‘final_insight_reflection_complete’
})
});
await runEndOfChapterPersonalCoach();
const endReportBox = qs(‘#cp-end-report’);
if (endReportBox) {
endReportBox.innerHTML = `
`;
endReportBox.style.display = ‘block’;
const nextInsightBtn =
qs(‘#cp-end-report-next’, endReportBox);
if (nextInsightBtn) {
nextInsightBtn.addEventListener(‘click’, () => {
endReportShown = false;
triggerNextNavigation();
});
}
}
if (nextBtn) {
nextBtn.disabled = true;
nextBtn.style.display = ‘none’;
}
clearNudge();
aiTurnInProgress = false;
ctaText.textContent = ”;
return;
}
aiTurnInProgress = true;
/*
* Once the user has submitted their response there is nothing
* further for them to type until the Coach has responded.
*
* Hide the whole response area while the turn is being processed.
*/
if (inputWrap) {
inputWrap.style.display = ‘none’;
}
// Lock remaining controls while AI Coach is working
sendBtn.disabled = true;
nextBtn.disabled = true;
ctaText.textContent = ‘AI Coach is considering your response…’;
/*
* Show the user’s submitted response immediately.
*
* Spellcheck happens straight afterwards and, if it makes a
* correction, the displayed user message is updated before the
* response is sent through the coaching architecture.
*/
ta.value = ”;
coachState.draft = ”;
nextConfirmedOverride = false;
endReportShown = false;
renderEndOfInsightReport(”, ”);
nextBtn.textContent = ‘End’;
nextBtn.style.display = ”;
/*
* Freeze the exact submitted wording for the coaching turn.
*
* Spellcheck now runs independently. It may improve the displayed
* wording, but the Insight Coach must not wait for it before beginning
* its question-critical processing.
*/
const submittedText = text;
/*
* Remember exactly which thread item belongs to this submission.
*
* Do not later search for “the latest user message”, because by the
* time an asynchronous spellcheck completes the conversation may
* already contain additional Coach content.
*/
const submittedUserMessageIndex = thread.length;
pushMsg(‘user’, submittedText);
/*
* Temporary visual acknowledgement that the response has
* been received and the Coach is now considering it.
*/
showCoachThinkingIndicator();
/*
* Start spellcheck, but deliberately DO NOT await it here.
*
* The coaching architecture begins immediately below.
*
* When spellcheck finishes:
* – update only the exact user message that was submitted;
* – never alter a later user message;
* – redraw the thread;
* – restore the thinking indicator only if the Coach has not yet
* produced a visible response.
*/
const spellcheckPromise =
spellcheckLocale(submittedText)
.then(correctedText => {
const corrected =
String(correctedText || ”).trim();
if (
!corrected ||
corrected === submittedText
) {
return submittedText;
}
const submittedUserMessage =
thread[submittedUserMessageIndex];
if (
!submittedUserMessage ||
submittedUserMessage.role !== ‘user’ ||
String(
submittedUserMessage.content || ”
).trim() !== submittedText
) {
return submittedText;
}
submittedUserMessage.content = corrected;
/*
* renderThread() removes temporary UI-only elements, including
* the thinking indicator. Restore it only when no Coach message
* has yet appeared after this submitted response.
*/
const coachHasResponded =
thread
.slice(submittedUserMessageIndex + 1)
.some(message =>
message?.role === ‘assistant’
);
renderThread();
if (
aiTurnInProgress &&
!coachHasResponded
) {
showCoachThinkingIndicator();
}
return corrected;
})
.catch(err => {
console.log(
‘NON-BLOCKING SPELLCHECK ERROR:’,
err
);
return submittedText;
});
let turnResult = null;
try {
turnResult = await runLayeredInsightTurn(
submittedText,
submittedResponseSession
);
} catch (e) {
removeCoachThinkingIndicator();
pushMsg(
‘assistant’,
`Sorry — something went wrong. ${String(e.message || e)}`
);
} finally {
aiTurnInProgress = false;
/*
* The real Coach response will normally have caused renderThread()
* to redraw the conversation. Remove the temporary indicator
* defensively in case the turn ended without a normal response.
*/
removeCoachThinkingIndicator();
const keepInputHidden =
endReportShown ||
turnResult?.stopped_at === ‘C3_NON_RESONANCE_OFFER’ ||
turnResult?.stopped_at === ‘RELATIONSHIP_TERMINATED’ ||
turnResult?.stopped_at === ‘D_REVIEW’ ||
turnResult?.stopped_at === ‘D_RELATIONSHIP_RESISTANCE’;
/*
* Restore the response area only when the Coach has produced
* another question that genuinely requires a user response.
*
* End states, Continue / End choices and relationship termination
* manage their own UI and therefore remain hidden.
*/
if (!keepInputHidden && inputWrap) {
inputWrap.style.display = ”;
}
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;
/*
* AI Coach entitlement from Form 313 field 7232.
*
* The server normalises this to:
* – insight
* – personal
*
* Default safely to Insight Coach only if the value is missing or invalid.
*/
page.aiCoachLevel =
String(ctx?.aiCoachLevel || ”).trim().toLowerCase() === ‘personal’
? ‘personal’
: ‘insight’;
page.coachMemory =
ctx && ctx.coachMemory && typeof ctx.coachMemory === ‘object’
? ctx.coachMemory
: {};
/*
* Restore accepted B1/B1a evidence for this Insight unless reset=1.
*
* This keeps novelty comparison and end-of-Chapter accumulation intact if the
* user reloads the page part-way through an Insight.
*/
acceptedEvidence.length = 0;
if (
getParam(‘reset’, ”) !== ‘1’ &&
Array.isArray(page.coachMemory?.currentAcceptedEvidence)
) {
const seenAcceptedEvidence = new Set();
page.coachMemory.currentAcceptedEvidence.forEach(item => {
if (!item || typeof item !== ‘object’) 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 duplicateKey = [
category.toLowerCase(),
statement.toLowerCase(),
quote.toLowerCase()
].join(‘|’);
if (seenAcceptedEvidence.has(duplicateKey)) return;
seenAcceptedEvidence.add(duplicateKey);
acceptedEvidence.push({
category,
statement,
quote,
novelty_status:
String(item.novelty_status || ”).trim() ===
‘materially_developed’
? ‘materially_developed’
: ‘new’
});
});
}
/*
* Restore cumulative AI usage for this Insight before any new
* Insight AI calls are made.
*
* reset=1 deliberately starts the Insight with zero usage because
* the corresponding Form 320 record has been deleted.
*/
if (
window.cpAiService &&
typeof window.cpAiService.initialiseUsage === ‘function’
) {
window.cpAiService.initialiseUsage(
getParam(‘reset’, ”) === ‘1’
? null
: page.coachMemory?.aiUsage
);
}
/*
* Restore cumulative framework coverage and semantic context
* unless reset=1.
*
* A reset deliberately starts with no restored Step Coverage Map
* or Step Coverage Context.
*/
Object.keys(stepCoverageMap).forEach(key => {
delete stepCoverageMap[key];
});
Object.keys(stepCoverageContext).forEach(key => {
delete stepCoverageContext[key];
});
if (getParam(‘reset’, ”) !== ‘1’) {
const restoredCoverage =
page.coachMemory?.stepCoverageMap;
if (
restoredCoverage &&
typeof restoredCoverage === ‘object’ &&
!Array.isArray(restoredCoverage)
) {
Object.entries(restoredCoverage).forEach(
([stepNumber, status]) => {
const cleanStatus =
String(status || ”).trim();
if (
[
‘not_addressed’,
‘partially_addressed’,
‘left_incomplete’,
‘sufficiently_addressed’
].includes(cleanStatus)
) {
stepCoverageMap[String(stepNumber)] =
cleanStatus;
}
}
);
}
const restoredContext =
page.coachMemory?.stepCoverageContext;
if (
restoredContext &&
typeof restoredContext === ‘object’ &&
!Array.isArray(restoredContext)
) {
Object.entries(restoredContext).forEach(
([stepNumber, semanticContext]) => {
const cleanStepNumber =
String(stepNumber || ”).trim();
const coverageStatus =
stepCoverageMap[cleanStepNumber];
if (
!/^\d+$/.test(cleanStepNumber) ||
(
coverageStatus !== ‘partially_addressed’ &&
coverageStatus !== ‘left_incomplete’
) ||
!semanticContext ||
typeof semanticContext !== ‘object’ ||
Array.isArray(semanticContext)
) {
return;
}
const established =
String(
semanticContext.established || ”
).trim();
const remainingGap =
String(
semanticContext.remaining_gap || ”
).trim();
if (!established && !remainingGap) return;
stepCoverageContext[cleanStepNumber] = {
established,
remaining_gap: remainingGap
};
}
);
}
}
// 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
),
insightFocus: pick(
i?.insight_focus,
i2?.insight_focus,
i?.[7235],
i?.[‘7235’],
i2?.[7235],
i2?.[‘7235’]
),
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);
if (
String(ctx?.aiCoachingStatus || ”)
.trim()
.toLowerCase() === ‘terminated’
) {
const chat = qs(‘.cp-chat’);
if (chat) {
chat.innerHTML =
‘
‘;
}
return;
}
/*
* First AI Coach question
*
* A genuinely new Insight may use the opening pre-assessment.
*
* An existing saved Live Insight must NOT run that opening path again:
* its current-Insight progress already exists and must be resumed rather
* than recalculated from GTKY / previous Insight material.
*/
const currentInsightState =
page.coachMemory?.currentInsightState &&
typeof page.coachMemory.currentInsightState === ‘object’
? page.coachMemory.currentInsightState
: {};
const isExistingLiveInsight =
getParam(‘reset’, ”) !== ‘1’ &&
String(currentInsightState.status || ”).trim().toLowerCase() === ‘live’ &&
String(currentInsightState.dialogue || ”).trim() !== ”;
const initialStepConfig = getStepConfig();
if (isExistingLiveInsight) {
const dialogueRestored =
restoreThreadFromTranscript(
currentInsightState.dialogue
);
if (dialogueRestored) {
renderThread();
}
console.log(
‘RESUME LIVE INSIGHT:’,
dialogueRestored
? ‘saved dialogue restored; opening pre-assessment skipped’
: ‘saved dialogue could not be restored; opening pre-assessment skipped’
);
return;
}
/*
* NEW INSIGHT OPENING
*
* Do not show an incomplete “Your Turn” state while the opening
* pre-assessment and first Coach question are still being prepared.
*
* Existing Live Insights return above, so resume behaviour is unchanged.
*/
const openingCoachHeading =
qs(‘.cp-coach-h2-wrap’);
const openingCoachChat =
qs(‘.cp-chat’);
if (openingCoachHeading) {
openingCoachHeading.style.display = ‘none’;
}
if (openingCoachChat) {
openingCoachChat.style.display = ‘none’;
}
const frameworkSteps =
Array.isArray(initialStepConfig.steps)
? initialStepConfig.steps
: [];
const completionCriteriaByStep =
initialStepConfig.completionCriteriaByStep || {};
const gtkyDialogue =
String(
page.coachMemory?.gtkyDialogue || ”
).trim();
const previousInsightReviews =
Array.isArray(
page.coachMemory?.previousInsightReviews
)
? page.coachMemory.previousInsightReviews
: [];
const establishedMaterialParts = [];
if (gtkyDialogue) {
establishedMaterialParts.push(
`GTKY DIALOGUE:\n${gtkyDialogue}`
);
}
if (previousInsightReviews.length) {
establishedMaterialParts.push(
`COMPLETED EARLIER INSIGHT REVIEWS:\n${
JSON.stringify(
previousInsightReviews,
null,
2
)
}`
);
}
const establishedMaterial =
establishedMaterialParts.join(‘\n\n’).trim();
/*
* Only perform the opening pre-assessment when there is established
* material to assess.
*
* Failure is non-destructive: the existing opening behaviour remains
* available as the fallback.
*/
let openingContext = {
step_context: {},
overall_established: ”
};
if (
establishedMaterial &&
window.cpInsightLayerA &&
typeof window.cpInsightLayerA
.assessEstablishedFrameworkCoverage === ‘function’
) {
try {
const assessedCoverage =
await window.cpInsightLayerA
.assessEstablishedFrameworkCoverage({
config: page,
responseId: getOrCreateResponseId(),
frameworkSteps,
completionCriteriaByStep,
establishedMaterial
});
console.log(
‘INITIAL ESTABLISHED FRAMEWORK COVERAGE:’,
assessedCoverage
);
if (
assessedCoverage &&
typeof assessedCoverage === ‘object’ &&
!Array.isArray(assessedCoverage)
) {
if (
assessedCoverage.__opening_context &&
typeof assessedCoverage.__opening_context === ‘object’
) {
openingContext = {
step_context:
assessedCoverage.__opening_context.step_context &&
typeof assessedCoverage.__opening_context.step_context === ‘object’
? assessedCoverage.__opening_context.step_context
: {},
overall_established:
String(
assessedCoverage.__opening_context
.overall_established || ”
).trim()
};
}
Object.entries(assessedCoverage).forEach(
([stepNumber, status]) => {
const cleanStatus =
String(status || ”).trim();
if (
[
‘not_addressed’,
‘partially_addressed’,
‘sufficiently_addressed’
].includes(cleanStatus)
) {
stepCoverageMap[String(stepNumber)] =
cleanStatus;
}
}
);
}
} catch (err) {
console.log(
‘INITIAL FRAMEWORK COVERAGE ERROR:’,
err
);
}
}
/*
* Start at the first framework territory that is not already
* sufficiently addressed.
*/
const firstUnresolved =
findFirstForwardStep(
-1,
frameworkSteps,
completionCriteriaByStep
);
/*
* If every framework step is already sufficiently addressed,
* the opening should not fall back to Step 1.
*
* Use the final step only as a technical state anchor while Layer C
* writes a whole-Insight integration question instead of reopening
* any individual framework step.
*/
const allStepsSufficient =
!firstUnresolved &&
frameworkSteps.length > 0;
const openingStepIndex =
firstUnresolved
? firstUnresolved.index
: Math.max(frameworkSteps.length – 1, 0);
const openingFrameworkStep =
String(
frameworkSteps[openingStepIndex] || ”
)
.replace(
/^[A-Za-z][A-Za-z\s/&-]{0,40}:\s*/,
”
)
.trim();
const openingStepObjective =
String(
completionCriteriaByStep[
openingStepIndex + 1
] ||
openingFrameworkStep
).trim();
if (openingFrameworkStep) {
/*
* Keep the normal live-conversation step state aligned with the
* territory selected by the opening pre-assessment.
*/
if (initialStepConfig.stepKey) {
sessionStorage.setItem(
initialStepConfig.stepKey,
String(openingStepIndex)
);
}
let firstQuestion = openingFrameworkStep;
try {
if (
window.cpInsightLayerC &&
typeof window.cpInsightLayerC.writeQuestion === ‘function’
) {
firstQuestion =
await window.cpInsightLayerC.writeQuestion(
{
config: page,
responseId: getOrCreateResponseId(),
userText: ”,
thread: [],
lastCoachQuestion: ”,
insightQuestion: String(
page.coachFields?.insightQuestion ||
page.insight?.question ||
”
).trim(),
insightSummary: String(
page.coachFields?.insightSummary ||
page.insight?.summary ||
page.insight?.text ||
”
).trim(),
interventionScope: String(
page.coachFields?.interventionScope || ”
).trim(),
aiInsightPromptV3: String(
page.coachFields?.aiInsightPromptV3 || ”
).trim(),
currentFrameworkStep:
openingFrameworkStep,
currentStepObjective:
openingStepObjective,
nextFrameworkStep: ”,
nextStepObjective: ”,
frameworkSteps,
currentStepIndex:
openingStepIndex,
completionCriteriaByStep,
stepCoverageMap,
openingEstablished: String(
openingContext.step_context?.[
String(openingStepIndex + 1)
]?.established || ”
).trim(),
openingRemainingGap: String(
openingContext.step_context?.[
String(openingStepIndex + 1)
]?.remaining_gap || ”
).trim(),
openingOverallEstablished: String(
openingContext.overall_established || ”
).trim(),
layerBEvidence: [],
acceptedEvidence: [],
aiStepPromptV3: String(
page.coachFields?.aiStepPromptV3 || ”
).trim(),
coachStyle: String(
page.coachFields?.coachStyle ||
page.tone ||
‘direct’
).trim(),
previousInsightReviews,
persistentCoachContext: String(
page.coachMemory?.persistentCoachContext || ”
).trim(),
gate: {
assessment: ”,
additional_context: {
present: false,
summary: ”,
coach_note: ”
}
}
},
{
strategy: {
response_strategy:
allStepsSufficient
? ‘initial_integration’
: ‘initial’,
reason:
allStepsSufficient
? ‘All framework territories are already sufficiently addressed. Write one whole-Insight integration question.’
: ‘Write the opening question for the first unresolved framework territory.’,
follow_up_gap:
!allStepsSufficient &&
firstUnresolved?.coverage_status ===
‘partially_addressed’
? String(
openingContext.step_context?.[
String(openingStepIndex + 1)
]?.remaining_gap || ”
).trim()
: ”
}
},
”
);
}
} catch (err) {
console.log(
‘INITIAL QUESTION WRITER ERROR:’,
err
);
firstQuestion = openingFrameworkStep;
}
if (firstQuestion) {
/*
* The first Coach question is now ready.
* Reveal the complete coaching area before rendering it.
*/
if (openingCoachHeading) {
openingCoachHeading.style.display = ”;
}
if (openingCoachChat) {
openingCoachChat.style.display = ”;
}
pushMsg(‘assistant’, firstQuestion);
}
}
// 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();
}
})();