| 1 |
/** |
| 2 |
* ZipWP MCP — Vibe Editing v2: editor/get-context handler. |
| 3 |
* |
| 4 |
* Reads a slice of the LIVE Gutenberg block tree from wp.data and returns it as |
| 5 |
* structured JSON. Dispatched by the brain's AgentBrowserLoop as a `js_rpc` |
| 6 |
* tool (same eager-execute path as js_hook); the result is POSTed back to the |
| 7 |
* Laravel /agent/rpc-reply route so the loop resolves it IN THE SAME TURN. |
| 8 |
* |
| 9 |
* Input: |
| 10 |
* scope (optional) — a block clientId. Present → return that block's subtree; |
| 11 |
* absent → the page's top-level sections (a cheap outline the brain can then |
| 12 |
* expand by re-calling with a section clientId). |
| 13 |
* |
| 14 |
* Returns { success, data: { scope, postId, blocks: [row] } } where each row is |
| 15 |
* { clientId, blockName, text, className, path, html?, computed? } — a |
| 16 |
* DESIGN-FAITHFUL view: `text` identifies the block (stripped), `html` is the |
| 17 |
* rich content WITH inline markup (the design pattern, when present), `computed` |
| 18 |
* is the rendered-style digest (fontSize/color/background/padding/fontWeight, |
| 19 |
* best-effort) so the brain edits relative to RENDERED reality, not class |
| 20 |
* strings. The clientId is the LIVE targeting handle (re-snapshotted every |
| 21 |
* call); the brain never persists it across turns. |
| 22 |
* |
| 23 |
* @package |
| 24 |
*/ |
| 25 |
( function () { |
| 26 |
'use strict'; |
| 27 |
|
| 28 |
// Content-read budget. A get_context row must carry the block's REAL copy so |
| 29 |
// the editor LLM rewrites what is actually on the page — the old 120/600 caps |
| 30 |
// meant a long paragraph came back as ~120 chars and the model invented the |
| 31 |
// rest (generic, off-page output). Sized to hold any single real block whole |
| 32 |
// (~1300 words); only paid on an explicit scoped read, and a section's |
| 33 |
// containers carry little own-text so the outline map stays lean. |
| 34 |
const CONTENT_READ_MAX = 8000; |
| 35 |
|
| 36 |
// Trim to the budget, but make any clip VISIBLE — a silent truncation is the |
| 37 |
// content-loss bug we are closing (the model confidently rewrites a long block |
| 38 |
// it only half-saw and drops the tail). With the marker it knows the copy is |
| 39 |
// incomplete and preserves/appends instead of replacing blind. |
| 40 |
function clip( s, max ) { |
| 41 |
return s.length > max ? s.slice( 0, max ) + ' …[+' + ( s.length - max ) + ' more chars not shown]' : s; |
| 42 |
} |
| 43 |
|
| 44 |
// Plain-text of a block's own copy — the brain identifies the block by it AND, |
| 45 |
// for a plain (no-markup) leaf, rewrites THIS string. Full copy up to the read |
| 46 |
// budget so a content edit sees the whole thing, not a stub. |
| 47 |
function textOf( block ) { |
| 48 |
const a = block.attributes || {}; |
| 49 |
const raw = a.text || a.content || a.label || a.title || ''; |
| 50 |
return clip( String( raw ).replace( /<[^>]*>/g, '' ).trim(), CONTENT_READ_MAX ); |
| 51 |
} |
| 52 |
|
| 53 |
// RICH content WITH inline markup intact — the carrier of the page's design |
| 54 |
// PATTERN (per-word colour <span>s, emphasis). `text` above is stripped for |
| 55 |
// identification; this is what the brain must edit so a content rewrite can |
| 56 |
// re-author the SAME inline structure with new words instead of flattening |
| 57 |
// it to a plain string (the headline-flattening defect). Surfaced ONLY when |
| 58 |
// the content actually carries inline markup, so plain blocks stay lean. |
| 59 |
function htmlOf( block ) { |
| 60 |
const a = block.attributes || {}; |
| 61 |
// eslint-disable-next-line eqeqeq |
| 62 |
const raw = a.content != null ? a.content : ( a.text != null ? a.text : null ); |
| 63 |
if ( typeof raw !== 'string' || raw.indexOf( '<' ) === -1 ) { |
| 64 |
return null; |
| 65 |
} |
| 66 |
return clip( raw, CONTENT_READ_MAX ); |
| 67 |
} |
| 68 |
|
| 69 |
// The live editor canvas document (iframe in the block editor; falls back to |
| 70 |
// the main document for the no-iframe mount). Shared shape with apply-change's |
| 71 |
// canvasCtx — kept minimal here (read-only lookup) to avoid a cross-handler |
| 72 |
// load-order dependency. |
| 73 |
function canvasDoc() { |
| 74 |
const f = document.querySelector( 'iframe[name="editor-canvas"]' ); |
| 75 |
return f && f.contentDocument ? f.contentDocument : document; |
| 76 |
} |
| 77 |
|
| 78 |
// The canvas node for a block (the live rendered element). Resolved ONCE per |
| 79 |
// row by the caller and shared by computedOf + renderedContentOf so the |
| 80 |
// [data-block] lookup runs once, not per-field. null when not mounted |
| 81 |
// (off-screen/virtualized). |
| 82 |
function nodeForBlock( doc, clientId ) { |
| 83 |
return doc.querySelector( '[data-block="' + clientId + '"]' ); |
| 84 |
} |
| 85 |
|
| 86 |
// RENDERED design facts for a block — the load-bearing axes the brain |
| 87 |
// otherwise cannot perceive (it sees authored class tokens, never the |
| 88 |
// rendered px/colour). Lets "bigger" be relative to the real size and lets |
| 89 |
// the brain see when an authored class did NOT move a property (a gs-* |
| 90 |
// !important rule owns it). Best-effort: null when the canvas node isn't |
| 91 |
// mounted (off-screen/virtualized) — the doctrine then falls back to |
| 92 |
// className. Read-only; never throws. |
| 93 |
function computedOf( el, win ) { |
| 94 |
try { |
| 95 |
if ( ! el ) { |
| 96 |
return null; |
| 97 |
} |
| 98 |
const cs = win.getComputedStyle( el ); |
| 99 |
return { |
| 100 |
fontSize: cs.fontSize, |
| 101 |
color: cs.color, |
| 102 |
backgroundColor: cs.backgroundColor, |
| 103 |
padding: cs.padding, |
| 104 |
fontWeight: cs.fontWeight, |
| 105 |
}; |
| 106 |
} catch ( e ) { |
| 107 |
return null; |
| 108 |
} |
| 109 |
} |
| 110 |
|
| 111 |
// Collapse whitespace + trim — for the RENDERED DOM text, which the browser |
| 112 |
// already decoded to plain text (no entities/tags). Deliberately NOT routed |
| 113 |
// through an innerHTML round-trip: that would mangle a literal "<" in the text. |
| 114 |
function collapseWs( s ) { |
| 115 |
return ( s === null || s === undefined ? '' : String( s ) ).replace( /\s+/g, ' ' ).trim(); |
| 116 |
} |
| 117 |
|
| 118 |
// Decode HTML entities + strip tags + collapse whitespace — for the AUTHORED |
| 119 |
// attr, which may carry entities/markup. A detached element (cached, plain |
| 120 |
// `document` — decoding is document-agnostic) does the exact decode the browser |
| 121 |
// does; under jest (no DOM) it degrades to a tag strip. Pairs with collapseWs |
| 122 |
// so authored + rendered compare like-for-like — entity/markup/whitespace |
| 123 |
// differences never cause a false divergence. |
| 124 |
let _decodeEl = null; |
| 125 |
function normContent( s ) { |
| 126 |
let str = s === null || s === undefined ? '' : String( s ); |
| 127 |
try { |
| 128 |
if ( _decodeEl === null ) { |
| 129 |
_decodeEl = document.createElement( 'div' ); |
| 130 |
} |
| 131 |
_decodeEl.innerHTML = str; |
| 132 |
str = _decodeEl.textContent || ''; |
| 133 |
} catch ( e ) { |
| 134 |
str = str.replace( /<[^>]*>/g, '' ); |
| 135 |
} |
| 136 |
return str.replace( /\s+/g, ' ' ).trim(); |
| 137 |
} |
| 138 |
|
| 139 |
// CONTENT OWNERSHIP (the content analog of get-styles' styleContext): the |
| 140 |
// RENDERED text of a LEAF content block vs its own authored content attr. |
| 141 |
// When they DIFFER, the block's own attr is a DEAD LEVER — the rendered value |
| 142 |
// is owned UPSTREAM (a parent composite computes it / pushes it via block |
| 143 |
// context: a countdown unit's label comes from the parent's `{unit}sLabel`). |
| 144 |
// Editing the child attr then silently no-ops on render. Surfacing the |
| 145 |
// rendered value (NOT a server-computed boolean — the brain compares `text` |
| 146 |
// vs `rendered` and judges) lets the brain redirect the edit to the owning |
| 147 |
// parent attr, WITHOUT any per-block knowledge — the render is the source of |
| 148 |
// truth, exactly like styleContext's `effective`. |
| 149 |
// |
| 150 |
// Scoped HARD to avoid false positives: LEAF blocks only (a container's |
| 151 |
// textContent is its whole subtree → would always "differ"); only TEXT attrs |
| 152 |
// (content/text/label) — NOT `number`, which leaves self-format/animate |
| 153 |
// (1000→"1,000", count-up mid-flight), so value-inequality there is not |
| 154 |
// ownership; both sides normalized; empty/partial renders (unmounted, or |
| 155 |
// mid-typewriter where rendered is a prefix of authored) are skipped. Takes the |
| 156 |
// pre-resolved canvas node. Returns the normalized rendered value, or null. |
| 157 |
function renderedContentOf( block, el ) { |
| 158 |
if ( ( block.innerBlocks || [] ).length > 0 ) { |
| 159 |
return null; |
| 160 |
} // leaf-only — never a container subtree |
| 161 |
const a = block.attributes || {}; |
| 162 |
// eslint-disable-next-line eqeqeq |
| 163 |
const authoredRaw = a.content != null ? a.content : a.text != null ? a.text : a.label != null ? a.label : null; |
| 164 |
if ( authoredRaw === null || authoredRaw === '' ) { |
| 165 |
return null; |
| 166 |
} |
| 167 |
if ( ! el ) { |
| 168 |
return null; |
| 169 |
} |
| 170 |
try { |
| 171 |
const rendered = collapseWs( el.textContent || '' ); |
| 172 |
if ( ! rendered ) { |
| 173 |
return null; |
| 174 |
} // unmounted / cleared (mid-stream) |
| 175 |
// indexOf===0 covers BOTH equality (child owns it) and rendered being a |
| 176 |
// strict prefix of authored (mid-typewriter) — either way, not shadowed. |
| 177 |
if ( normContent( authoredRaw ).indexOf( rendered ) === 0 ) { |
| 178 |
return null; |
| 179 |
} |
| 180 |
return rendered.slice( 0, 120 ); |
| 181 |
} catch ( e ) { |
| 182 |
return null; |
| 183 |
} |
| 184 |
} |
| 185 |
|
| 186 |
// Current className (L2 styling). Surfaced so an apply_change.setAttributes |
| 187 |
// can PRESERVE the existing utility/gs classes instead of replacing them |
| 188 |
// wholesale — the brain appends to this string. |
| 189 |
function classOf( block ) { |
| 190 |
const a = block.attributes || {}; |
| 191 |
return typeof a.className === 'string' ? a.className : ''; |
| 192 |
} |
| 193 |
|
| 194 |
// The 8 GBS-banned visual styling props are NEVER advertised as authorable |
| 195 |
// attrs (styling lives in `className` ONLY, the Spectra GBS JIT grammar). The |
| 196 |
// list is the SHARED SSOT (editor-shared-utils.isBannedVisualAttr) so this + |
| 197 |
// apply-change can never drift. If the shared module isn't resolved we |
| 198 |
// advertise the raw registry keys (the brain's zod still rejects the 8 on |
| 199 |
// write — degrade consistently, never a duplicate list). |
| 200 |
function isBannedVisualAttr( key ) { |
| 201 |
const u = sharedEditorUtils(); |
| 202 |
return !! ( u && typeof u.isBannedVisualAttr === 'function' && u.isBannedVisualAttr( key ) ); |
| 203 |
} |
| 204 |
|
| 205 |
// The block's VALID structural attr keys — straight from the registry |
| 206 |
// (getBlockType(blockName).attributes, the browser-side SSOT for what |
| 207 |
// Gutenberg accepts) minus the 8 GBS-banned styling attrs. Surfaced so the |
| 208 |
// model authors valid `attrs` first-try (e.g. counter `endNumber`, button |
| 209 |
// `linkURL`, container `isRootBlock`) instead of guessing against a brain |
| 210 |
// allowlist — the registry, not a hand-maintained list, decides validity. |
| 211 |
// null when the type isn't registered (forked/unknown block) → row omits the |
| 212 |
// field rather than advertise an empty/false set. |
| 213 |
function attrKeysOf( name ) { |
| 214 |
const t = window.wp && window.wp.blocks && window.wp.blocks.getBlockType |
| 215 |
? window.wp.blocks.getBlockType( name ) |
| 216 |
: null; |
| 217 |
if ( ! t || ! t.attributes ) { |
| 218 |
return null; |
| 219 |
} |
| 220 |
const out = []; |
| 221 |
Object.keys( t.attributes ).forEach( function ( k ) { |
| 222 |
// `spectraId` is an internal identifier that renders as a conditional |
| 223 |
// `data-spectra-id` ATTRIBUTE, never a queryable class/id — advertising |
| 224 |
// it as authorable lured the model into `querySelector('.<spectraId>')` |
| 225 |
// dead selectors. The sanctioned identity channel is `anchor` (→ the |
| 226 |
// element's frontend `id`), surfaced at the row level instead. |
| 227 |
if ( k === 'spectraId' ) { |
| 228 |
return; |
| 229 |
} |
| 230 |
if ( ! isBannedVisualAttr( k ) ) { |
| 231 |
out.push( k ); |
| 232 |
} |
| 233 |
} ); |
| 234 |
return out; |
| 235 |
} |
| 236 |
|
| 237 |
// SET (non-default) attribute VALUES — the higher-specificity styling/layout |
| 238 |
// layer that OUTRANKS className (cascade: block attribute > GBS class > block |
| 239 |
// default). Surfaced so the model SEES a pinned `style.*` / `layout` / spacing |
| 240 |
// attr BEFORE it sets a class that would silently no-op against it, and can |
| 241 |
// clear/move it. Uses the PROPER Gutenberg notion of "comment attributes": walk |
| 242 |
// the registered schema (getBlockType), drop attrs SOURCE-d from markup |
| 243 |
// (content/html — those are the text, not a styling override) and attrs still at |
| 244 |
// their declared `default` — exactly the set Gutenberg's serializer persists. |
| 245 |
// FULLY registry-driven — no hardcoded key list: only the schema's own `source` |
| 246 |
// (markup-backed) + `default` + the banned visual props filter it. So whatever |
| 247 |
// is pinned (style.*, layout, spacing, …) surfaces; the model picks the ones |
| 248 |
// relevant to its edit. null when nothing is overridden. |
| 249 |
function setAttrsOf( block ) { |
| 250 |
const t = window.wp && window.wp.blocks && window.wp.blocks.getBlockType |
| 251 |
? window.wp.blocks.getBlockType( block.name ) |
| 252 |
: null; |
| 253 |
if ( ! t || ! t.attributes ) { |
| 254 |
return null; |
| 255 |
} |
| 256 |
const a = block.attributes || {}; |
| 257 |
const schema = t.attributes; |
| 258 |
const out = {}; |
| 259 |
Object.keys( schema ).forEach( function ( k ) { |
| 260 |
const def = schema[ k ] || {}; |
| 261 |
if ( def.source ) { |
| 262 |
return; |
| 263 |
} // sourced from the markup — it's the content, not a styling override |
| 264 |
if ( isBannedVisualAttr( k ) ) { |
| 265 |
return; |
| 266 |
} // never authorable in attrs anyway |
| 267 |
const v = a[ k ]; |
| 268 |
if ( v === undefined || v === null || v === '' ) { |
| 269 |
return; |
| 270 |
} |
| 271 |
if ( Array.isArray( v ) && v.length === 0 ) { |
| 272 |
return; |
| 273 |
} |
| 274 |
if ( typeof v === 'object' && ! Array.isArray( v ) && Object.keys( v ).length === 0 ) { |
| 275 |
return; |
| 276 |
} |
| 277 |
try { |
| 278 |
if ( JSON.stringify( v ) === JSON.stringify( def.default ) ) { |
| 279 |
return; |
| 280 |
} // unchanged from default |
| 281 |
} catch ( e ) { /* unstringifiable → treat as a real override */ } |
| 282 |
out[ k ] = v; |
| 283 |
} ); |
| 284 |
return Object.keys( out ).length ? out : null; |
| 285 |
} |
| 286 |
|
| 287 |
function rowOf( block, path ) { |
| 288 |
const row = { |
| 289 |
clientId: block.clientId, |
| 290 |
blockName: block.name, |
| 291 |
text: textOf( block ), |
| 292 |
className: classOf( block ), |
| 293 |
path, |
| 294 |
}; |
| 295 |
// The block's HTML `anchor` → its frontend `id` (deterministic: |
| 296 |
// BlockAttributes wrapper sets id = anchor). The sanctioned way to TARGET |
| 297 |
// this block from custom JS is `#<anchor>` — surfaced beside className so |
| 298 |
// the model reads/reuses a real id instead of guessing a frontend class. |
| 299 |
const anchor = block.attributes && typeof block.attributes.anchor === 'string' ? block.attributes.anchor : ''; |
| 300 |
if ( anchor !== '' ) { |
| 301 |
row.anchor = anchor; |
| 302 |
} |
| 303 |
// Registry-declared valid attr keys (minus GBS-banned) so the model |
| 304 |
// authors valid setAttributes.attrs first-try. Omitted for unregistered |
| 305 |
// types — never advertise a guess. |
| 306 |
const attrKeys = attrKeysOf( block.name ); |
| 307 |
if ( attrKeys !== null ) { |
| 308 |
row.attrKeys = attrKeys; |
| 309 |
} |
| 310 |
// Pinned (non-default) attr VALUES — the higher-specificity layer that |
| 311 |
// overrides className. Lets the model reconcile specificity before a class no-ops. |
| 312 |
const setAttrs = setAttrsOf( block ); |
| 313 |
if ( setAttrs !== null ) { |
| 314 |
row.setAttrs = setAttrs; |
| 315 |
} |
| 316 |
// Design-faithful enrichment — only present when meaningful, so the |
| 317 |
// payload stays lean: `html` for blocks carrying inline markup, `computed` |
| 318 |
// when the live canvas node is readable. The canvas node is resolved ONCE |
| 319 |
// here and shared by computedOf + renderedContentOf (one [data-block] |
| 320 |
// lookup per row, not per field). |
| 321 |
const html = htmlOf( block ); |
| 322 |
if ( html !== null ) { |
| 323 |
row.html = html; |
| 324 |
} |
| 325 |
const cdoc = canvasDoc(); |
| 326 |
const cel = nodeForBlock( cdoc, block.clientId ); |
| 327 |
const computed = computedOf( cel, cdoc.defaultView || window ); |
| 328 |
if ( computed !== null ) { |
| 329 |
row.computed = computed; |
| 330 |
} |
| 331 |
// Content ownership: present ONLY when the rendered text differs from this |
| 332 |
// leaf's own content attr — i.e. the attr is a dead lever and the value is |
| 333 |
// owned upstream (a parent composite). The brain compares `text` vs |
| 334 |
// `rendered` and redirects the edit to the parent's owning attr. |
| 335 |
const rendered = renderedContentOf( block, cel ); |
| 336 |
if ( rendered !== null ) { |
| 337 |
row.rendered = rendered; |
| 338 |
} |
| 339 |
return row; |
| 340 |
} |
| 341 |
|
| 342 |
// Read bounds. An unbounded subtree dump on a large/whole page can exceed the |
| 343 |
// brain's compaction backstop and clear the active read with NO recovery |
| 344 |
// (the brain holds only the js_rpc envelope, not this payload). Cap rows + |
| 345 |
// depth + total bytes so a single read is always self-contained; a truncated |
| 346 |
// read flags `truncated` and stamps per-parent `childCount`/`hasMore` so the |
| 347 |
// model drills down with a narrower scope instead of getting a silent cut. |
| 348 |
const MAX_CONTEXT_ROWS = 250; |
| 349 |
const MAX_CONTEXT_DEPTH = 8; |
| 350 |
const MAX_CONTEXT_BYTES = 48000; |
| 351 |
|
| 352 |
// Push a row into the bounded accumulator. Returns false (caller stops) when |
| 353 |
// a cap is hit. Tracks an approximate serialized size so a few heavy rows |
| 354 |
// (long html / many attrKeys) trip the ceiling as readily as many light ones. |
| 355 |
function pushRow( state, row ) { |
| 356 |
if ( state.rows.length >= MAX_CONTEXT_ROWS ) { |
| 357 |
state.truncated = true; return false; |
| 358 |
} |
| 359 |
let sz = 200; |
| 360 |
try { |
| 361 |
sz = JSON.stringify( row ).length; |
| 362 |
} catch ( e ) { |
| 363 |
sz = 200; |
| 364 |
} |
| 365 |
if ( state.bytes + sz > MAX_CONTEXT_BYTES && state.rows.length > 0 ) { |
| 366 |
state.truncated = true; |
| 367 |
return false; |
| 368 |
} |
| 369 |
state.rows.push( row ); |
| 370 |
state.bytes += sz; |
| 371 |
return true; |
| 372 |
} |
| 373 |
|
| 374 |
// Flatten a block + descendants into bounded rows (DFS): clientId (live |
| 375 |
// handle), block name, text snippet, className, dot-path. Stops at the |
| 376 |
// row/byte caps; at the depth cap it emits the node with childCount/hasMore |
| 377 |
// rather than descending, so deeper blocks stay discoverable via a scoped |
| 378 |
// re-read. |
| 379 |
function flatten( block, path, depth, state ) { |
| 380 |
const inner = block.innerBlocks || []; |
| 381 |
const row = rowOf( block, path ); |
| 382 |
if ( inner.length > 0 && depth >= MAX_CONTEXT_DEPTH ) { |
| 383 |
row.childCount = inner.length; |
| 384 |
row.hasMore = true; |
| 385 |
pushRow( state, row ); |
| 386 |
return; |
| 387 |
} |
| 388 |
if ( ! pushRow( state, row ) ) { |
| 389 |
return; |
| 390 |
} |
| 391 |
for ( let i = 0; i < inner.length; i++ ) { |
| 392 |
if ( state.truncated ) { |
| 393 |
// The caps tripped before this parent's children were exhausted — |
| 394 |
// flag it so the omitted subtree is discoverable. |
| 395 |
row.childCount = inner.length; |
| 396 |
row.hasMore = true; |
| 397 |
return; |
| 398 |
} |
| 399 |
flatten( inner[ i ], path === '' ? String( i ) : path + '.' + i, depth + 1, state ); |
| 400 |
} |
| 401 |
} |
| 402 |
|
| 403 |
// Shared editor utilities — resolved at call time from the ONE source |
| 404 |
// (editor/shared/editor-shared-utils.js): window in the browser, require() |
| 405 |
// under jest. currentPostId lives there so this handler and apply-change can |
| 406 |
// never drift; a missing module degrades consistently (null). |
| 407 |
function sharedEditorUtils() { |
| 408 |
if ( typeof window !== 'undefined' && window.zipwpEditorShared ) { |
| 409 |
return window.zipwpEditorShared; |
| 410 |
} |
| 411 |
if ( typeof require === 'function' ) { |
| 412 |
try { |
| 413 |
return require( '../shared/editor-shared-utils.js' ); |
| 414 |
} catch ( e ) { |
| 415 |
return null; |
| 416 |
} |
| 417 |
} |
| 418 |
return null; |
| 419 |
} |
| 420 |
function currentPostId() { |
| 421 |
const u = sharedEditorUtils(); |
| 422 |
return u && u.currentPostId ? u.currentPostId() : null; |
| 423 |
} |
| 424 |
|
| 425 |
function handleGetContext( args ) { |
| 426 |
if ( ! window.wp || ! window.wp.data ) { |
| 427 |
return { success: false, error: 'block_editor_unavailable' }; |
| 428 |
} |
| 429 |
const sel = window.wp.data.select( 'core/block-editor' ); |
| 430 |
if ( ! sel ) { |
| 431 |
return { success: false, error: 'block_editor_unavailable' }; |
| 432 |
} |
| 433 |
|
| 434 |
// A scope that isn't a real clientId — empty, or a sentinel the model |
| 435 |
// sometimes emits when it means "the whole page" (the literal string |
| 436 |
// "null", "root", "page", …) — degrades to the page outline rather than |
| 437 |
// a hard miss. The outline is the cheap top-level map the model can |
| 438 |
// re-target from; a hard error here just burns a round-trip. |
| 439 |
const scopeRaw = args && args.scope ? String( args.scope ) : ''; |
| 440 |
const SCOPE_SENTINELS = { '': 1, null: 1, undefined: 1, root: 1, page: 1, 0: 1, none: 1, false: 1 }; |
| 441 |
let scope = SCOPE_SENTINELS[ scopeRaw.toLowerCase() ] ? '' : scopeRaw; |
| 442 |
// Bounded accumulator (rows + running byte size + truncation flag). |
| 443 |
const state = { rows: [], bytes: 0, truncated: false }; |
| 444 |
|
| 445 |
// A stale/rotated clientId — gone after a structural edit (replace/insert |
| 446 |
// mint new ids), a page change, or replayed from older conversation history |
| 447 |
// — must NOT dead-end the turn. A hard `scope_not_found` error makes the |
| 448 |
// model re-ask the SAME dead id (wasted round-trips, duplicate-call blocks, |
| 449 |
// token bloat). Instead DEGRADE to the page outline and FLAG the miss, so the |
| 450 |
// model re-grounds against the live tree and re-targets in the SAME turn. The |
| 451 |
// grounding tool is the model's only map; it must always return a usable one. |
| 452 |
let scopeMissed = ''; |
| 453 |
if ( scope ) { |
| 454 |
const root = sel.getBlock( scope ); |
| 455 |
if ( root ) { |
| 456 |
flatten( root, '', 0, state ); |
| 457 |
} else { |
| 458 |
scopeMissed = scope; |
| 459 |
scope = ''; |
| 460 |
} |
| 461 |
} |
| 462 |
if ( ! scope ) { |
| 463 |
const top = sel.getBlocks() || []; |
| 464 |
for ( let i = 0; i < top.length; i++ ) { |
| 465 |
// Outline read = top-level sections only (no recurse). Still |
| 466 |
// row/byte-bounded so a page with hundreds of sections can't |
| 467 |
// blow the read. |
| 468 |
if ( ! pushRow( state, rowOf( top[ i ], String( i ) ) ) ) { |
| 469 |
break; |
| 470 |
} |
| 471 |
} |
| 472 |
} |
| 473 |
const blocks = state.rows; |
| 474 |
|
| 475 |
const data = { |
| 476 |
scope: scope || null, |
| 477 |
postId: currentPostId(), |
| 478 |
blocks, |
| 479 |
}; |
| 480 |
// The read hit a cap (rows/bytes) — tell the model these rows are a |
| 481 |
// PREFIX, so it narrows the scope (re-read a child clientId) rather than |
| 482 |
// assuming it saw the whole tree. Pairs with per-parent childCount/hasMore. |
| 483 |
if ( state.truncated ) { |
| 484 |
data.truncated = true; |
| 485 |
} |
| 486 |
// Flag a degraded read so the model KNOWS the id it asked for is gone and |
| 487 |
// these rows are the live outline to re-target from (not the requested |
| 488 |
// subtree) — turns a silent substitution into an explicit re-ground signal. |
| 489 |
if ( scopeMissed ) { |
| 490 |
data.scopeNotFound = scopeMissed; |
| 491 |
} |
| 492 |
return { success: true, data }; |
| 493 |
} |
| 494 |
|
| 495 |
// Register once the bridge is ready (same retry pattern as the other |
| 496 |
// editor tools — the bridge mounts asynchronously). |
| 497 |
function initHandler() { |
| 498 |
if ( window.zipwpMcp && window.zipwpMcp.registerTool ) { |
| 499 |
window.zipwpMcp.registerTool( |
| 500 |
'editor/get-context', |
| 501 |
async ( args ) => handleGetContext( args ), |
| 502 |
{ previewMode: 'client' } |
| 503 |
); |
| 504 |
} else { |
| 505 |
setTimeout( initHandler, 100 ); |
| 506 |
} |
| 507 |
} |
| 508 |
|
| 509 |
initHandler(); |
| 510 |
}() ); |
| 511 |
|