PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.13
ZIP AI – AI Website Builder & AI Agent v0.0.13
0.0.14 0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / assets / js / tools / editor / get-context / handler.js

handler.js in ZIP AI – AI Website Builder & AI Agent 0.0.13, at assets/js/tools/editor/get-context/handler.js

596 lines 27.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 // (The offset skip lives in flatten, BEFORE rowOf — see there.)
356 function pushRow( state, row ) {
357 if ( state.rows.length >= MAX_CONTEXT_ROWS ) {
358 state.truncated = true; return false;
359 }
360 let sz = 200;
361 try {
362 sz = JSON.stringify( row ).length;
363 } catch ( e ) {
364 sz = 200;
365 }
366 if ( state.bytes + sz > MAX_CONTEXT_BYTES && state.rows.length > 0 ) {
367 state.truncated = true;
368 return false;
369 }
370 state.rows.push( row );
371 state.bytes += sz;
372 return true;
373 }
374
375 // Row count of the walk flatten() would emit with NO row/byte caps (the
376 // depth cap still applies — at it a parent is one row and its subtree is
377 // not walked, exactly like flatten). Cheap (no serialization); powers the
378 // reply's `totalRows` so a truncated read can SAY how much exists and the
379 // model can page with `offset` instead of re-reading the same prefix.
380 function countRows( block, depth ) {
381 const inner = block.innerBlocks || [];
382 if ( inner.length > 0 && depth >= MAX_CONTEXT_DEPTH ) {
383 return 1;
384 }
385 let n = 1;
386 for ( let i = 0; i < inner.length; i++ ) {
387 n += countRows( inner[ i ], depth + 1 );
388 }
389 return n;
390 }
391
392 // Flatten a block + descendants into bounded rows (DFS): clientId (live
393 // handle), block name, text snippet, className, dot-path. Stops at the
394 // row/byte caps; at the depth cap it emits the node with childCount/hasMore
395 // rather than descending, so deeper blocks stay discoverable via a scoped
396 // re-read.
397 function flatten( block, path, depth, state ) {
398 const inner = block.innerBlocks || [];
399 // `toSkip` implements the read's `offset`: the first N rows of the SAME
400 // DFS order are consumed without being emitted or counted against the
401 // caps, so `offset = <previous reply's offset + rows.length>` continues
402 // a truncated read exactly where it stopped (measured 2026-08-22: an
403 // email in the last of 114 children sat ~100 rows past the cap; the
404 // model re-read the same prefix 20 times and burned the turn). The skip
405 // is decided HERE, before rowOf: a skipped row must not pay rowOf's
406 // DOM + computed-style + registry cost, or paging a big tree does
407 // O(pages²) row-builds. Skip arithmetic mirrors the emit paths exactly —
408 // a depth-capped node consumes ONE skip and its subtree is not walked.
409 const skipping = state.toSkip > 0;
410 if ( skipping ) {
411 state.toSkip--;
412 }
413 if ( inner.length > 0 && depth >= MAX_CONTEXT_DEPTH ) {
414 if ( skipping ) {
415 return;
416 }
417 const capped = rowOf( block, path );
418 capped.childCount = inner.length;
419 capped.hasMore = true;
420 pushRow( state, capped );
421 return;
422 }
423 let row = null;
424 if ( ! skipping ) {
425 row = rowOf( block, path );
426 if ( ! pushRow( state, row ) ) {
427 return;
428 }
429 }
430 for ( let i = 0; i < inner.length; i++ ) {
431 if ( state.truncated ) {
432 // The caps tripped before this parent's children were exhausted —
433 // flag it so the omitted subtree is discoverable.
434 if ( row ) {
435 row.childCount = inner.length;
436 row.hasMore = true;
437 }
438 return;
439 }
440 flatten( inner[ i ], path === '' ? String( i ) : path + '.' + i, depth + 1, state );
441 }
442 }
443
444 // Shared editor utilities — resolved at call time from the ONE source
445 // (editor/shared/editor-shared-utils.js): window in the browser, require()
446 // under jest. currentPostId lives there so this handler and apply-change can
447 // never drift; a missing module degrades consistently (null).
448 function sharedEditorUtils() {
449 if ( typeof window !== 'undefined' && window.zipwpEditorShared ) {
450 return window.zipwpEditorShared;
451 }
452 if ( typeof require === 'function' ) {
453 try {
454 return require( '../shared/editor-shared-utils.js' );
455 } catch ( e ) {
456 return null;
457 }
458 }
459 return null;
460 }
461 function currentPostId() {
462 const u = sharedEditorUtils();
463 return u && u.currentPostId ? u.currentPostId() : null;
464 }
465
466 function handleGetContext( args ) {
467 if ( ! window.wp || ! window.wp.data ) {
468 return { success: false, error: 'block_editor_unavailable' };
469 }
470 const sel = window.wp.data.select( 'core/block-editor' );
471 if ( ! sel ) {
472 return { success: false, error: 'block_editor_unavailable' };
473 }
474
475 // A scope that isn't a real clientId — empty, or a sentinel the model
476 // sometimes emits when it means "the whole page" (the literal string
477 // "null", "root", "page", …) — degrades to the page outline rather than
478 // a hard miss. The outline is the cheap top-level map the model can
479 // re-target from; a hard error here just burns a round-trip.
480 const scopeRaw = args && args.scope ? String( args.scope ) : '';
481 const SCOPE_SENTINELS = { '': 1, null: 1, undefined: 1, root: 1, page: 1, 0: 1, none: 1, false: 1 };
482 let scope = SCOPE_SENTINELS[ scopeRaw.toLowerCase() ] ? '' : scopeRaw;
483 // `offset` pages a capped read: skip the first N rows of the same DFS
484 // order (see pushRow). Non-numeric / negative degrades to 0.
485 const offsetRaw = args && args.offset !== undefined ? Number( args.offset ) : 0;
486 const offset = Number.isFinite( offsetRaw ) && offsetRaw > 0 ? Math.floor( offsetRaw ) : 0;
487 // Bounded accumulator (rows + running byte size + truncation flag).
488 const state = { rows: [], bytes: 0, truncated: false, toSkip: offset };
489
490 // A stale/rotated clientId — gone after a structural edit (replace/insert
491 // mint new ids), a page change, or replayed from older conversation history
492 // — must NOT dead-end the turn. A hard `scope_not_found` error makes the
493 // model re-ask the SAME dead id (wasted round-trips, duplicate-call blocks,
494 // token bloat). Instead DEGRADE to the page outline and FLAG the miss, so the
495 // model re-grounds against the live tree and re-targets in the SAME turn. The
496 // grounding tool is the model's only map; it must always return a usable one.
497 let scopeMissed = '';
498 let totalRows = 0;
499 if ( scope ) {
500 const root = sel.getBlock( scope );
501 if ( root ) {
502 totalRows = countRows( root, 0 );
503 flatten( root, '', 0, state );
504 } else {
505 scopeMissed = scope;
506 scope = '';
507 // The requested subtree is gone — the fallback outline is a
508 // re-grounding map and must be COMPLETE, so a stale read's
509 // offset never clips it.
510 state.toSkip = 0;
511 }
512 }
513 if ( ! scope ) {
514 const top = sel.getBlocks() || [];
515 totalRows = top.length;
516 for ( let i = 0; i < top.length; i++ ) {
517 // Outline read = top-level sections only (no recurse). Still
518 // row/byte-bounded so a page with hundreds of sections can't
519 // blow the read.
520 //
521 // Same `toSkip` contract as flatten, and for the same reason:
522 // this loop used to ignore it, so `offset` was silently a no-op
523 // on outline reads while `data.offset` and the truncation note
524 // still told the model to page. The re-call returned the SAME
525 // prefix, relabelled "showing rows N+1..", and the model re-read
526 // it until the turn burned — the exact failure the offset work
527 // exists to end. Skip is consumed BEFORE rowOf so a skipped row
528 // never pays its DOM + computed-style cost.
529 if ( state.toSkip > 0 ) {
530 state.toSkip--;
531 continue;
532 }
533 if ( ! pushRow( state, rowOf( top[ i ], String( i ) ) ) ) {
534 break;
535 }
536 }
537 }
538 const blocks = state.rows;
539 const effectiveOffset = scopeMissed ? 0 : offset;
540
541 const data = {
542 scope: scope || null,
543 postId: currentPostId(),
544 blocks,
545 // How many rows this walk holds in full (row/byte caps aside), so a
546 // capped read can SAY what fraction it showed and the model pages
547 // with `offset` instead of re-reading the same prefix.
548 totalRows,
549 };
550 if ( effectiveOffset > 0 ) {
551 data.offset = effectiveOffset;
552 }
553 // The read hit a cap (rows/bytes) — tell the model these rows are a
554 // PREFIX, so it narrows the scope (re-read a child clientId) rather than
555 // assuming it saw the whole tree. Pairs with per-parent childCount/hasMore.
556 if ( state.truncated ) {
557 data.truncated = true;
558 data.note =
559 'Showing rows ' + ( effectiveOffset + 1 ) + '-' + ( effectiveOffset + blocks.length ) +
560 ' of ' + totalRows + '. Re-call with the same scope and offset: ' +
561 ( effectiveOffset + blocks.length ) + ' for the next rows, or narrow scope to a child ' +
562 'container (rows with childCount/hasMore have unread children).';
563 }
564 // Flag a degraded read so the model KNOWS the id it asked for is gone and
565 // these rows are the live outline to re-target from (not the requested
566 // subtree) — turns a silent substitution into an explicit re-ground signal.
567 if ( scopeMissed ) {
568 data.scopeNotFound = scopeMissed;
569 }
570 return { success: true, data };
571 }
572
573 // Test seam (jest `unit-handler` project): guarded CommonJS re-export of the
574 // pure entry, same pattern as the other editor handlers. The browser IIFE
575 // path is unaffected.
576 if ( typeof module !== 'undefined' && module.exports ) {
577 module.exports = { handleGetContext };
578 }
579
580 // Register once the bridge is ready (same retry pattern as the other
581 // editor tools — the bridge mounts asynchronously).
582 function initHandler() {
583 if ( window.zipwpMcp && window.zipwpMcp.registerTool ) {
584 window.zipwpMcp.registerTool(
585 'editor/get-context',
586 async ( args ) => handleGetContext( args ),
587 { previewMode: 'client' }
588 );
589 } else {
590 setTimeout( initHandler, 100 );
591 }
592 }
593
594 initHandler();
595 }() );
596