HARPA.AI
LIBRARYAPIGUIDESAI COMMANDSBLOG

📸  Full Page Screenshot Summary

Photographs the entire page and lets the vision model actually look at it, so pictures, charts, diagrams and layout count as content instead of being lost the way a plain text extraction loses them. You choose how deep the walk should go and whether it starts at the top or at the current position, and the command then walks the page downwards one screen at a time, which is also what makes a lazy page load its next chunk, so loading and photographing are the same movement. Ten frames travel together in one vision request, and the walk stops by itself at the bottom or at the ceiling you picked. Before a single request is spent you see the exact number of frames and requests and can still stop. One question then asks whether the reading should follow any instructions of your own - a one to one rebuild with fonts and colours, text only without the pictures, anything you type - and whatever you type takes precedence over the format and is carried out on the finished record as well; skipping it reads the whole page in full detail, every word transcribed and every picture described. Every reading is verified: an empty segment is retried, then photographed again and read with a plainer prompt, and only then marked as a visible gap, so a failure is never a silent hole. The frame readings merge into one continuous record. When that record outgrows a single request it is never rewritten smaller: it is indexed into passages cut on frame seams, and you choose between sending every frame in one pass or letting each request draw only the passages it needs, selected by lexical search over that index and delivered with the frame every passage came from named beside it. Both are free. Under retrieval every answer carries frame citations, and every quote is then located in the record, restored to its exact wording, re-pointed at the passage it truly sits in, and dropped if it cannot be found, with numeric, negation and modal guards that catch a claim which kept the words and reversed the meaning. The record answers everything: a quick summary, a long-form or structured report, a visual analysis, a data extraction of every table and chart, a UI audit, a full markdown rebuild of the page, an inventory of links and actions, verbatim citations, a translation, an export, a handover to another command, or anything else you type into the same menu as a free instruction or question. PDFs are handled by page: the page count is read out of the viewer for free where possible, and every page is photographed in turn. Each finished segment reports itself with one compact progress line, so a long page is never a minute of silence. Everything is written in the output language chosen at the start.

Created by Morteza H.
Updated on Aug 29, 10:14
Installed 55 times
RUNS JS CODE

How to Use

IMPORT COMMAND

Content

- label: INITIALIZE RUN
  type: group
  steps:
    - label: RESET FRAME SERIES
      type: calc
      func: set
      param: shots
      value: ''
      format: ''
    - label: RESET CAPTURED COUNTER
      type: calc
      func: set
      param: capturedFrames
      value: '0'
      format: ''
    - label: RESET FRAME COUNT
      type: calc
      func: set
      param: shotCount
      value: '0'
      format: ''
    - label: RESET VISION RECORD
      type: calc
      func: set
      param: notes
      value: ''
      format: ''
    - label: RESET SEGMENT BUFFER
      type: calc
      func: set
      param: segmentNotes
      value: ''
      format: ''
    - label: RESET BATCH BUFFER
      type: calc
      func: set
      param: batchImages
      value: ''
      format: ''
    - label: RESET SEGMENT COUNTER
      type: calc
      func: set
      param: segment
      value: '0'
      format: ''
    - label: RESET FAILED SEGMENTS
      type: calc
      func: set
      param: failedSegments
      value: '0'
      format: ''
    - label: RESET ANSWER
      type: calc
      func: set
      param: gpt
      value: ''
      format: ''
    - label: RESET FOLLOW UP STATE
      type: calc
      func: set
      param: change
      value: ''
      format: ''
    - label: RESET REPAIR BUFFER
      type: calc
      func: set
      param: repairImages
      value: ''
      format: ''
    - label: RESET REPAIR MODE
      type: calc
      func: set
      param: repairMode
      value: 'no'
      format: ''
    - label: RESET CHROME VERDICT
      type: calc
      func: set
      param: chromeVerdict
      value: ''
      format: ''
    - label: RESET RAW READING
      type: calc
      func: set
      param: rawSegment
      value: ''
      format: ''
    - label: RESET SCOPE NOTE
      type: calc
      func: set
      param: scopeNote
      value: ''
      format: ''
    - label: RESET PROGRESS LINE
      type: calc
      func: set
      param: progress
      value: ''
      format: ''
    - label: RESET SCRATCH SLOT
      type: calc
      func: set
      param: scratch
      value: ''
      format: ''
    - label: RESET REPAIR COUNT
      type: calc
      func: set
      param: repairCount
      value: ''
      format: ''
    - label: RESET PROMPT PAYLOAD
      type: calc
      func: set
      param: payload
      value: ''
      format: ''
    - label: RESET PDF PAGE READ
      type: calc
      func: set
      param: pdfPageRead
      value: ''
      format: ''
    - label: RESET PDF PROBE
      type: calc
      func: set
      param: pdfProbe
      value: ''
      format: ''
    - label: RESET READING FOCUS
      type: calc
      func: set
      param: focusChoice
      value: ''
      format: ''
    - label: RESET FOCUS BRIEF
      type: calc
      func: set
      param: focus
      value: ''
      format: ''
    - label: RESET OUTPUT FORMAT
      type: calc
      func: set
      param: format
      value: ''
      format: ''
    - label: RESET HANDOVER SLOT
      type: calc
      func: set
      param: handoff
      value: ''
      format: ''
    - label: RESET CAPTURE STATE
      type: calc
      func: set
      param: capture
      value: ''
      format: ''
    - label: RESET THE PASSAGE INDEX
      type: calc
      func: set
      param: passages
      value: ''
      format: ''
    - label: RESET THE EVIDENCE SLOT
      type: calc
      func: set
      param: evidence
      value: ''
      format: ''
    - label: RESET THE SELECTION
      type: calc
      func: set
      param: selection
      value: ''
      format: ''
    - label: RESET THE RETRIEVAL QUERY
      type: calc
      func: set
      param: query
      value: ''
      format: ''
    - label: RESET THE CITATION CHECK
      type: calc
      func: set
      param: citationCheck
      value: ''
      format: ''
    - label: RESET THE CITATION RULE
      type: calc
      func: set
      param: citationRule
      value: ''
      format: ''
    - label: RESET THE PENDING ANSWER
      type: calc
      func: set
      param: pendingAnswer
      value: ''
      format: ''
    - label: DEFAULT TO THE WHOLE RECORD
      type: calc
      func: set
      param: method
      value: full
      format: ''
    - label: RESET THE RECORD SIZE
      type: calc
      func: set
      param: recordChars
      value: ''
      format: ''
    - label: RESET THE PASSAGE COUNT
      type: calc
      func: set
      param: passageCount
      value: ''
      format: ''
    - label: RESET THE FRAME COUNT
      type: calc
      func: set
      param: frameCount
      value: ''
      format: ''
    - label: IMAGES PER GPT REQUEST
      type: calc
      func: set
      param: batchSize
      value: '10'
      format: ''
    - label: FRAME CEILING
      type: calc
      func: set
      param: maxShots
      value: '300'
      format: ''
    - label: INITIAL SETTLE DELAY
      type: calc
      func: set
      param: settleDelay
      value: '220'
      format: ''
    - label: MILLISECONDS BETWEEN FRAMES
      type: calc
      func: set
      param: captureInterval
      value: '520'
      format: ''
    - label: LAZY LOAD GRACE PERIOD
      type: calc
      func: set
      param: growWait
      value: '900'
      format: ''
    - label: DEFAULT START MODE
      type: calc
      func: set
      param: startMode
      value: top
      format: ''
    - label: DEFAULT PDF URL
      type: calc
      func: set
      param: pdfUrl
      value: '{{url}}'
      format: ''
- label: CONFIGURE CAPTURE SCOPE
  type: group
  steps:
    - label: SELECT CAPTURE SCOPE
      type: ask
      message: >-
        How deep should this page be read?


        One frame is one screenshot of one screen; between two frames the page
        scrolls down by exactly one screen, and ten frames are read in one
        vision request. The number is only a ceiling — the walk stops on its own
        at the bottom of the page.
      param: scopeChoice
      options:
        - label: 🔭 300 FRAMES · 299 scrolls · up to 30 requests · the deepest walk
          value: '300'
        - label: >-
            📚 100 FRAMES · 99 scrolls · up to 10 requests · docs and long
            threads
          value: '100'
        - label: 📄 50 FRAMES · 49 scrolls · up to 5 requests · a normal article
          value: '50'
        - label: 👀 20 FRAMES · 19 scrolls · up to 2 requests · a look at the top
          value: '20'
        - value: $custom
      default: '300'
      vision:
        enabled: false
        mode: area
        hint: ''
        send: true
      optionsInvalid: false
    - label: NORMALISE CAPTURE SCOPE
      type: js
      args: scopeChoice
      code: >-
        // FULL PAGE VISION · SCOPE

        // Turns the chosen scope into the three numbers the run is actually
        made of: how many

        // screenshots may be taken, how many scroll steps that is, and how many
        vision requests

        // they can cost at ten images per request. Free text is accepted and
        clamped, so a

        // typed "1000" becomes the hard ceiling of 300 instead of an impossible
        plan.

        const HARD_MAX = 300;

        const SIZE = 10;


        const raw = String(args.scopeChoice == null ? '' : args.scopeChoice);

        const digits = raw.match(/\d+/);


        let frames = digits ? Number(digits[0]) : 300;

        if (!isFinite(frames) || frames < 1) frames = 300;

        frames = Math.min(HARD_MAX, Math.max(1, Math.round(frames)));


        return {
          frames: frames,
          scrolls: Math.max(0, frames - 1),
          requests: Math.ceil(frames / SIZE),
          ceiling: HARD_MAX
        };
      param: scope
      timeout: 30000
      onFailure: ''
      silent: true
    - label: APPLY FRAME CEILING
      type: calc
      func: set
      param: maxShots
      value: '{{scope.frames}}'
      format: ''
    - label: NOTE THE CEILING
      type: calc
      func: set
      param: scopeNote
      value: ceiling {{scope.frames}} frames · max {{scope.requests}} requests
      format: ''
- label: CONFIGURE START POINT
  type: group
  steps:
    - label: SELECT START POINT
      type: ask
      message: Where should the walk begin?
      param: startMode
      options:
        - label: ⬆️ TOP · photograph the page from the very beginning
          value: top
        - label: 📍 HERE · start at the current scroll position and walk down
          value: here
      default: top
      vision:
        enabled: false
        mode: area
        hint: ''
        send: true
      optionsInvalid: false
    - condition: '{{startMode}} = here'
      label: NOTE PARTIAL WALK
      type: say
      message: 📍 Starting at the current position — everything above it stays unread.
- label: CONFIGURE OUTPUT LANGUAGE
  type: group
  steps:
    - label: SELECT OUTPUT LANGUAGE
      type: ask
      message: In which language should every answer be written?
      param: languageChoice
      options:
        - label: 🔤 MATCH PAGE · answer in the language of the page itself
          value: matchPage
        - label: 🇩🇪 GERMAN · Deutsch
          value: German
        - label: 🇬🇧 ENGLISH · English
          value: English
        - label: 🇪🇸 SPANISH · Español
          value: Spanish
        - label: 🇫🇷 FRENCH · Français
          value: French
        - label: 🇸🇦 ARABIC · العربية
          value: Arabic
        - value: $custom
      default: matchPage
      vision:
        enabled: false
        mode: area
        hint: ''
        send: true
      optionsInvalid: false
    - label: APPLY SELECTED LANGUAGE
      type: calc
      func: set
      param: outputLanguage
      value: '{{languageChoice}}'
      format: ''
    - condition: '{{languageChoice}} = matchPage'
      label: MATCH THE PAGE LANGUAGE
      type: calc
      func: set
      param: outputLanguage
      value: >-
        the same language that the page itself is written in, as seen in the
        screenshots
      format: ''
- label: DETECT SOURCE TYPE
  type: group
  steps:
    - label: MATCH PDF URL
      type: calc
      func: match
      param: url
      to: pdfMatch
      regex: /https?:\/\/[^\s]+\.pdf(\/[^\s]*)?|[^\s]+\/pdf\/[^\s]*/
    - label: ASSUME WEB PAGE
      type: calc
      func: set
      param: sourceKind
      value: html
      format: ''
    - condition: '{{pdfMatch}}'
      label: SWITCH TO PDF
      type: calc
      func: set
      param: sourceKind
      value: pdf
      format: ''
    - condition: '{{pdfMatch}}'
      label: STORE PDF URL
      type: calc
      func: set
      param: pdfUrl
      value: '{{pdfMatch}}'
      format: ''
- condition: '{{sourceKind}} = html'
  label: HTML CAPTURE PIPELINE
  type: group
  steps:
    - label: ANNOUNCE HTML CAPTURE
      type: say
      message: 📸 Walking the page · {{scopeNote}}
    - label: PREPARE CAPTURE
      type: js
      args: settleDelay, startMode
      code: >-
        // FULL PAGE VISION · PREPARE CAPTURE

        // There is no separate preload pass: the capture walk itself scrolls
        the page one

        // viewport at a time, which is exactly what triggers lazy loading, so
        loading and

        // photographing happen in ONE sweep. This step neutralises animated
        scrolling, finds

        // the real scroll container, measures how much of the viewport is
        permanently covered

        // by pinned elements, parks the page at the chosen starting point and
        publishes the

        // capture state.

        //

        // STRIDE: exactly one screen, no artificial overlap. The only thing
        subtracted is the

        // height of elements that are pinned to the top or the bottom of the
        screen. Those

        // elements cover a strip of the viewport on every single frame, so
        content that lands

        // underneath them would never be photographed at all. Subtracting their
        height is not

        // overlap, it is the correction that makes an exact one-screen step
        actually exact.

        //

        // Nothing in here is allowed to throw. Every failure is reported as
        data.

        function sleep(ms) {
          return new Promise(function (r) { setTimeout(r, ms); });
        }


        function killSmoothScrolling() {
          // A page level `scroll-behavior: smooth` animates even behavior:'auto' and would
          // leave every frame mid transition. Override it for the whole run.
          try {
            if (document.getElementById('__fpv_no_smooth')) return;
            const style = document.createElement('style');
            style.id = '__fpv_no_smooth';
            style.textContent = 'html,body,:root,*{scroll-behavior:auto !important}';
            (document.head || document.documentElement).appendChild(style);
          } catch (e) { /* best effort */ }
        }


        function docScroller() {
          return document.scrollingElement || document.documentElement || document.body;
        }


        function findInnerScroller() {
          let best = null;
          let bestScore = 0;
          let nodes = [];
          try {
            nodes = document.querySelectorAll('div,main,section,article,ul,ol,table,form');
          } catch (e) {
            return null;
          }
          const limit = Math.min(nodes.length, 6000);
          for (let i = 0; i < limit; i++) {
            const el = nodes[i];
            let sh = 0;
            let ch = 0;
            try { sh = el.scrollHeight; ch = el.clientHeight; } catch (e) { continue; }
            if (sh <= ch + 200) continue;
            let rect;
            try { rect = el.getBoundingClientRect(); } catch (e) { continue; }
            if (rect.width < 240 || rect.height < 240) continue;
            let overflow = '';
            try { overflow = window.getComputedStyle(el).overflowY; } catch (e) { overflow = ''; }
            if (!/(auto|scroll|overlay)/.test(overflow)) continue;
            const score = (sh - ch) * rect.width * rect.height;
            if (score > bestScore) { bestScore = score; best = el; }
          }
          return best;
        }


        function pickScroller() {
          const se = docScroller();
          let sh = 0;
          let ch = 0;
          try { sh = Math.max(se.scrollHeight, document.body ? document.body.scrollHeight : 0); } catch (e) { sh = 0; }
          try { ch = se.clientHeight || window.innerHeight || 800; } catch (e) { ch = window.innerHeight || 800; }
          if (sh > ch + 120) return null;      // null means: scroll the window
          return findInnerScroller();          // may still be null
        }


        // How many pixels at the top and at the bottom of the screen are
        permanently covered by

        // pinned bars: navigation, cookie banners, sticky footers, chat
        bubbles. Content that lands

        // underneath them would never be photographed, so their height has to
        come off the step.

        //

        // Measured by asking the browser what is actually painted at nine
        points along each edge.

        // The obvious alternative - walking every element and reading its
        computed style - costs

        // thousands of forced layouts on a long encyclopedia page and can take
        longer than the walk

        // itself. Eighteen hit tests answer the same question in constant time.

        function measureOcclusion(vh, vw) {
          const out = { top: 0, bottom: 0, items: 0 };
          const xs = [Math.round(vw * 0.15), Math.round(vw * 0.5), Math.round(vw * 0.85)];
          const topYs = [2, 12, 28];
          const bottomYs = [vh - 2, vh - 12, vh - 28];

          function stack(x, y) {
            try { return document.elementsFromPoint(x, y) || []; } catch (e) { return []; }
          }

          function consider(el, edge, vh, vw) {
            if (!el || el === document.body || el === document.documentElement) return;
            let cs;
            try { cs = window.getComputedStyle(el); } catch (e) { return; }
            if (!cs) return;
            if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
            if (cs.visibility === 'hidden' || cs.display === 'none' || Number(cs.opacity) === 0) return;
            let r;
            try { r = el.getBoundingClientRect(); } catch (e) { return; }
            if (r.width < vw * 0.55) return;
            if (r.height < 12 || r.height > vh * 0.6) return;
            if (edge === 'top' && r.bottom > 4 && r.bottom < vh * 0.5) {
              out.top = Math.max(out.top, Math.ceil(r.bottom));
              out.items++;
            } else if (edge === 'bottom' && r.top < vh - 4 && r.top > vh * 0.5) {
              out.bottom = Math.max(out.bottom, Math.ceil(vh - r.top));
              out.items++;
            }
          }

          for (let i = 0; i < xs.length; i++) {
            for (let j = 0; j < topYs.length; j++) {
              const s = stack(xs[i], topYs[j]);
              for (let k = 0; k < Math.min(s.length, 6); k++) consider(s[k], 'top', vh, vw);
            }
            for (let j = 0; j < bottomYs.length; j++) {
              const s = stack(xs[i], bottomYs[j]);
              for (let k = 0; k < Math.min(s.length, 6); k++) consider(s[k], 'bottom', vh, vw);
            }
          }

          // A full screen overlay must not collapse the step to nothing.
          const cap = Math.floor(vh * 0.4);
          if (out.top + out.bottom > cap) {
            const scale = cap / (out.top + out.bottom);
            out.top = Math.floor(out.top * scale);
            out.bottom = Math.floor(out.bottom * scale);
          }
          return out;
        }


        function heightOf(el) {
          if (el) return el.scrollHeight;
          const se = docScroller();
          return Math.max(
            se ? se.scrollHeight : 0,
            document.body ? document.body.scrollHeight : 0,
            document.documentElement ? document.documentElement.scrollHeight : 0,
            window.innerHeight || 800
          );
        }


        function viewportOf(el) {
          if (el) return Math.max(200, el.clientHeight || 800);
          return Math.max(200, window.innerHeight || 800);
        }


        function posOf(el) {
          if (el) return el.scrollTop || 0;
          const se = docScroller();
          const w = typeof window.pageYOffset === 'number' ? window.pageYOffset : 0;
          return Math.max(w, se ? (se.scrollTop || 0) : 0, document.body ? (document.body.scrollTop || 0) : 0);
        }


        function setPos(el, y) {
          const target = Math.max(0, Math.round(y));
          if (el) { el.scrollTop = target; return; }
          try { window.scrollTo({ top: target, behavior: 'instant' }); } catch (e) { /* ignore */ }
          try { window.scrollTo({ top: target, behavior: 'auto' }); } catch (e) { /* ignore */ }
          try { window.scroll(0, target); } catch (e) { /* ignore */ }
          const se = docScroller();
          if (se) se.scrollTop = target;
          if (document.body) document.body.scrollTop = target;
        }


        // Deliberately no frame prediction. The height measured here is the
        height of the page

        // BEFORE the walk, and the walk is what makes a lazy page load the rest
        of itself.

        const result = {
          ok: 'no', viewport: 0, total: 0, stride: 0, startY: 0,
          pinnedTop: 0, pinnedBottom: 0, pinned: 0,
          scroller: 'window', start: 'top', error: ''
        };


        try {
          const wantHere = String(args.startMode || 'top').toLowerCase().indexOf('here') === 0;

          killSmoothScrolling();
          const el = pickScroller();
          result.scroller = el ? 'element' : 'window';
          result.start = wantHere ? 'here' : 'top';

          const keepAt = wantHere ? posOf(el) : 0;
          setPos(el, keepAt);
          await sleep(Math.max(120, Number(args.settleDelay) || 250));

          const vh = viewportOf(el);
          const vw = Math.max(320, window.innerWidth || 1280);
          const height = heightOf(el);
          const occ = measureOcclusion(vh, vw);
          const stride = Math.max(120, vh - occ.top - occ.bottom);
          const startY = posOf(el);

          window.__fpv = {
            el: el, stride: stride, viewport: vh, total: height,
            frames: 0, startY: startY, abort: false, misses: 0, retries: 0,
            pinnedTop: occ.top, pinnedBottom: occ.bottom
          };

          result.viewport = vh;
          result.total = height;
          result.stride = stride;
          result.startY = startY;
          result.pinnedTop = occ.top;
          result.pinnedBottom = occ.bottom;
          result.pinned = occ.items;
          result.ok = 'yes';
        } catch (e) {
          result.error = String((e && e.message) || e);
        }


        return result;
      param: capture
      timeout: 60000
      onFailure: PLAN FRAME LIST
      silent: true
    - label: PLAN FRAME LIST
      type: js
      args: maxShots
      code: >-
        // FULL PAGE VISION · FRAME LIST

        // The walk cannot know the final page height in advance, because lazy
        content only

        // appears while scrolling. The list is therefore the ceiling chosen by
        the user,

        // not a prediction: the loop leaves early the moment the scroller stops
        moving.

        const MAX_SHOTS = Math.max(1, Math.min(300, Number(String(args.maxShots
        || '').replace(/[^0-9]/g, '')) || 300));

        const list = [];

        for (let i = 1; i <= MAX_SHOTS; i++) list.push({ n: i });

        return list;
      param: frameList
      timeout: 30000
      onFailure: FINISH CAPTURE
      silent: true
    - label: CAPTURE HTML FRAMES
      type: loop
      list: frameList
      steps:
        - label: APPEND FRAME
          type: js
          args: shots, view, maxShots, capturedFrames
          code: >-
            // FULL PAGE VISION · APPEND FRAME

            // Exactly the accumulation of the original command, which is the
            only form proven

            // to deliver several images to one prompt:

            //

            //   arrayValue += ',' + viewValue   ->  a real parameter  ->  used
            in the prompt

            //

            // Two rules follow from that and must not be touched:

            //   * the separator is a COMMA. HARPA splits the stored series on
            commas to resolve

            //     the individual attachments. A newline joined series arrives
            as a single blob

            //     and only one image reaches the model.

            //   * the token travels as a JS ARGUMENT. Interpolating {{view}}
            inside a calc step

            //     coerces the reference to text and breaks it, which surfaces
            as

            //     "Failed to execute 'createImageBitmap'".

            //

            // Retry behaviour is built here, not delegated: a missing frame
            throws on purpose

            // so the loop lands on the pacing step, waits, and retries the SAME
            scroll position

            // on the next pass. The counter step is skipped on that path, so
            the frame count

            // stays exact. After too many refusals in a row the pacing step
            raises the abort

            // flag and this step stops throwing, which lets the walk end
            cleanly instead of

            // grinding through the whole ceiling.

            const LIMIT = Math.max(1, Math.min(300, Number(String(args.maxShots
            || '').replace(/[^0-9]/g, '')) || 300));

            const held = Math.max(0, Number(args.capturedFrames) || 0);


            let arrayValue = args['shots'] || '';

            const viewValue = args.view;


            const S = window.__fpv || (window.__fpv = {});


            if (S.abort === true) return arrayValue;   // clean shutdown, no
            further frames

            if (held >= LIMIT) return arrayValue;      // ceiling reached


            if (!viewValue) {
              S.misses = (Number(S.misses) || 0) + 1;
              throw new Error('frame not available yet');
            }


            S.misses = 0;


            if (arrayValue === '') {
              arrayValue = viewValue;
            } else {
              arrayValue += ',' + viewValue;
            }


            return arrayValue;
          param: shots
          timeout: 60000
          onFailure: PACE CAPTURE
          silent: true
        - label: COUNT CAPTURED FRAME
          type: calc
          func: increment
          param: capturedFrames
          delta: '1'
        - label: ADVANCE ONE FRAME
          type: js
          args: captureInterval, growWait
          code: >-
            // FULL PAGE VISION · ADVANCE ONE FRAME

            // Runs immediately after a frame was grabbed. Scrolls forward by
            exactly one stride -

            // one full screen minus whatever is pinned to the top or bottom
            edge, so the frames sit

            // edge to edge without overlap and without a hidden gap -

            // verifies the real position, gives lazily loaded content the
            chance to extend the

            // page, and returns true only when the scroller truly cannot move
            any further.

            // Loading and capturing therefore happen in a single downward pass.

            //

            // It is also the single exit of the walk: when the pacing step has
            given up on a

            // position, the abort flag is read here and reported as "bottom
            reached", so the

            // existing exit jump ends the loop without any extra control flow.

            function sleep(ms) {
              return new Promise(function (r) { setTimeout(r, ms); });
            }


            function docScroller() {
              return document.scrollingElement || document.documentElement || document.body;
            }


            function heightOf(el) {
              if (el) return el.scrollHeight;
              const se = docScroller();
              return Math.max(
                se ? se.scrollHeight : 0,
                document.body ? document.body.scrollHeight : 0,
                document.documentElement ? document.documentElement.scrollHeight : 0,
                window.innerHeight || 800
              );
            }


            function viewportOf(el) {
              if (el) return Math.max(200, el.clientHeight || 800);
              return Math.max(200, window.innerHeight || 800);
            }


            function posOf(el) {
              if (el) return el.scrollTop || 0;
              const se = docScroller();
              const w = typeof window.pageYOffset === 'number' ? window.pageYOffset : 0;
              return Math.max(w, se ? (se.scrollTop || 0) : 0, document.body ? (document.body.scrollTop || 0) : 0);
            }


            function setPos(el, y) {
              const target = Math.max(0, Math.round(y));
              if (el) { el.scrollTop = target; return; }
              try { window.scrollTo({ top: target, behavior: 'instant' }); } catch (e) { /* ignore */ }
              try { window.scrollTo({ top: target, behavior: 'auto' }); } catch (e) { /* ignore */ }
              try { window.scroll(0, target); } catch (e) { /* ignore */ }
              const se = docScroller();
              if (se) se.scrollTop = target;
              if (document.body) document.body.scrollTop = target;
            }


            const START = Date.now();

            const rawInterval = String(args.captureInterval == null ? '' :
            args.captureInterval).replace(/[^0-9]/g, '');

            const INTERVAL = Math.max(0, Math.min(5000, Number(rawInterval) ||
            200));

            const SETTLE = Math.max(30, Math.min(300, Math.round(INTERVAL *
            0.45) || 90));

            const GROW_WAIT = Math.max(0, Number(args.growWait) || 900);


            let state = window.__fpv;

            if (!state || typeof state !== 'object') {
              const vh = viewportOf(null);
              state = { el: null, stride: vh, viewport: vh, total: heightOf(null), frames: 0, startY: 0, abort: false };
              window.__fpv = state;
            }

            if (state.abort === true) return true;

            if (state.el && !document.contains(state.el)) state.el = null;


            const el = state.el || null;

            const stride = Math.max(120, Number(state.stride) ||
            viewportOf(el));


            try {
              state.lastShot = START;
              state.frames = (Number(state.frames) || 0) + 1;

              const before = posOf(el);
              const heightBefore = heightOf(el);
              const want = before + stride;

              setPos(el, want);
              await sleep(SETTLE);
              let after = posOf(el);

              // The scroller did not move although there is room left: it may still be animating
              // or re-rendering. Push once more before drawing any conclusion.
              if (after - before < 8 && before < heightOf(el) - viewportOf(el) - 8) {
                setPos(el, want);
                await sleep(Math.max(240, SETTLE));
                after = posOf(el);
              }

              // We look like we are at the bottom. Lazy loaders usually append content only once
              // the bottom is actually reached, so wait for the page to grow.
              if (after - before < 8 && GROW_WAIT > 0) {
                const deadline = Date.now() + GROW_WAIT;
                while (Date.now() < deadline) {
                  await sleep(150);
                  if (heightOf(el) > heightBefore + 8) {
                    setPos(el, want);
                    await sleep(SETTLE);
                    after = posOf(el);
                    if (after - before >= 8) break;
                  }
                }
              }

              state.total = heightOf(el);
              state.viewport = viewportOf(el);

              return (after - before) < 8;
            } catch (e) {
              return true;
            }
          param: atBottom
          timeout: 60000
          onFailure: ''
          silent: true
        - condition: '{{atBottom}} = true'
          label: EXIT AT PAGE BOTTOM
          type: jump
          to: FINISH CAPTURE
        - label: PACE CAPTURE
          type: js
          args: captureInterval
          code: >-
            // FULL PAGE VISION · PACE CAPTURE

            // Last step of the capture loop, and therefore also the landing
            point when a frame

            // grab fails. Three jobs:

            //   1. keep the requested capture rate without paying twice for
            time already spent

            //      scrolling;

            //   2. back off progressively when frames are refused, so a
            throttled run recovers

            //      instead of spinning through the loop budget;

            //   3. give up in a controlled way. After GIVE_UP consecutive
            refusals of the same

            //      position the abort flag is raised. The append step then
            stops throwing and

            //      the scroll step reports "bottom", so the walk ends with
            whatever it has

            //      instead of retrying three hundred times.

            // Chrome enforces its own screenshot rate limit of two calls per
            second and it

            // cannot be raised; this step only controls how fast the command
            asks for frames.

            function sleep(ms) {
              return new Promise(function (r) { setTimeout(r, ms); });
            }


            const GIVE_UP = 6;


            const raw = String(args.captureInterval == null ? '' :
            args.captureInterval).replace(/[^0-9]/g, '');

            const interval = Math.max(0, Math.min(5000, Number(raw) || 200));


            const S = window.__fpv || (window.__fpv = {});

            const advanced = Number(S.frames) || 0;


            // The scroll step increments S.frames. If it did not run since the
            last pause, the

            // previous capture was refused and this is a retry of the same
            position.

            if (S.lastAdvance === advanced) {
              S.retries = (Number(S.retries) || 0) + 1;
            } else {
              S.retries = 0;
              S.lastAdvance = advanced;
            }


            const retries = Number(S.retries) || 0;

            if (retries >= GIVE_UP) S.abort = true;


            const backoff = retries > 0 ? Math.min(4000, 300 * Math.pow(2,
            retries - 1)) : 0;

            const since = S.lastShot ? Date.now() - Number(S.lastShot) :
            interval;

            const waitFor = Math.max(0, interval - since) + backoff;


            if (waitFor > 0) await sleep(waitFor);


            return { waited: waitFor, retries: retries, interval: interval,
            frames: advanced, abort: S.abort === true ? 'yes' : 'no' };
          param: scratch
          timeout: 30000
          onFailure: ''
          silent: true
    - label: AFTER HTML CAPTURE
      type: jump
      to: FINISH CAPTURE
- condition: '{{sourceKind}} = pdf'
  label: PDF CAPTURE PIPELINE
  type: group
  steps:
    - label: ANNOUNCE PDF CAPTURE
      type: say
      message: >-
        📄 PDF · {{scopeNote}} · reading the page count, then photographing
        every page.
    - label: PROBE PDF PAGE COUNT
      type: js
      args: ''
      code: >-
        // FULL PAGE VISION · PROBE PDF PAGE COUNT

        // Asks the viewer how many pages the document has, for free, before a
        vision request is spent

        // on the same question.

        //

        // Why this matters beyond the saved request: when the page count is
        unknown the plan falls

        // back to the chosen ceiling, and the ceiling defaults to three
        hundred. On a twelve page PDF

        // that meant three hundred navigations to pages that do not exist,
        three hundred photographs

        // of the same last page, and thirty vision requests spent reading it. A
        number that is right

        // removes the whole failure mode, and the viewers hand it over if
        asked.

        //

        // Four sources, cheapest and most reliable first. Every one of them is
        wrapped, because a

        // missing viewer must cost nothing but a zero.

        function deepQuery(root, selector, depth) {
          if (!root || depth > 6) return null;
          try {
            const direct = root.querySelector ? root.querySelector(selector) : null;
            if (direct) return direct;
          } catch (e) { /* keep looking */ }
          let kids = [];
          try { kids = root.querySelectorAll ? Array.prototype.slice.call(root.querySelectorAll('*')) : []; } catch (e) { kids = []; }
          for (let i = 0; i < Math.min(kids.length, 400); i++) {
            const sr = kids[i].shadowRoot;
            if (!sr) continue;
            const hit = deepQuery(sr, selector, depth + 1);
            if (hit) return hit;
          }
          return null;
        }


        function digits(value) {
          const m = String(value == null ? '' : value).match(/\d[\d.,\u00a0 ]*/);
          if (!m) return 0;
          const n = Number(m[0].replace(/[^0-9]/g, ''));
          return isFinite(n) ? n : 0;
        }


        const out = { pages: 0, pagesText: '', source: 'none', base: '' };


        // The base URL the page loop navigates against. A PDF opened at a
        bookmark already carries a

        // fragment, and appending a second #page= to it produces an address no
        viewer understands.

        try {
          const href = String(window.location.href || '');
          out.base = href.split('#')[0];
        } catch (e) { out.base = ''; }


        // 1 · PDF.js and every viewer built on it publish the count directly.

        try {
          const app = window.PDFViewerApplication;
          if (app && Number(app.pagesCount) > 0) {
            out.pages = Number(app.pagesCount);
            out.source = 'pdfjs';
          }
        } catch (e) { /* not a PDF.js viewer */ }


        // 2 · The PDF.js toolbar prints it as plain text.

        if (out.pages < 1) {
          try {
            const el = document.getElementById('numPages') || document.querySelector('#numPages,.numPages,[data-l10n-id="page_of_pages"]');
            const n = el ? digits(el.textContent) : 0;
            if (n > 0) { out.pages = n; out.source = 'toolbar'; }
          } catch (e) { /* no toolbar */ }
        }


        // 3 · Chrome's built in viewer keeps its toolbar inside nested shadow
        roots.

        if (out.pages < 1) {
          try {
            const el = deepQuery(document, '#pagelength, viewer-page-selector #pagelength, #pageselector', 0);
            const n = el ? digits(el.textContent) : 0;
            if (n > 0) { out.pages = n; out.source = 'shadow'; }
          } catch (e) { /* no shadow toolbar */ }
        }


        // 4 · Last free resort: the visible "7 / 34" the viewers print
        somewhere on screen.

        if (out.pages < 1) {
          try {
            const text = String(document.body ? document.body.innerText || '' : '').slice(0, 4000);
            const m = text.match(/(?:^|\s)(\d{1,4})\s*(?:\/|of|von|sur|de)\s*(\d{1,4})(?:\s|$)/i);
            const n = m ? Number(m[2]) : 0;
            if (n > 0 && n <= 5000) { out.pages = n; out.source = 'text'; }
          } catch (e) { /* nothing readable */ }
        }


        if (!isFinite(out.pages) || out.pages < 1 || out.pages > 5000) {
          out.pages = 0;
          out.source = 'none';
        }


        // The reading ladder downstream is gated on one slot being empty. A
        probe that found nothing

        // therefore has to hand back an EMPTY string rather than a zero,
        because "0" is not empty and

        // would silently skip the vision fallback it is supposed to trigger.

        out.pagesText = out.pages > 0 ? String(out.pages) : '';


        return out;
      param: pdfProbe
      timeout: 30000
      onFailure: ''
      silent: true
    - condition: '{{pdfProbe.base}}'
      label: ADOPT PDF BASE URL
      type: calc
      func: set
      param: pdfUrl
      value: '{{pdfProbe.base}}'
      format: ''
    - condition: '{{pdfProbe.pages}} > 0'
      label: NOTE VIEWER PAGE COUNT
      type: say
      message: >-
        📄 The viewer reports **{{pdfProbe.pages}}** page(s) — no request was
        needed to find that out.
    - label: SEED PDF PAGE COUNT
      type: calc
      func: set
      param: pdfPageRead
      value: '{{pdfProbe.pagesText}}'
      format: ''
    - condition: '{{pdfPageRead}} ='
      label: READ PDF PAGE COUNT
      type: gpt
      prompt: >-
        Analyze the provided screenshot of a PDF document and determine the
        total number of pages. **Provide *ONLY* the numeral** representing the
        page count as your output. Ensure the number is **strictly unformatted**
        (no backticks, symbols, punctuation, or additional text of any kind).
        Your response must be **exclusively the numeral** (e.g., `5`, `12`,
        `23`) and *nothing else*:


        {{view}}
      param: pdfPageRead
      isolated: true
      silent: true
    - condition: '{{pdfPageRead}} ='
      label: RETRY PDF PAGE COUNT
      type: gpt
      prompt: >-
        Look at the attached screenshot of a PDF and answer with the total
        number of pages in the document — the number printed after the page
        indicator, for example the 34 in `7 / 34`.


        Answer with that numeral alone. No words, no punctuation, no formatting.


        {{view}}
      param: pdfPageRead
      isolated: true
      silent: true
    - condition: '{{pdfPageRead}} ='
      label: NOTE PDF PAGE COUNT FALLBACK
      type: say
      message: >-
        🧭 Neither the viewer nor the vision reading could name the page count,
        so the first **20** pages are photographed. That bound is deliberate:
        without a real count the walk would otherwise navigate to hundreds of
        pages that do not exist and photograph the last one over and over.
    - label: PLAN PDF PAGES
      type: js
      args: pdfPageRead, maxShots, pdfProbe
      code: >-
        // FULL PAGE VISION · PDF SHOT PLAN

        // One screenshot per PDF page. Three sources for the page count, in
        falling order of trust:

        //

        //   1. the viewer itself, read out of the DOM one step earlier - free
        and exact;

        //   2. a vision reading of the first frame - one request, and only when
        the viewer said

        //      nothing;

        //   3. a blind fallback, because a run that cannot count still has to
        produce something.

        //

        // The blind fallback is deliberately NOT the chosen ceiling. The
        ceiling defaults to three

        // hundred, and walking three hundred pages of a document that has
        twelve means three hundred

        // navigations, three hundred photographs of the same clamped last page,
        and thirty vision

        // requests spent reading that same page over and over. Where the count
        is genuinely unknown

        // the plan stops at BLIND_LIMIT pages and the command says so in plain
        words: a bounded,

        // announced partial reading instead of an unbounded silent one.

        const MAX_SHOTS = Math.max(1, Math.min(300, Number(String(args.maxShots
        || '').replace(/[^0-9]/g, '')) || 300));

        const BLIND_LIMIT = 20;


        let probeSource = 'none';

        try {
          const p = args.pdfProbe;
          if (p && typeof p === 'object' && p.source) probeSource = String(p.source);
        } catch (e) { probeSource = 'none'; }


        const raw = String(args.pdfPageRead == null ? '' : args.pdfPageRead);

        const seen = raw.match(/\d+/);

        const counted = seen ? Number(seen[0]) : 0;


        let pages;

        let source;


        if (isFinite(counted) && counted > 0) {
          pages = counted;
          source = probeSource !== 'none' ? 'viewer' : 'vision';
        } else {
          pages = Math.min(MAX_SHOTS, BLIND_LIMIT);
          source = 'blind';
        }


        pages = Math.max(1, Math.min(pages, MAX_SHOTS));


        const plan = [];

        for (let i = 1; i <= pages; i++) plan.push({ n: i, page: i, source:
        source });

        return plan;
      param: frameList
      timeout: 30000
      onFailure: FINISH CAPTURE
      silent: true
    - label: CAPTURE PDF PAGES
      type: loop
      list: frameList
      steps:
        - label: OPEN PDF PAGE
          type: navigate
          url: '{{pdfUrl}}#page={{item.page}}'
          waitForIdle: true
          onFailure: ''
          silent: true
        - label: SETTLE PDF PAGE
          type: wait
          for: custom-delay
          delay: 400
          silent: true
        - label: APPEND PDF FRAME
          type: js
          args: shots, view, maxShots, capturedFrames
          code: >-
            // FULL PAGE VISION · APPEND FRAME

            // Exactly the accumulation of the original command, which is the
            only form proven

            // to deliver several images to one prompt:

            //

            //   arrayValue += ',' + viewValue   ->  a real parameter  ->  used
            in the prompt

            //

            // Two rules follow from that and must not be touched:

            //   * the separator is a COMMA. HARPA splits the stored series on
            commas to resolve

            //     the individual attachments. A newline joined series arrives
            as a single blob

            //     and only one image reaches the model.

            //   * the token travels as a JS ARGUMENT. Interpolating {{view}}
            inside a calc step

            //     coerces the reference to text and breaks it, which surfaces
            as

            //     "Failed to execute 'createImageBitmap'".

            //

            // Retry behaviour is built here, not delegated: a missing frame
            throws on purpose

            // so the loop lands on the pacing step, waits, and retries the SAME
            scroll position

            // on the next pass. The counter step is skipped on that path, so
            the frame count

            // stays exact. After too many refusals in a row the pacing step
            raises the abort

            // flag and this step stops throwing, which lets the walk end
            cleanly instead of

            // grinding through the whole ceiling.

            const LIMIT = Math.max(1, Math.min(300, Number(String(args.maxShots
            || '').replace(/[^0-9]/g, '')) || 300));

            const held = Math.max(0, Number(args.capturedFrames) || 0);


            let arrayValue = args['shots'] || '';

            const viewValue = args.view;


            const S = window.__fpv || (window.__fpv = {});


            if (S.abort === true) return arrayValue;   // clean shutdown, no
            further frames

            if (held >= LIMIT) return arrayValue;      // ceiling reached


            if (!viewValue) {
              S.misses = (Number(S.misses) || 0) + 1;
              throw new Error('frame not available yet');
            }


            S.misses = 0;


            if (arrayValue === '') {
              arrayValue = viewValue;
            } else {
              arrayValue += ',' + viewValue;
            }


            return arrayValue;
          param: shots
          timeout: 60000
          onFailure: PACE PDF CAPTURE
          silent: true
        - label: COUNT CAPTURED PDF FRAME
          type: calc
          func: increment
          param: capturedFrames
          delta: '1'
        - label: PACE PDF CAPTURE
          type: js
          args: captureInterval, capturedFrames
          code: >-
            // FULL PAGE VISION · PACE PDF CAPTURE

            // Last step of the PDF loop, and therefore also the landing point
            when a page grab fails.

            // Three jobs: keep the requested capture rate, back off
            progressively when pages are refused,

            // and give up in a controlled way after GIVE_UP consecutive
            refusals of the same page.

            //

            // It used to detect a refusal by watching window.__fpv.frames, the
            counter the HTML walk's

            // scroll step increments. The PDF pipeline has no scroll step, so
            that counter never moved:

            // from the second page onwards every pass looked like a refusal of
            the same position, the

            // backoff doubled, and after six pages the abort flag went up and
            the append step stopped

            // appending. A PDF longer than seven pages silently lost everything
            after page seven.

            //

            // The honest signal is the frame counter of the run itself. It is
            incremented by the step

            // that follows a SUCCESSFUL append and is skipped when the append
            throws, so an unchanged

            // value means, precisely and only, that the previous page was
            refused.

            function sleep(ms) {
              return new Promise(function (r) { setTimeout(r, ms); });
            }


            const GIVE_UP = 6;


            const raw = String(args.captureInterval == null ? '' :
            args.captureInterval).replace(/[^0-9]/g, '');

            const interval = Math.max(0, Math.min(5000, Number(raw) || 200));

            const captured = Math.max(0, Number(String(args.capturedFrames ==
            null ? '' : args.capturedFrames).replace(/[^0-9]/g, '')) || 0);


            const S = window.__fpv || (window.__fpv = {});


            if (S.lastPdfCount === captured) {
              S.pdfRetries = (Number(S.pdfRetries) || 0) + 1;
            } else {
              S.pdfRetries = 0;
              S.lastPdfCount = captured;
            }


            const retries = Number(S.pdfRetries) || 0;

            if (retries >= GIVE_UP) S.abort = true;


            const backoff = retries > 0 ? Math.min(4000, 300 * Math.pow(2,
            retries - 1)) : 0;

            const since = S.lastShot ? Date.now() - Number(S.lastShot) :
            interval;

            const waitFor = Math.max(0, interval - since) + backoff;


            if (waitFor > 0) await sleep(waitFor);

            S.lastShot = Date.now();


            return { waited: waitFor, retries: retries, interval: interval,
            pages: captured, abort: S.abort === true ? 'yes' : 'no' };
          param: scratch
          timeout: 30000
          onFailure: ''
          silent: true
    - label: RETURN TO FIRST PAGE
      type: navigate
      url: '{{pdfUrl}}'
      waitForIdle: false
      onFailure: ''
      silent: true
    - label: AFTER PDF CAPTURE
      type: jump
      to: FINISH CAPTURE
- label: FINALIZE CAPTURE
  type: group
  steps:
    - label: FINISH CAPTURE
      type: js
      args: sourceKind
      code: >-
        // FULL PAGE VISION · FINISH CAPTURE

        // Returns the page to where the walk began and reports the geometry
        that was really walked,

        // which is the fastest way to tell whether a run covered the page.
        Coverage is measured

        // against the part of the page that was in scope, so a walk that
        deliberately started half

        // way down is not reported as half a failure.

        //

        // A PDF has no walk. Its frames come from navigation, so window.__fpv
        is never set up and

        // every measurement below reads back as zero - which used to make every
        single PDF run end

        // on "only 0 % of the page height was walked" plus a diagnostics line
        of zeroes. Coverage is

        // not a meaningful question there: the plan names the pages and the
        loop photographs them.

        // So the mode is named, the geometry is reported as complete, and the
        warnings that hang off

        // coverage simply do not fire.

        function docScroller() {
          return document.scrollingElement || document.documentElement || document.body;
        }


        const kind = String(args.sourceKind ||
        'html').toLowerCase().indexOf('pdf') === 0 ? 'pdf' : 'html';

        const state = window.__fpv || {};

        const el = (state.el && document.contains(state.el)) ? state.el : null;


        const out = {
          ok: 'true',
          mode: kind,
          total: Number(state.total) || 0,
          viewport: Number(state.viewport) || (window.innerHeight || 0),
          stride: Number(state.stride) || 0,
          walked: Number(state.frames) || 0,
          startY: Number(state.startY) || 0,
          aborted: state.abort === true ? 'yes' : 'no',
          coverage: 0,
          scroller: el ? 'element' : 'window'
        };


        if (kind === 'pdf') {
          out.coverage = 100;
          out.scroller = 'document';
          return out;
        }


        try {
          const home = out.startY;
          if (el) {
            el.scrollTop = home;
          } else {
            try { window.scrollTo({ top: home, behavior: 'instant' }); } catch (e) { /* ignore */ }
            try { window.scrollTo({ top: home, behavior: 'auto' }); } catch (e) { /* ignore */ }
            try { window.scroll(0, home); } catch (e) { /* ignore */ }
            const se = docScroller();
            if (se) se.scrollTop = home;
            if (document.body) document.body.scrollTop = home;
          }
          const inScope = Math.max(1, out.total - out.startY);
          const covered = out.stride > 0 && out.walked > 0
            ? (out.walked - 1) * out.stride + out.viewport
            : 0;
          out.coverage = Math.max(0, Math.min(100, Math.round(100 * covered / inScope)));
          await new Promise(function (r) { setTimeout(r, 150); });
        } catch (e) {
          out.ok = 'false';
        }


        return out;
      param: geometry
      timeout: 30000
      onFailure: ''
      silent: true
    - label: MEASURE FRAME SERIES
      type: js
      args: shots, capturedFrames
      code: >-
        // FULL PAGE VISION · MEASURE FRAME SERIES

        // The single source of truth for "how many frames do we actually hold".
        The engine

        // side counter can drift by one when the walk shuts itself down, so the
        number is

        // recomputed from the stored series instead of being trusted.

        //

        // A frame token is opaque. Should it contain a comma of its own, a
        naive split would

        // tear it apart and multiply the count. Two independent reconstructions
        are used:

        //   1. if the tokens are data URLs, the boundaries are visible and
        exact;

        //   2. otherwise, if the number of comma parts is an exact multiple of
        the counted

        //      frames, that multiple is the number of parts per token;

        //   3. otherwise one part is one token.

        const raw = String(args.shots == null ? '' : args.shots);

        const counted = Math.max(0, Number(args.capturedFrames) || 0);


        const parts = raw
          .split(',')
          .map(function (s) { return s.trim(); })
          .filter(function (s) { return s.length > 0; });

        let frames = [];

        let method = 'one-part-per-frame';


        const looksLikeDataUrl = parts.some(function (p) { return
        /^data:/i.test(p); });


        if (counted > 0 && parts.length > counted && parts.length % counted ===
        0) {
          method = 'derived-parts-per-frame';
          const per = parts.length / counted;
          for (let i = 0; i < parts.length; i += per) frames.push(parts.slice(i, i + per).join(','));
        } else if (looksLikeDataUrl) {
          method = 'data-url-boundaries';
          let current = [];
          for (let i = 0; i < parts.length; i++) {
            if (/^data:/i.test(parts[i]) && current.length > 0) {
              frames.push(current.join(','));
              current = [];
            }
            current.push(parts[i]);
          }
          if (current.length > 0) frames.push(current.join(','));
        } else {
          frames = parts.slice();
        }


        // A request that carries ten dense screenshots takes roughly twenty to
        fifty seconds. Saying

        // so at the gate is what separates "this is working" from "this has
        hung", and it costs

        // nothing to say.

        const size = 10;

        const requests = Math.ceil(frames.length / size);

        return {
          frames: frames.length,
          parts: parts.length,
          counted: counted,
          requests: requests,
          minLo: Math.max(1, Math.round(requests * 20 / 60)),
          minHi: Math.max(2, Math.round(requests * 50 / 60)),
          method: method,
          ok: frames.length > 0 ? 'yes' : 'no'
        };
      param: series
      timeout: 30000
      onFailure: ''
      silent: true
    - label: ADOPT FRAME COUNT
      type: calc
      func: set
      param: shotCount
      value: '{{series.frames}}'
      format: ''
    - label: SPLIT INTO BATCHES
      type: js
      args: shots, batchSize, shotCount
      code: >-
        // FULL PAGE VISION · BATCH SPLITTER

        // A chat request carries up to ten images, so the comma separated frame
        series is cut

        // into groups of ten and each group is read by its own vision call. The
        token

        // boundaries are reconstructed exactly the way the measuring step
        reconstructs them,

        // so both steps always agree on what a frame is.

        const SIZE = Math.max(1, Math.min(10, Number(args.batchSize) || 10));

        const counted = Math.max(0, Number(String(args.shotCount ||
        '').replace(/[^0-9]/g, '')) || 0);

        const raw = String(args.shots == null ? '' : args.shots);


        const parts = raw
          .split(',')
          .map(function (s) { return s.trim(); })
          .filter(function (s) { return s.length > 0; });

        // Order matters and follows the original: the engine side counter says
        how many frames were

        // really taken, so the number of comma parts each token occupies is
        DERIVED from it. Only when

        // that derivation does not apply is the visible shape of the token used
        as a fallback.

        let frames = [];

        const looksLikeDataUrl = parts.some(function (p) { return
        /^data:/i.test(p); });


        if (counted > 0 && parts.length > counted && parts.length % counted ===
        0) {
          const per = parts.length / counted;
          for (let i = 0; i < parts.length; i += per) frames.push(parts.slice(i, i + per).join(','));
        } else if (looksLikeDataUrl) {
          let current = [];
          for (let i = 0; i < parts.length; i++) {
            if (/^data:/i.test(parts[i]) && current.length > 0) {
              frames.push(current.join(','));
              current = [];
            }
            current.push(parts[i]);
          }
          if (current.length > 0) frames.push(current.join(','));
        } else {
          frames = parts.slice();
        }


        const batches = [];

        for (let i = 0; i < frames.length; i += SIZE) {
          batches.push(frames.slice(i, i + SIZE).join(','));
        }

        return batches;
      param: batches
      timeout: 30000
      onFailure: ''
      silent: true
    - label: BUILD PROMPT HEADER
      type: js
      args: title, url, shotCount, batches
      code: >-
        // FULL PAGE VISION · PROMPT HEADER

        // The three lines that sit above every answer prompt. Two of them - the
        page title and the

        // address - come from the page itself, and page text is exactly what
        has to be armoured

        // before it is interpolated into a prompt: a title that reads "How
        {{page}} works" would

        // otherwise make the engine extract the whole page again, once per
        occurrence, for every

        // answer that carries the header. The record has been armoured since
        the first version; the

        // header had not been, and it is built from the same kind of untrusted
        text.

        function harden(value) {
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        function clean(value) {
          return harden(String(value == null ? '' : value).replace(/\s+/g, ' ').trim());
        }


        const title = clean(args.title) || 'untitled page';

        const url = clean(args.url) || 'unknown source';

        const frames = String(args.shotCount == null ? '' :
        args.shotCount).replace(/[^0-9]/g, '') || '0';


        let segments = 0;

        try {
          const b = args.batches;
          segments = b && typeof b.length === 'number' ? b.length : 0;
        } catch (e) { segments = 0; }


        return [
          '[PAGE TITLE]: ' + title,
          '[SOURCE]: ' + url,
          '[FRAMES READ]: ' + frames + ' screenshots in ' + segments + ' segment(s)'
        ].join('\n');
      param: promptHeader
      timeout: 30000
      onFailure: ''
      silent: true
    - label: BUILD GROUND RULE
      type: calc
      func: set
      param: groundRule
      value: >-
        The [EVIDENCE] below is the frame-by-frame reading of the whole page,
        produced by looking at every screenshot. It is the only permitted source
        of facts. Never add anything it does not contain. Regions the record
        marks as `[unreadable]` or as `NOT READ` are gaps, not facts: name them
        as missing instead of filling them in.
      format: ''
    - label: BUILD CONSTRUCTION RULE
      type: calc
      func: set
      param: buildRule
      value: >-
        The [EVIDENCE] below is the frame-by-frame reading of the whole page,
        written by looking at every screenshot. It is your only source for what
        the page CONTAINS: its words, its numbers, its pictures, its structure,
        and the typefaces, sizes, colours and spacing it describes.


        It is not the source of the FORM your answer takes. When you are asked
        to build something - HTML, CSS, code, a table, a document, a translation
        - then writing the markup, the syntax and the scaffolding that carries
        the content is construction, not invention. That is the work, and you
        are expected to do it in full. Only the content inside it has to come
        from the record.
      format: ''
    - label: REPORT CAPTURE
      type: say
      message: 🖼️ **{{shotCount}}** frames · **{{batches.length}}** segment(s)
    - condition:
        - '{{geometry.coverage}} < 95'
        - '{{geometry.aborted}} = yes'
      label: REPORT GEOMETRY
      type: say
      message: >-
        🔎 Diagnostics ({{geometry.mode}}): {{geometry.total}} px tall,
        {{geometry.viewport}} px viewport, exact step {{geometry.stride}} px
        ({{geometry.pinnedTop}} px pinned at the top, {{geometry.pinnedBottom}}
        px at the bottom), scroller {{geometry.scroller}}, coverage
        {{geometry.coverage}} %.
    - condition: '{{geometry.coverage}} < 95'
      label: WARN LOW COVERAGE
      type: say
      message: >-
        ⚠️ Only {{geometry.coverage}} % of the page height was walked. Frames
        may have been refused by the browser — the record below can have holes.
    - condition: '{{shotCount}} = {{maxShots}}'
      label: WARN CEILING REACHED
      type: say
      message: >-
        ♾️ The chosen ceiling of {{maxShots}} frames was reached, so the source
        probably continues beyond it. Run the command again with a larger scope
        — on a web page you can also set START POINT to HERE to carry on from
        where this run stopped.
    - condition: '{{capture.ok}} = no'
      label: WARN PREPARATION FAILED
      type: say
      message: >-
        ⚠️ The page could not be prepared for the walk ({{capture.error}}), so
        the frames may be uneven. What was captured is still usable.
    - condition: '{{geometry.aborted}} = yes'
      label: WARN CAPTURE ABORTED
      type: say
      message: >-
        🛑 The walk stopped early because the browser refused several frames in
        a row. What was captured up to that point is intact.
- condition: '{{shotCount}} = 0'
  label: ABORT NOTICE
  type: say
  message: >-
    ⛔ No frames could be captured. The connection may not support vision, or
    this tab may refuse screenshots. Try a normal web page with a vision-capable
    model.
- condition: '{{shotCount}} = 0'
  label: STOP ON EMPTY CAPTURE
  type: stop
- condition: '{{batches.length}} > 1'
  label: CONFIRM VISION READING
  type: group
  steps:
    - label: CONFIRM READING
      type: ask
      message: >-
        Read all **{{shotCount}}** frames now? That is **{{batches.length}}**
        vision request(s), roughly **{{series.minLo}}–{{series.minHi}}
        minutes**. Every answer afterwards costs one more; the frames are read
        only once.
      param: proceed
      options:
        - label: ▶️ CONTINUE · read the frames
          value: continue
        - label: ⛔ CANCEL · stop without spending a request
          value: cancel
      default: continue
      vision:
        enabled: false
        mode: area
        hint: ''
        send: true
      optionsInvalid: false
    - condition: '{{proceed}} = cancel'
      label: CANCEL VISION READING
      type: jump
      to: READING CANCELLED
- condition: '{{batches.length}} = 1'
  label: NOTE SINGLE REQUEST
  type: say
  message: 💳 One request covers this page.
- label: CONFIGURE READING INSTRUCTIONS
  type: group
  steps:
    - label: SELECT READING INSTRUCTIONS
      type: ask
      message: >-
        Any instructions for the reading?


        SKIP reads the whole page in full detail. Anything you type takes
        precedence over that, and is carried out on the finished record as well.
      param: focusChoice
      options:
        - label: ⏭️ SKIP · read everything, in as much detail as possible
          value: skip
        - value: $custom
      default: skip
      vision:
        enabled: false
        mode: area
        hint: ''
        send: true
      optionsInvalid: false
    - label: BUILD READING INSTRUCTIONS
      type: js
      args: focusChoice
      code: >-
        // FULL PAGE VISION · READING INSTRUCTIONS

        // One answer, given before a single frame is read, and it is an
        INSTRUCTION rather than a

        // choice from a list. There is nothing to pick because there is nothing
        to narrow down: the

        // reading always extracts everything it can see, in the greatest detail
        it can manage. That

        // is the floor, not an option.

        //

        // SKIP means exactly that floor and nothing added.

        //

        // Anything typed keeps the whole floor - the same completeness, the
        same detail, the same

        // record format - and is laid on top of it with PRECEDENCE. Precedence
        is the point and it

        // cuts both ways:

        //   * "rebuild this page one to one, design and fonts included" makes
        the reading write down

        //     typefaces, sizes, colours and spacing it would otherwise only
        have implied;

        //   * "do not extract the images, text only" makes it stop describing
        pictures, and that is

        //     allowed to override the format above, because the person reading
        the record said so.

        //

        // This is why the instruction is asked HERE and not after the reading.
        The reading is the

        // only step that ever sees the pixels. Everything downstream works from
        the words it wrote,

        // and a picture that was never described cannot be described later,
        however precisely it is

        // asked for.

        function harden(value) {
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        const raw = String(args.focusChoice == null ? '' : args.focusChoice);

        const flat = raw.replace(/\s+/g, ' ').trim();


        const out = { active: 'no', answer: 'no', read: '', request: '' };


        if (flat === '' || flat.toLowerCase() === 'skip') return out;


        // The user's own words, used as written rather than interpreted,
        armoured like any other

        // text that reaches a prompt, and capped so a pasted essay cannot crowd
        the reading out of

        // the model's attention. Line breaks are kept: an instruction is often
        a small list.

        const instruction = harden(raw.replace(/\r/g, '').trim().slice(0,
        1200));


        out.active = 'yes';

        out.answer = 'yes';

        out.request = instruction;

        out.read = [
          '[INSTRUCTIONS · HIGHEST PRIORITY]',
          'Everything above still holds: the same completeness, the same detail, the same record format.',
          'On top of it, and ahead of it, the person this record is being written for has asked for the following. Where it asks for more than the format above, go further. Where it contradicts the format above, IT WINS - if it says to leave something out, leave it out; if it says to record something the format does not mention, record it anyway.',
          '',
          instruction,
          ''
        ].join('\n');


        return out;
      param: focus
      timeout: 30000
      onFailure: ''
      silent: true
    - label: ARM THE INSTRUCTION ANSWER
      type: calc
      func: set
      param: pendingAnswer
      value: '{{focus.answer}}'
      format: ''
    - condition: '{{focus.active}} = yes'
      label: NOTE READING INSTRUCTIONS
      type: say
      message: >-
        🎯 Your instructions are steering the reading and will be carried out on
        the finished record.
- label: READ SCREENSHOT BATCHES
  type: group
  steps:
    - label: ANNOUNCE BATCH READING
      type: say
      message: 👁️ Reading {{batches.length}} segment(s)…
    - label: START SEGMENT COUNTER
      type: calc
      func: set
      param: segment
      value: '0'
      format: ''
    - label: BATCH READING LOOP
      type: loop
      list: batches
      steps:
        - label: MATERIALIZE BATCH
          type: calc
          func: set
          param: batchImages
          value: '{{item}}'
          format: ''
        - label: COUNT SEGMENT
          type: calc
          func: increment
          param: segment
          delta: '1'
        - label: COMPUTE SEGMENT RANGE
          type: js
          args: segment, batchSize, shotCount
          code: >-
            // FULL PAGE VISION · SEGMENT RANGE

            // Which frames a segment is made of. The record marker carries this
            range, so a citation

            // that says "segment 3" also says "frames 21 to 30" - and the
            repair step knows exactly

            // where on the page it has to go back to when a segment could not
            be read.

            const SIZE = Math.max(1, Math.min(10, Number(args.batchSize) ||
            10));

            const seg = Math.max(1, Number(String(args.segment ||
            '').replace(/[^0-9]/g, '')) || 1);

            const total = Math.max(0, Number(String(args.shotCount ||
            '').replace(/[^0-9]/g, '')) || 0);


            const from = (seg - 1) * SIZE + 1;

            const to = total > 0 ? Math.min(total, seg * SIZE) : seg * SIZE;

            const count = Math.max(0, to - from + 1);


            return { from: from, to: to, count: count, size: SIZE, label:
            'frames ' + from + '-' + to };
          param: range
          timeout: 30000
          onFailure: ''
          silent: true
        - label: RESET REPAIR SERIES
          type: calc
          func: set
          param: repairImages
          value: ''
          format: ''
        - label: RESET REPAIR PLAN
          type: calc
          func: set
          param: repairPlan
          value: ''
          format: ''
        - label: RESET REPAIR COUNT FOR SEGMENT
          type: calc
          func: set
          param: repairCount
          value: ''
          format: ''
        - label: CLEAR SEGMENT SLOT
          type: calc
          func: set
          param: segmentNotes
          value: ''
          format: ''
        - label: CLEAR RAW READING
          type: calc
          func: set
          param: rawSegment
          value: ''
          format: ''
        - label: READ SEGMENT
          type: gpt
          prompt: >-
            Please ignore all previous instructions. Write only in
            {{outputLanguage}}.


            Attached are {{range.count}} screenshots of one web page, in order,
            top to bottom. They are frames {{range.from}} to {{range.to}} of
            {{shotCount}}. Consecutive frames are exact one-screen steps and do
            **not** overlap, so they fit together edge to edge like the pages of
            a book.


            You are the only reader of these pixels. Turn them into a complete,
            faithful record.


            [OUTPUT SKELETON - follow it exactly, it is not a suggestion]


            Write one block per screenshot, in order. Each block begins with a
            heading of exactly this shape:


            #### Frame N


            N is the absolute frame number. The first attached image is Frame
            {{range.from}}, the second is Frame {{range.from}} plus one, and the
            last is Frame {{range.to}}. Never restart the count at 1. Never
            write Panel, Image, Screenshot, Slide, Page or Figure in place of
            Frame. Produce exactly {{range.count}} such headings, one per
            attached image, even if an image is blank.


            Under each heading write bullet lines only. Every bullet starts with
            one of these eight labels and nothing else:


            - **Heading:** a heading or title as it appears on screen

            - **Text:** any writing - body copy, list items, captions, labels,
            footnotes, speech bubbles, narration boxes, sound effects, system
            messages. Name the kind in the line when it matters, for example:
            speech bubble - "TAKE MY HEART."

            - **Table:** followed by a markdown table on the next lines

            - **Data:** numbers, prices, dates, scores, ratings, measurements,
            chart values, with their units

            - **Visual:** a photograph, illustration, icon, logo, avatar, chart,
            diagram, map or screenshot, and what it depicts and conveys

            - **UI:** buttons, links, tabs, menus, form fields, navigation,
            pagination

            - **Cut:** an element that is sliced by the frame edge and continues
            in the next frame

            - **Gap:** [unreadable] - anything cut off, blurred or illegible


            [RULES]

            - Detail is the entire point of this record. It is the only thing
            anyone will have afterwards; the screenshots are discarded once you
            are done. Wherever you can choose between a short description and a
            thorough one, write the thorough one.

            - Describe every picture the way you would for someone who cannot
            see it: what it shows, how it is composed, its colours and its
            style, any writing inside it, and what it is there to communicate.
            An illustration, a photograph, an avatar, an icon that carries
            meaning, a chart, a diagram, a map, a screenshot - each one gets its
            own line and its own description, never a label like "an image".

            - Record the visual form alongside the content wherever the form
            carries meaning: how large a heading is against the body text around
            it, which typeface it appears to be set in, the colours of text,
            backgrounds, borders and buttons, how much space separates the
            blocks, and how the blocks are arranged across the screen.

            - Transcribe all visible text verbatim. Keep numbers, units, dates
            and proper nouns exactly as shown: never round, never normalise,
            never translate them.

            - Read charts off the pixels: name the axes, the series and every
            value you can see.

            - Keep the reading order and the visual hierarchy of each frame.

            - The frames do not overlap, so nothing in them is a duplicate. A
            sentence, a table row or an image that is cut off at the bottom edge
            of one frame continues at the top of the next: note it once with
            **Cut:** and write the joined, complete version in the frame where
            it finishes.

            - Bars pinned to the screen - navigation, cookie banners, sticky
            footers, chat bubbles, a series or chapter title reprinted above
            every screen - do come back on every frame. Record each of them
            once, in the first frame it appears in, and ignore it afterwards.

            - Never invent anything you cannot see.

            - Do not write a document title, a source line, a preamble, a
            summary or a closing remark. The record already has a header. Start
            with `#### Frame {{range.from}}` and end with the last bullet of
            `#### Frame {{range.to}}`.


            {{focus.read}}

            [SCREENSHOTS]:

            {{batchImages}}


            [RECORD]:
          param: rawSegment
          isolated: true
          silent: true
        - label: SHAPE THE READING
          type: js
          args: rawSegment, segment, batchSize, shotCount
          code: >-
            // FULL PAGE VISION · SHAPE SEGMENT

            // The record has to look the same in every segment, because
            everything downstream reads

            // it: the citations, the rebuild, the data extraction, the export.
            Left to itself a model

            // invents a new layout per call - one segment comes back as "###
            [Panel 1]", the next as a

            // flat bullet list with no headings at all, the next as "[FRAME 1]"
            - and every one of them

            // starts counting at one again, so "frame 3" means three different
            places in one record.

            //

            // The prompt prescribes the shape. This step enforces it,
            deterministically and for free:

            //   * every heading variant a model reaches for is recognised and
            rewritten to `#### Frame N`

            //   * N is the ABSOLUTE frame number, so frame 47 is called frame
            47 in every segment

            //   * the bullet labels are folded into one closed vocabulary, with
            the original wording

            //     preserved inside the line so nothing is lost

            //   * a document title or source line that the model reprinted per
            segment is dropped,

            //     because the record already carries one at the top

            //

            // THIS STEP MAY NEVER REFUSE A READING.

            //

            // It used to have a strict mode that returned nothing when the
            model had not produced exactly

            // one block per screenshot, on the theory that one more attempt
            would come back tidier. That

            // was a serious mistake: the shape of the record is cosmetic, the
            reading is what costs money,

            // and wiring the cheap thing to the expensive one turned a
            formatting nicety into a gate on

            // the whole ladder. A model that writes twenty panels instead of
            ten frames is not a failed

            // reading - it is a reading that needs renumbering, which happens
            here for free. With a forty

            // percent shape-deviation rate measured on real output, thirty
            segments became sixty-six model

            // calls instead of thirty, every one of them silent, plus a fresh
            ten-frame capture each time.

            // From the outside that is indistinguishable from a hang.

            //

            // So: whatever comes in, something usable comes out. Too many
            blocks are mapped onto the

            // frames proportionally, too few are numbered from the start of the
            segment and the shortfall

            // is stated, none at all are filed under the segment's frame span.
            The only empty answer is

            // for empty input, which is what the reading ladder above actually
            needs to know - and the

            // whole body is wrapped so that even an unforeseen error hands the
            raw reading back intact

            // rather than costing a request.

            function harden(value) {
              // Text lifted off a page can contain the engine's own {{parameter}} syntax. Left as it is,
              // it would be executed the next time this text is interpolated - fetching pages, running
              // searches - once per occurrence and once per answer. The documented escape makes the
              // braces literal without changing how the text reads. Idempotent by design.
              return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
            }


            const SIZE = Math.max(1, Math.min(10, Number(args.batchSize) ||
            10));

            const seg = Math.max(1, Number(String(args.segment ||
            '').replace(/[^0-9]/g, '')) || 1);

            const totalFrames = Math.max(0, Number(String(args.shotCount ||
            '').replace(/[^0-9]/g, '')) || 0);


            const from = (seg - 1) * SIZE + 1;

            const to = totalFrames > 0 ? Math.min(totalFrames, seg * SIZE) : seg
            * SIZE;

            const want = Math.max(1, to - from + 1);


            const original = String(args.rawSegment == null ? '' :
            args.rawSegment).replace(/\r/g, '').trim();

            if (original === '') return '';


            let raw = original;

            try {


            // A model that is told "do not write a preamble" still sometimes
            writes one. Fenced output,

            // a repeated document title, a source line and a horizontal rule at
            the very top are all

            // duplicates of information the record already holds once.

            raw = raw.replace(/^```[a-z]*\s*\n/i, '').replace(/\n```\s*$/i, '');

            const lead = raw.split('\n');

            while (lead.length > 0) {
              const line = lead[0].trim();
              const drop =
                line === '' ||
                line === '---' ||
                /^#{1,3}\s+\S/.test(line) && lead.length > 3 && !/frame|panel|image|screenshot|slide|shot/i.test(line) ||
                /^[-*]?\s*\**\s*(source|url|page|title|link)\s*\**\s*:/i.test(line) ||
                /^(here (is|are)|below (is|are)|sure[,!.]|certainly[,!.]|i('| wi)ll |this (is|segment))/i.test(line);
              if (!drop) break;
              lead.shift();
            }

            raw = lead.join('\n').trim();

            if (raw === '') return '';


            // Canonical bullet labels. The original wording is kept inside the
            line, so a speech bubble

            // is still recognisable as a speech bubble - it is simply filed
            under Text like every other

            // piece of transcribed writing.

            const LABELS = [
              ['Heading', /^(heading|title|header|section title|chapter title)$/i, false],
              ['Table', /^(table|grid|matrix|comparison)$/i, false],
              ['Data', /^(data|numbers?|values?|stats?|statistics|metrics?|price|prices|pricing|score|scores|rank(ing)?s?)$/i, false],
              ['UI', /^(ui|ui element|navigation|navigation ui|nav|nav bar|navbar|menu|button|buttons|link|links|form|form field|input|control|controls|tab|tabs|toolbar|footer|footer nav|breadcrumb|pagination)$/i, false],
              ['Visual', /^(visual|illustration|image|picture|photo|photograph|art|artwork|drawing|panel art|graphic|icon|logo|avatar|chart|diagram|figure|screenshot|map)$/i, true],
              ['Text', /^(text|speech bubble|speech|dialogue|dialog|narration|narration box|narrative box|text box|textbox|caption|body|body text|paragraph|quote|sound effect|sfx|system text|system window|system message|label|note|footnote|banner|banner box|top text box|bottom banner box|notification)$/i, true],
              ['Cut', /^(cut|continues|continued|continuation|bridged|carry over|spans)$/i, false],
              ['Gap', /^(gap|unreadable|illegible|blank|empty|missing|cut off)$/i, false]
            ];


            function normaliseLine(line) {
              const m = line.match(/^(\s*)([-*+]|\d+[.)])\s+\**\s*([A-Za-z][A-Za-z /_-]{1,40}?)\s*\**\s*:\s*\**\s*(.*)$/);
              if (!m) return line;
              const indent = m[1];
              const bullet = /^\d/.test(m[2]) ? '-' : '-';
              const rawLabel = m[3].trim();
              let rest = m[4];
              for (let i = 0; i < LABELS.length; i++) {
                if (!LABELS[i][1].test(rawLabel)) continue;
                const canonical = LABELS[i][0];
                const keepOriginal = LABELS[i][2] && rawLabel.toLowerCase() !== canonical.toLowerCase();
                const prefix = keepOriginal ? rawLabel.toLowerCase() + ' — ' : '';
                return indent + bullet + ' **' + canonical + ':** ' + prefix + rest.trim();
              }
              return indent + bullet + ' **' + rawLabel + ':** ' + rest.trim();
            }


            // Every heading shape a model reaches for, in one expression.

            const HEAD =
            /^[\s>*_]*(?:#{1,6})?[\s*_]*\[?\s*(?:frame|panel|image|screenshot|screen|slide|page|shot|fig(?:ure)?)\s*[#:\-]?\s*(\d+)\s*\]?\s*[:.\-]?[\s*_]*$/i;


            const lines = raw.split('\n');

            const blocks = [];

            let current = null;

            let preamble = [];


            for (let i = 0; i < lines.length; i++) {
              const m = lines[i].match(HEAD);
              if (m) {
                current = { body: [] };
                blocks.push(current);
                continue;
              }
              if (current === null) preamble.push(lines[i]);
              else current.body.push(lines[i]);
            }


            if (blocks.length === 0) {
              // No frame boundaries at all. The content is intact, only the per-frame granularity is
              // gone, so it is filed under the segment's frame span and marked as such rather than
              // being silently renumbered into something that would be wrong.
              const body = raw.split('\n').map(function (l) { return normaliseLine(l); }).join('\n');
              return harden('#### Frames ' + from + '-' + to + '\n' +
                     '- **Gap:** the reading did not separate these ' + want + ' frames; the content below covers all of them together.\n' +
                     body.trim() + '\n');
            }


            if (preamble.join('').trim() !== '') {
              blocks[0].body = preamble.concat(blocks[0].body);
            }


            // Mapping blocks onto frames. Equal counts is the normal case and
            maps one to one. More

            // blocks than images means the model cut the screenshots into
            panels: they are distributed

            // proportionally, so panels eleven and twelve of twenty land on
            frame six rather than all

            // surplus being dumped on the last one. Fewer blocks means it
            merged some: they are numbered

            // from the start of the segment and the shortfall is stated instead
            of being papered over.

            const out = [];

            let lastNumber = -1;

            for (let i = 0; i < blocks.length; i++) {
              const number = blocks.length > want
                ? Math.min(to, from + Math.floor(i * want / blocks.length))
                : Math.min(to, from + i);
              const body = blocks[i].body
                .map(function (l) { return normaliseLine(l); })
                .join('\n')
                .replace(/\n{3,}/g, '\n\n')
                .trim();
              if (number === lastNumber && out.length > 0) {
                if (body) out[out.length - 1] += '\n' + body;
                continue;
              }
              lastNumber = number;
              out.push('#### Frame ' + number + '\n' + (body || '- **Gap:** nothing legible in this frame.'));
            }


            if (blocks.length < want) {
              out.push('- **Gap:** the reading separated ' + blocks.length + ' of the ' + want + ' frames in this segment; the rest were merged into the blocks above.');
            }


            return harden(out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim() +
            '\n');

            } catch (e) {
              // Shaping is a convenience. If anything in it goes wrong the reading still has to reach the
              // record, hardened, rather than being reported as a failure that costs another request.
              return harden('#### Frames ' + from + '-' + to + '\n' +
                            '- **Gap:** the reading could not be shaped (' + String((e && e.message) || e) +
                            '); it is filed here unchanged.\n' + original);
            }
          param: segmentNotes
          timeout: 30000
          onFailure: ''
          silent: true
        - condition: '{{segmentNotes}} ='
          label: CLEAR RAW BEFORE RETRY
          type: calc
          func: set
          param: rawSegment
          value: ''
          format: ''
        - condition: '{{segmentNotes}} ='
          label: RETRY SEGMENT
          type: gpt
          prompt: >-
            Please ignore all previous instructions. Write only in
            {{outputLanguage}}.


            Attached are {{range.count}} screenshots of one web page, in order,
            top to bottom. They are frames {{range.from}} to {{range.to}} of
            {{shotCount}}. Consecutive frames are exact one-screen steps and do
            **not** overlap, so they fit together edge to edge like the pages of
            a book.


            You are the only reader of these pixels. Turn them into a complete,
            faithful record.


            [OUTPUT SKELETON - follow it exactly, it is not a suggestion]


            Write one block per screenshot, in order. Each block begins with a
            heading of exactly this shape:


            #### Frame N


            N is the absolute frame number. The first attached image is Frame
            {{range.from}}, the second is Frame {{range.from}} plus one, and the
            last is Frame {{range.to}}. Never restart the count at 1. Never
            write Panel, Image, Screenshot, Slide, Page or Figure in place of
            Frame. Produce exactly {{range.count}} such headings, one per
            attached image, even if an image is blank.


            Under each heading write bullet lines only. Every bullet starts with
            one of these eight labels and nothing else:


            - **Heading:** a heading or title as it appears on screen

            - **Text:** any writing - body copy, list items, captions, labels,
            footnotes, speech bubbles, narration boxes, sound effects, system
            messages. Name the kind in the line when it matters, for example:
            speech bubble - "TAKE MY HEART."

            - **Table:** followed by a markdown table on the next lines

            - **Data:** numbers, prices, dates, scores, ratings, measurements,
            chart values, with their units

            - **Visual:** a photograph, illustration, icon, logo, avatar, chart,
            diagram, map or screenshot, and what it depicts and conveys

            - **UI:** buttons, links, tabs, menus, form fields, navigation,
            pagination

            - **Cut:** an element that is sliced by the frame edge and continues
            in the next frame

            - **Gap:** [unreadable] - anything cut off, blurred or illegible


            [RULES]

            - Detail is the entire point of this record. It is the only thing
            anyone will have afterwards; the screenshots are discarded once you
            are done. Wherever you can choose between a short description and a
            thorough one, write the thorough one.

            - Describe every picture the way you would for someone who cannot
            see it: what it shows, how it is composed, its colours and its
            style, any writing inside it, and what it is there to communicate.
            An illustration, a photograph, an avatar, an icon that carries
            meaning, a chart, a diagram, a map, a screenshot - each one gets its
            own line and its own description, never a label like "an image".

            - Record the visual form alongside the content wherever the form
            carries meaning: how large a heading is against the body text around
            it, which typeface it appears to be set in, the colours of text,
            backgrounds, borders and buttons, how much space separates the
            blocks, and how the blocks are arranged across the screen.

            - Transcribe all visible text verbatim. Keep numbers, units, dates
            and proper nouns exactly as shown: never round, never normalise,
            never translate them.

            - Read charts off the pixels: name the axes, the series and every
            value you can see.

            - Keep the reading order and the visual hierarchy of each frame.

            - The frames do not overlap, so nothing in them is a duplicate. A
            sentence, a table row or an image that is cut off at the bottom edge
            of one frame continues at the top of the next: note it once with
            **Cut:** and write the joined, complete version in the frame where
            it finishes.

            - Bars pinned to the screen - navigation, cookie banners, sticky
            footers, chat bubbles, a series or chapter title reprinted above
            every screen - do come back on every frame. Record each of them
            once, in the first frame it appears in, and ignore it afterwards.

            - Never invent anything you cannot see.

            - Do not write a document title, a source line, a preamble, a
            summary or a closing remark. The record already has a header. Start
            with `#### Frame {{range.from}}` and end with the last bullet of
            `#### Frame {{range.to}}`.


            {{focus.read}}

            [SCREENSHOTS]:

            {{batchImages}}


            [RECORD]:
          param: rawSegment
          isolated: true
          silent: true
        - condition: '{{segmentNotes}} ='
          label: SHAPE THE RETRY
          type: js
          args: rawSegment, segment, batchSize, shotCount
          code: >-
            // FULL PAGE VISION · SHAPE SEGMENT

            // The record has to look the same in every segment, because
            everything downstream reads

            // it: the citations, the rebuild, the data extraction, the export.
            Left to itself a model

            // invents a new layout per call - one segment comes back as "###
            [Panel 1]", the next as a

            // flat bullet list with no headings at all, the next as "[FRAME 1]"
            - and every one of them

            // starts counting at one again, so "frame 3" means three different
            places in one record.

            //

            // The prompt prescribes the shape. This step enforces it,
            deterministically and for free:

            //   * every heading variant a model reaches for is recognised and
            rewritten to `#### Frame N`

            //   * N is the ABSOLUTE frame number, so frame 47 is called frame
            47 in every segment

            //   * the bullet labels are folded into one closed vocabulary, with
            the original wording

            //     preserved inside the line so nothing is lost

            //   * a document title or source line that the model reprinted per
            segment is dropped,

            //     because the record already carries one at the top

            //

            // THIS STEP MAY NEVER REFUSE A READING.

            //

            // It used to have a strict mode that returned nothing when the
            model had not produced exactly

            // one block per screenshot, on the theory that one more attempt
            would come back tidier. That

            // was a serious mistake: the shape of the record is cosmetic, the
            reading is what costs money,

            // and wiring the cheap thing to the expensive one turned a
            formatting nicety into a gate on

            // the whole ladder. A model that writes twenty panels instead of
            ten frames is not a failed

            // reading - it is a reading that needs renumbering, which happens
            here for free. With a forty

            // percent shape-deviation rate measured on real output, thirty
            segments became sixty-six model

            // calls instead of thirty, every one of them silent, plus a fresh
            ten-frame capture each time.

            // From the outside that is indistinguishable from a hang.

            //

            // So: whatever comes in, something usable comes out. Too many
            blocks are mapped onto the

            // frames proportionally, too few are numbered from the start of the
            segment and the shortfall

            // is stated, none at all are filed under the segment's frame span.
            The only empty answer is

            // for empty input, which is what the reading ladder above actually
            needs to know - and the

            // whole body is wrapped so that even an unforeseen error hands the
            raw reading back intact

            // rather than costing a request.

            function harden(value) {
              // Text lifted off a page can contain the engine's own {{parameter}} syntax. Left as it is,
              // it would be executed the next time this text is interpolated - fetching pages, running
              // searches - once per occurrence and once per answer. The documented escape makes the
              // braces literal without changing how the text reads. Idempotent by design.
              return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
            }


            const SIZE = Math.max(1, Math.min(10, Number(args.batchSize) ||
            10));

            const seg = Math.max(1, Number(String(args.segment ||
            '').replace(/[^0-9]/g, '')) || 1);

            const totalFrames = Math.max(0, Number(String(args.shotCount ||
            '').replace(/[^0-9]/g, '')) || 0);


            const from = (seg - 1) * SIZE + 1;

            const to = totalFrames > 0 ? Math.min(totalFrames, seg * SIZE) : seg
            * SIZE;

            const want = Math.max(1, to - from + 1);


            const original = String(args.rawSegment == null ? '' :
            args.rawSegment).replace(/\r/g, '').trim();

            if (original === '') return '';


            let raw = original;

            try {


            // A model that is told "do not write a preamble" still sometimes
            writes one. Fenced output,

            // a repeated document title, a source line and a horizontal rule at
            the very top are all

            // duplicates of information the record already holds once.

            raw = raw.replace(/^```[a-z]*\s*\n/i, '').replace(/\n```\s*$/i, '');

            const lead = raw.split('\n');

            while (lead.length > 0) {
              const line = lead[0].trim();
              const drop =
                line === '' ||
                line === '---' ||
                /^#{1,3}\s+\S/.test(line) && lead.length > 3 && !/frame|panel|image|screenshot|slide|shot/i.test(line) ||
                /^[-*]?\s*\**\s*(source|url|page|title|link)\s*\**\s*:/i.test(line) ||
                /^(here (is|are)|below (is|are)|sure[,!.]|certainly[,!.]|i('| wi)ll |this (is|segment))/i.test(line);
              if (!drop) break;
              lead.shift();
            }

            raw = lead.join('\n').trim();

            if (raw === '') return '';


            // Canonical bullet labels. The original wording is kept inside the
            line, so a speech bubble

            // is still recognisable as a speech bubble - it is simply filed
            under Text like every other

            // piece of transcribed writing.

            const LABELS = [
              ['Heading', /^(heading|title|header|section title|chapter title)$/i, false],
              ['Table', /^(table|grid|matrix|comparison)$/i, false],
              ['Data', /^(data|numbers?|values?|stats?|statistics|metrics?|price|prices|pricing|score|scores|rank(ing)?s?)$/i, false],
              ['UI', /^(ui|ui element|navigation|navigation ui|nav|nav bar|navbar|menu|button|buttons|link|links|form|form field|input|control|controls|tab|tabs|toolbar|footer|footer nav|breadcrumb|pagination)$/i, false],
              ['Visual', /^(visual|illustration|image|picture|photo|photograph|art|artwork|drawing|panel art|graphic|icon|logo|avatar|chart|diagram|figure|screenshot|map)$/i, true],
              ['Text', /^(text|speech bubble|speech|dialogue|dialog|narration|narration box|narrative box|text box|textbox|caption|body|body text|paragraph|quote|sound effect|sfx|system text|system window|system message|label|note|footnote|banner|banner box|top text box|bottom banner box|notification)$/i, true],
              ['Cut', /^(cut|continues|continued|continuation|bridged|carry over|spans)$/i, false],
              ['Gap', /^(gap|unreadable|illegible|blank|empty|missing|cut off)$/i, false]
            ];


            function normaliseLine(line) {
              const m = line.match(/^(\s*)([-*+]|\d+[.)])\s+\**\s*([A-Za-z][A-Za-z /_-]{1,40}?)\s*\**\s*:\s*\**\s*(.*)$/);
              if (!m) return line;
              const indent = m[1];
              const bullet = /^\d/.test(m[2]) ? '-' : '-';
              const rawLabel = m[3].trim();
              let rest = m[4];
              for (let i = 0; i < LABELS.length; i++) {
                if (!LABELS[i][1].test(rawLabel)) continue;
                const canonical = LABELS[i][0];
                const keepOriginal = LABELS[i][2] && rawLabel.toLowerCase() !== canonical.toLowerCase();
                const prefix = keepOriginal ? rawLabel.toLowerCase() + ' — ' : '';
                return indent + bullet + ' **' + canonical + ':** ' + prefix + rest.trim();
              }
              return indent + bullet + ' **' + rawLabel + ':** ' + rest.trim();
            }


            // Every heading shape a model reaches for, in one expression.

            const HEAD =
            /^[\s>*_]*(?:#{1,6})?[\s*_]*\[?\s*(?:frame|panel|image|screenshot|screen|slide|page|shot|fig(?:ure)?)\s*[#:\-]?\s*(\d+)\s*\]?\s*[:.\-]?[\s*_]*$/i;


            const lines = raw.split('\n');

            const blocks = [];

            let current = null;

            let preamble = [];


            for (let i = 0; i < lines.length; i++) {
              const m = lines[i].match(HEAD);
              if (m) {
                current = { body: [] };
                blocks.push(current);
                continue;
              }
              if (current === null) preamble.push(lines[i]);
              else current.body.push(lines[i]);
            }


            if (blocks.length === 0) {
              // No frame boundaries at all. The content is intact, only the per-frame granularity is
              // gone, so it is filed under the segment's frame span and marked as such rather than
              // being silently renumbered into something that would be wrong.
              const body = raw.split('\n').map(function (l) { return normaliseLine(l); }).join('\n');
              return harden('#### Frames ' + from + '-' + to + '\n' +
                     '- **Gap:** the reading did not separate these ' + want + ' frames; the content below covers all of them together.\n' +
                     body.trim() + '\n');
            }


            if (preamble.join('').trim() !== '') {
              blocks[0].body = preamble.concat(blocks[0].body);
            }


            // Mapping blocks onto frames. Equal counts is the normal case and
            maps one to one. More

            // blocks than images means the model cut the screenshots into
            panels: they are distributed

            // proportionally, so panels eleven and twelve of twenty land on
            frame six rather than all

            // surplus being dumped on the last one. Fewer blocks means it
            merged some: they are numbered

            // from the start of the segment and the shortfall is stated instead
            of being papered over.

            const out = [];

            let lastNumber = -1;

            for (let i = 0; i < blocks.length; i++) {
              const number = blocks.length > want
                ? Math.min(to, from + Math.floor(i * want / blocks.length))
                : Math.min(to, from + i);
              const body = blocks[i].body
                .map(function (l) { return normaliseLine(l); })
                .join('\n')
                .replace(/\n{3,}/g, '\n\n')
                .trim();
              if (number === lastNumber && out.length > 0) {
                if (body) out[out.length - 1] += '\n' + body;
                continue;
              }
              lastNumber = number;
              out.push('#### Frame ' + number + '\n' + (body || '- **Gap:** nothing legible in this frame.'));
            }


            if (blocks.length < want) {
              out.push('- **Gap:** the reading separated ' + blocks.length + ' of the ' + want + ' frames in this segment; the rest were merged into the blocks above.');
            }


            return harden(out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim() +
            '\n');

            } catch (e) {
              // Shaping is a convenience. If anything in it goes wrong the reading still has to reach the
              // record, hardened, rather than being reported as a failure that costs another request.
              return harden('#### Frames ' + from + '-' + to + '\n' +
                            '- **Gap:** the reading could not be shaped (' + String((e && e.message) || e) +
                            '); it is filed here unchanged.\n' + original);
            }
          param: segmentNotes
          timeout: 30000
          onFailure: ''
          silent: true
        - label: SET REPAIR MODE OFF
          type: calc
          func: set
          param: repairMode
          value: 'no'
          format: ''
        - condition: '{{segmentNotes}} ='
          label: ENABLE SEGMENT REPAIR
          type: calc
          func: set
          param: repairMode
          value: '{{sourceKind}}'
          format: ''
        - condition:
            - '{{repairMode}} = html'
            - '{{repairMode}} = pdf'
          label: ANNOUNCE SEGMENT REPAIR
          type: say
          message: >-
            🔧 Segment {{segment}} ({{range.label}}) came back empty twice.
            Photographing those frames again.
        - condition:
            - '{{repairMode}} = html'
            - '{{repairMode}} = pdf'
          label: PLAN SEGMENT REPAIR
          type: js
          args: segment, batchSize, shotCount
          code: >-
            // FULL PAGE VISION · PLAN SEGMENT REPAIR

            // Reached only when a segment came back empty twice. Instead of
            leaving a hole in the

            // record, the walk goes back to exactly where that segment was
            photographed and takes

            // the frames again - fresh pixels rather than a fourth attempt on
            the same tokens.

            //

            // The position is arithmetic, not a search: frame k sits at startY
            + (k-1) * stride,

            // and the stride is the exact one-screen step the walk used. The
            page has not been

            // reloaded since, so the lazily loaded content is still there.

            const SIZE = Math.max(1, Math.min(10, Number(args.batchSize) ||
            10));

            const seg = Math.max(1, Number(String(args.segment ||
            '').replace(/[^0-9]/g, '')) || 1);

            const total = Math.max(0, Number(String(args.shotCount ||
            '').replace(/[^0-9]/g, '')) || 0);


            const from = (seg - 1) * SIZE + 1;

            const to = total > 0 ? Math.min(total, seg * SIZE) : seg * SIZE;

            const count = Math.max(0, to - from + 1);

            if (count <= 0) return [];


            function sleep(ms) {
              return new Promise(function (r) { setTimeout(r, ms); });
            }


            function docScroller() {
              return document.scrollingElement || document.documentElement || document.body;
            }


            function setPos(el, y) {
              const target = Math.max(0, Math.round(y));
              if (el) { el.scrollTop = target; return; }
              try { window.scrollTo({ top: target, behavior: 'instant' }); } catch (e) { /* ignore */ }
              try { window.scrollTo({ top: target, behavior: 'auto' }); } catch (e) { /* ignore */ }
              try { window.scroll(0, target); } catch (e) { /* ignore */ }
              const se = docScroller();
              if (se) se.scrollTop = target;
              if (document.body) document.body.scrollTop = target;
            }


            const plan = [];

            for (let i = 0; i < count; i++) plan.push({ n: i + 1, frame: from +
            i, page: from + i });


            // On a PDF the frames come from navigation, not from scrolling, so
            there is nothing to

            // seek here and the plan is handed back as it is.

            try {
              const S = window.__fpv;
              if (S && typeof S === 'object') {
                const el = (S.el && document.contains(S.el)) ? S.el : null;
                const stride = Math.max(120, Number(S.stride) || 0);
                const startY = Number(S.startY) || 0;
                setPos(el, startY + (from - 1) * stride);
                await sleep(320);
                S.repairing = true;
              }
            } catch (e) { /* the plan still stands */ }


            return plan;
          param: repairPlan
          timeout: 60000
          onFailure: ''
          silent: true
        - condition: '{{repairMode}} = html'
          label: REPAIR HTML SEGMENT
          type: loop
          list: repairPlan
          steps:
            - label: APPEND REPAIR FRAME
              type: js
              args: repairImages, view
              code: >-
                // FULL PAGE VISION · APPEND REPAIR FRAME

                // The same accumulation as the main walk, and for the same
                reason: a comma separated series

                // held in a real parameter is the only form proven to deliver
                several images to one prompt.

                //

                //   arrayValue += ',' + viewValue   ->  a real parameter  -> 
                used in the prompt

                //

                // Two rules follow from that and must not be touched:

                //   * the separator is a COMMA. HARPA splits the stored series
                on commas to resolve the

                //     individual attachments. A newline joined series arrives
                as a single blob and only one

                //     image reaches the model.

                //   * the token travels as a JS ARGUMENT. Interpolating
                {{view}} inside a calc step coerces

                //     the reference to text and breaks it, which surfaces as

                //     "Failed to execute 'createImageBitmap'".

                //

                // This is the REPAIR variant and it reads its own slot. It used
                to read args['shots'], which

                // is not among its arguments: the accumulator was therefore
                undefined on every pass, the

                // series restarted from empty each time, and a repair that
                photographed ten frames handed

                // exactly one of them to the reading. The slot name is the
                whole fix.

                //

                // It also deliberately ignores the two guards the main walk
                needs. The frame ceiling counts

                // the frames of the WALK, and by the time a repair runs the
                walk has usually reached it, so

                // applying it here would refuse every repair frame on exactly
                the runs that need repairing.

                // The walk's abort flag means the same thing one step further
                on. The repair cannot run away

                // regardless: its plan has a fixed length, and a refused frame
                costs one entry of that plan

                // and nothing more.

                const arrayValue = args['repairImages'] || '';

                const viewValue = args.view;


                if (!viewValue) {
                  const S = window.__fpv || (window.__fpv = {});
                  S.repairMisses = (Number(S.repairMisses) || 0) + 1;
                  throw new Error('repair frame not available yet');
                }


                if (arrayValue === '') return viewValue;

                return arrayValue + ',' + viewValue;
              param: repairImages
              timeout: 60000
              onFailure: PACE REPAIR
              silent: true
            - label: ADVANCE REPAIR FRAME
              type: js
              args: ''
              code: >-
                // FULL PAGE VISION · ADVANCE REPAIR FRAME

                // One exact screen forward inside a repair pass. Deliberately
                separate from the main

                // walk: it must not touch the frame counter, the abort flag or
                the lazy-load logic of

                // the capture state, because the capture is long finished and
                only these few frames are

                // being taken again.

                function sleep(ms) {
                  return new Promise(function (r) { setTimeout(r, ms); });
                }


                function docScroller() {
                  return document.scrollingElement || document.documentElement || document.body;
                }


                function posOf(el) {
                  if (el) return el.scrollTop || 0;
                  const se = docScroller();
                  const w = typeof window.pageYOffset === 'number' ? window.pageYOffset : 0;
                  return Math.max(w, se ? (se.scrollTop || 0) : 0, document.body ? (document.body.scrollTop || 0) : 0);
                }


                function setPos(el, y) {
                  const target = Math.max(0, Math.round(y));
                  if (el) { el.scrollTop = target; return; }
                  try { window.scrollTo({ top: target, behavior: 'instant' }); } catch (e) { /* ignore */ }
                  try { window.scrollTo({ top: target, behavior: 'auto' }); } catch (e) { /* ignore */ }
                  try { window.scroll(0, target); } catch (e) { /* ignore */ }
                  const se = docScroller();
                  if (se) se.scrollTop = target;
                  if (document.body) document.body.scrollTop = target;
                }


                try {
                  const S = window.__fpv || {};
                  const el = (S.el && document.contains(S.el)) ? S.el : null;
                  const stride = Math.max(120, Number(S.stride) || (window.innerHeight || 800));
                  const before = posOf(el);
                  setPos(el, before + stride);
                  await sleep(140);
                  return String(posOf(el));
                } catch (e) {
                  return '0';
                }
              param: scratch
              timeout: 60000
              onFailure: ''
              silent: true
            - label: PACE REPAIR
              type: js
              args: captureInterval
              code: >-
                // FULL PAGE VISION · PACE REPAIR

                // Chrome refuses more than two screenshots per second and the
                limit cannot be raised, so

                // a repair pass keeps the same rhythm as the walk. This is also
                the landing point when a

                // repair frame is refused, which is why it does nothing except
                wait: the repair plan has

                // a fixed length, so a refused frame simply costs one entry and
                can never spin.

                function sleep(ms) {
                  return new Promise(function (r) { setTimeout(r, ms); });
                }

                const raw = String(args.captureInterval == null ? '' :
                args.captureInterval).replace(/[^0-9]/g, '');

                const interval = Math.max(200, Math.min(5000, Number(raw) ||
                520));

                await sleep(interval);

                return String(interval);
              param: scratch
              timeout: 30000
              onFailure: ''
              silent: true
        - condition: '{{repairMode}} = pdf'
          label: REPAIR PDF SEGMENT
          type: loop
          list: repairPlan
          steps:
            - label: OPEN REPAIR PAGE
              type: navigate
              url: '{{pdfUrl}}#page={{item.page}}'
              waitForIdle: true
              onFailure: ''
              silent: true
            - label: SETTLE REPAIR PAGE
              type: wait
              for: custom-delay
              delay: 400
              silent: true
            - label: APPEND REPAIR PDF FRAME
              type: js
              args: repairImages, view
              code: >-
                // FULL PAGE VISION · APPEND REPAIR FRAME

                // The same accumulation as the main walk, and for the same
                reason: a comma separated series

                // held in a real parameter is the only form proven to deliver
                several images to one prompt.

                //

                //   arrayValue += ',' + viewValue   ->  a real parameter  -> 
                used in the prompt

                //

                // Two rules follow from that and must not be touched:

                //   * the separator is a COMMA. HARPA splits the stored series
                on commas to resolve the

                //     individual attachments. A newline joined series arrives
                as a single blob and only one

                //     image reaches the model.

                //   * the token travels as a JS ARGUMENT. Interpolating
                {{view}} inside a calc step coerces

                //     the reference to text and breaks it, which surfaces as

                //     "Failed to execute 'createImageBitmap'".

                //

                // This is the REPAIR variant and it reads its own slot. It used
                to read args['shots'], which

                // is not among its arguments: the accumulator was therefore
                undefined on every pass, the

                // series restarted from empty each time, and a repair that
                photographed ten frames handed

                // exactly one of them to the reading. The slot name is the
                whole fix.

                //

                // It also deliberately ignores the two guards the main walk
                needs. The frame ceiling counts

                // the frames of the WALK, and by the time a repair runs the
                walk has usually reached it, so

                // applying it here would refuse every repair frame on exactly
                the runs that need repairing.

                // The walk's abort flag means the same thing one step further
                on. The repair cannot run away

                // regardless: its plan has a fixed length, and a refused frame
                costs one entry of that plan

                // and nothing more.

                const arrayValue = args['repairImages'] || '';

                const viewValue = args.view;


                if (!viewValue) {
                  const S = window.__fpv || (window.__fpv = {});
                  S.repairMisses = (Number(S.repairMisses) || 0) + 1;
                  throw new Error('repair frame not available yet');
                }


                if (arrayValue === '') return viewValue;

                return arrayValue + ',' + viewValue;
              param: repairImages
              timeout: 60000
              onFailure: PACE PDF REPAIR
              silent: true
            - label: PACE PDF REPAIR
              type: js
              args: captureInterval
              code: >-
                // FULL PAGE VISION · PACE REPAIR

                // Chrome refuses more than two screenshots per second and the
                limit cannot be raised, so

                // a repair pass keeps the same rhythm as the walk. This is also
                the landing point when a

                // repair frame is refused, which is why it does nothing except
                wait: the repair plan has

                // a fixed length, so a refused frame simply costs one entry and
                can never spin.

                function sleep(ms) {
                  return new Promise(function (r) { setTimeout(r, ms); });
                }

                const raw = String(args.captureInterval == null ? '' :
                args.captureInterval).replace(/[^0-9]/g, '');

                const interval = Math.max(200, Math.min(5000, Number(raw) ||
                520));

                await sleep(interval);

                return String(interval);
              param: scratch
              timeout: 30000
              onFailure: ''
              silent: true
        - condition: '{{repairMode}} = html'
          label: RESTORE POSITION AFTER REPAIR
          type: js
          args: ''
          code: >-
            // FULL PAGE VISION · RESTORE POSITION AFTER REPAIR

            // Puts the page back where the reader left it, so a repair pass is
            invisible afterwards.

            function docScroller() {
              return document.scrollingElement || document.documentElement || document.body;
            }

            try {
              const S = window.__fpv || {};
              const el = (S.el && document.contains(S.el)) ? S.el : null;
              const home = Number(S.startY) || 0;
              if (el) {
                el.scrollTop = home;
              } else {
                try { window.scrollTo({ top: home, behavior: 'instant' }); } catch (e) { /* ignore */ }
                try { window.scrollTo({ top: home, behavior: 'auto' }); } catch (e) { /* ignore */ }
                try { window.scroll(0, home); } catch (e) { /* ignore */ }
                const se = docScroller();
                if (se) se.scrollTop = home;
                if (document.body) document.body.scrollTop = home;
              }
              S.repairing = false;
              return 'restored';
            } catch (e) {
              return 'skipped';
            }
          param: scratch
          timeout: 30000
          onFailure: ''
          silent: true
        - condition: '{{repairMode}} = pdf'
          label: RETURN AFTER PDF REPAIR
          type: navigate
          url: '{{pdfUrl}}'
          waitForIdle: false
          onFailure: ''
          silent: true
        - condition:
            - '{{repairMode}} = html'
            - '{{repairMode}} = pdf'
          label: COUNT REPAIR FRAMES
          type: js
          args: repairImages
          code: >-
            // FULL PAGE VISION · COUNT A FRAME SERIES

            // Answers "did the repair capture actually produce frames" with a
            NUMBER.

            //

            // The obvious way to ask that is a condition on the series itself.
            That must never happen: a

            // frame token is a reference, not a string, and interpolating the
            series into a condition or

            // a calc value coerces it to text and destroys it - the failure
            surfaces later as

            // "Failed to execute 'createImageBitmap'". The series therefore
            only ever travels as a JS

            // argument, and only this count comes back out.

            const raw = String(args.repairImages == null ? '' :
            args.repairImages);

            const parts = raw.split(',').map(function (s) { return s.trim();
            }).filter(function (s) { return s.length > 0; });

            return { parts: parts.length, ok: parts.length > 0 ? 'yes' : 'no' };
          param: repairCount
          timeout: 30000
          onFailure: ''
          silent: true
        - condition: '{{repairCount.ok}} = yes'
          label: MATERIALIZE REPAIR
          type: calc
          func: set
          param: batchImages
          value: '{{repairImages}}'
          format: ''
        - condition: '{{segmentNotes}} ='
          label: CLEAR RAW BEFORE THIRD READING
          type: calc
          func: set
          param: rawSegment
          value: ''
          format: ''
        - condition: '{{segmentNotes}} ='
          label: READ SEGMENT AGAIN
          type: gpt
          prompt: >-
            Please ignore all previous instructions. Write only in
            {{outputLanguage}}.


            Attached are {{range.count}} screenshots of one web page, in order,
            top to bottom. They are frames {{range.from}} to {{range.to}} of
            {{shotCount}}. They were photographed again for this attempt, so
            these are fresh pixels.


            Two earlier attempts at these images came back empty. Do not aim for
            a polished record this time — aim for something rather than nothing.


            Write what you can see. One `#### Frame N` heading per image,
            starting at Frame {{range.from}} and counting upwards, then plain
            bullet lines underneath saying what stands there: text as text,
            transcribed as exactly as you can manage, and a description of
            anything shown as a picture, a chart or a control - what it depicts,
            not just that it exists.


            If an image is genuinely blank or illegible, write one bullet saying
            so and move to the next one. Do not explain, do not apologise, do
            not describe what you are about to do — just write the frames.


            {{focus.read}}

            [SCREENSHOTS]:

            {{batchImages}}


            [RECORD]:
          param: rawSegment
          isolated: true
          silent: true
        - condition: '{{segmentNotes}} ='
          label: SHAPE THE THIRD READING
          type: js
          args: rawSegment, segment, batchSize, shotCount
          code: >-
            // FULL PAGE VISION · SHAPE SEGMENT

            // The record has to look the same in every segment, because
            everything downstream reads

            // it: the citations, the rebuild, the data extraction, the export.
            Left to itself a model

            // invents a new layout per call - one segment comes back as "###
            [Panel 1]", the next as a

            // flat bullet list with no headings at all, the next as "[FRAME 1]"
            - and every one of them

            // starts counting at one again, so "frame 3" means three different
            places in one record.

            //

            // The prompt prescribes the shape. This step enforces it,
            deterministically and for free:

            //   * every heading variant a model reaches for is recognised and
            rewritten to `#### Frame N`

            //   * N is the ABSOLUTE frame number, so frame 47 is called frame
            47 in every segment

            //   * the bullet labels are folded into one closed vocabulary, with
            the original wording

            //     preserved inside the line so nothing is lost

            //   * a document title or source line that the model reprinted per
            segment is dropped,

            //     because the record already carries one at the top

            //

            // THIS STEP MAY NEVER REFUSE A READING.

            //

            // It used to have a strict mode that returned nothing when the
            model had not produced exactly

            // one block per screenshot, on the theory that one more attempt
            would come back tidier. That

            // was a serious mistake: the shape of the record is cosmetic, the
            reading is what costs money,

            // and wiring the cheap thing to the expensive one turned a
            formatting nicety into a gate on

            // the whole ladder. A model that writes twenty panels instead of
            ten frames is not a failed

            // reading - it is a reading that needs renumbering, which happens
            here for free. With a forty

            // percent shape-deviation rate measured on real output, thirty
            segments became sixty-six model

            // calls instead of thirty, every one of them silent, plus a fresh
            ten-frame capture each time.

            // From the outside that is indistinguishable from a hang.

            //

            // So: whatever comes in, something usable comes out. Too many
            blocks are mapped onto the

            // frames proportionally, too few are numbered from the start of the
            segment and the shortfall

            // is stated, none at all are filed under the segment's frame span.
            The only empty answer is

            // for empty input, which is what the reading ladder above actually
            needs to know - and the

            // whole body is wrapped so that even an unforeseen error hands the
            raw reading back intact

            // rather than costing a request.

            function harden(value) {
              // Text lifted off a page can contain the engine's own {{parameter}} syntax. Left as it is,
              // it would be executed the next time this text is interpolated - fetching pages, running
              // searches - once per occurrence and once per answer. The documented escape makes the
              // braces literal without changing how the text reads. Idempotent by design.
              return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
            }


            const SIZE = Math.max(1, Math.min(10, Number(args.batchSize) ||
            10));

            const seg = Math.max(1, Number(String(args.segment ||
            '').replace(/[^0-9]/g, '')) || 1);

            const totalFrames = Math.max(0, Number(String(args.shotCount ||
            '').replace(/[^0-9]/g, '')) || 0);


            const from = (seg - 1) * SIZE + 1;

            const to = totalFrames > 0 ? Math.min(totalFrames, seg * SIZE) : seg
            * SIZE;

            const want = Math.max(1, to - from + 1);


            const original = String(args.rawSegment == null ? '' :
            args.rawSegment).replace(/\r/g, '').trim();

            if (original === '') return '';


            let raw = original;

            try {


            // A model that is told "do not write a preamble" still sometimes
            writes one. Fenced output,

            // a repeated document title, a source line and a horizontal rule at
            the very top are all

            // duplicates of information the record already holds once.

            raw = raw.replace(/^```[a-z]*\s*\n/i, '').replace(/\n```\s*$/i, '');

            const lead = raw.split('\n');

            while (lead.length > 0) {
              const line = lead[0].trim();
              const drop =
                line === '' ||
                line === '---' ||
                /^#{1,3}\s+\S/.test(line) && lead.length > 3 && !/frame|panel|image|screenshot|slide|shot/i.test(line) ||
                /^[-*]?\s*\**\s*(source|url|page|title|link)\s*\**\s*:/i.test(line) ||
                /^(here (is|are)|below (is|are)|sure[,!.]|certainly[,!.]|i('| wi)ll |this (is|segment))/i.test(line);
              if (!drop) break;
              lead.shift();
            }

            raw = lead.join('\n').trim();

            if (raw === '') return '';


            // Canonical bullet labels. The original wording is kept inside the
            line, so a speech bubble

            // is still recognisable as a speech bubble - it is simply filed
            under Text like every other

            // piece of transcribed writing.

            const LABELS = [
              ['Heading', /^(heading|title|header|section title|chapter title)$/i, false],
              ['Table', /^(table|grid|matrix|comparison)$/i, false],
              ['Data', /^(data|numbers?|values?|stats?|statistics|metrics?|price|prices|pricing|score|scores|rank(ing)?s?)$/i, false],
              ['UI', /^(ui|ui element|navigation|navigation ui|nav|nav bar|navbar|menu|button|buttons|link|links|form|form field|input|control|controls|tab|tabs|toolbar|footer|footer nav|breadcrumb|pagination)$/i, false],
              ['Visual', /^(visual|illustration|image|picture|photo|photograph|art|artwork|drawing|panel art|graphic|icon|logo|avatar|chart|diagram|figure|screenshot|map)$/i, true],
              ['Text', /^(text|speech bubble|speech|dialogue|dialog|narration|narration box|narrative box|text box|textbox|caption|body|body text|paragraph|quote|sound effect|sfx|system text|system window|system message|label|note|footnote|banner|banner box|top text box|bottom banner box|notification)$/i, true],
              ['Cut', /^(cut|continues|continued|continuation|bridged|carry over|spans)$/i, false],
              ['Gap', /^(gap|unreadable|illegible|blank|empty|missing|cut off)$/i, false]
            ];


            function normaliseLine(line) {
              const m = line.match(/^(\s*)([-*+]|\d+[.)])\s+\**\s*([A-Za-z][A-Za-z /_-]{1,40}?)\s*\**\s*:\s*\**\s*(.*)$/);
              if (!m) return line;
              const indent = m[1];
              const bullet = /^\d/.test(m[2]) ? '-' : '-';
              const rawLabel = m[3].trim();
              let rest = m[4];
              for (let i = 0; i < LABELS.length; i++) {
                if (!LABELS[i][1].test(rawLabel)) continue;
                const canonical = LABELS[i][0];
                const keepOriginal = LABELS[i][2] && rawLabel.toLowerCase() !== canonical.toLowerCase();
                const prefix = keepOriginal ? rawLabel.toLowerCase() + ' — ' : '';
                return indent + bullet + ' **' + canonical + ':** ' + prefix + rest.trim();
              }
              return indent + bullet + ' **' + rawLabel + ':** ' + rest.trim();
            }


            // Every heading shape a model reaches for, in one expression.

            const HEAD =
            /^[\s>*_]*(?:#{1,6})?[\s*_]*\[?\s*(?:frame|panel|image|screenshot|screen|slide|page|shot|fig(?:ure)?)\s*[#:\-]?\s*(\d+)\s*\]?\s*[:.\-]?[\s*_]*$/i;


            const lines = raw.split('\n');

            const blocks = [];

            let current = null;

            let preamble = [];


            for (let i = 0; i < lines.length; i++) {
              const m = lines[i].match(HEAD);
              if (m) {
                current = { body: [] };
                blocks.push(current);
                continue;
              }
              if (current === null) preamble.push(lines[i]);
              else current.body.push(lines[i]);
            }


            if (blocks.length === 0) {
              // No frame boundaries at all. The content is intact, only the per-frame granularity is
              // gone, so it is filed under the segment's frame span and marked as such rather than
              // being silently renumbered into something that would be wrong.
              const body = raw.split('\n').map(function (l) { return normaliseLine(l); }).join('\n');
              return harden('#### Frames ' + from + '-' + to + '\n' +
                     '- **Gap:** the reading did not separate these ' + want + ' frames; the content below covers all of them together.\n' +
                     body.trim() + '\n');
            }


            if (preamble.join('').trim() !== '') {
              blocks[0].body = preamble.concat(blocks[0].body);
            }


            // Mapping blocks onto frames. Equal counts is the normal case and
            maps one to one. More

            // blocks than images means the model cut the screenshots into
            panels: they are distributed

            // proportionally, so panels eleven and twelve of twenty land on
            frame six rather than all

            // surplus being dumped on the last one. Fewer blocks means it
            merged some: they are numbered

            // from the start of the segment and the shortfall is stated instead
            of being papered over.

            const out = [];

            let lastNumber = -1;

            for (let i = 0; i < blocks.length; i++) {
              const number = blocks.length > want
                ? Math.min(to, from + Math.floor(i * want / blocks.length))
                : Math.min(to, from + i);
              const body = blocks[i].body
                .map(function (l) { return normaliseLine(l); })
                .join('\n')
                .replace(/\n{3,}/g, '\n\n')
                .trim();
              if (number === lastNumber && out.length > 0) {
                if (body) out[out.length - 1] += '\n' + body;
                continue;
              }
              lastNumber = number;
              out.push('#### Frame ' + number + '\n' + (body || '- **Gap:** nothing legible in this frame.'));
            }


            if (blocks.length < want) {
              out.push('- **Gap:** the reading separated ' + blocks.length + ' of the ' + want + ' frames in this segment; the rest were merged into the blocks above.');
            }


            return harden(out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim() +
            '\n');

            } catch (e) {
              // Shaping is a convenience. If anything in it goes wrong the reading still has to reach the
              // record, hardened, rather than being reported as a failure that costs another request.
              return harden('#### Frames ' + from + '-' + to + '\n' +
                            '- **Gap:** the reading could not be shaped (' + String((e && e.message) || e) +
                            '); it is filed here unchanged.\n' + original);
            }
          param: segmentNotes
          timeout: 30000
          onFailure: ''
          silent: true
        - condition: '{{segmentNotes}} ='
          label: COUNT FAILED SEGMENT
          type: calc
          func: increment
          param: failedSegments
          delta: '1'
        - condition: '{{segmentNotes}} ='
          label: MARK SEGMENT GAP
          type: calc
          func: set
          param: segmentNotes
          value: >-
            - **Gap:** [SEGMENT {{segment}} NOT READ - {{range.label}}. Three
            readings came back empty, one of them on freshly taken screenshots.
            These frames are missing from the record.]
          format: ''
        - label: RENDER SEGMENT PROGRESS
          type: js
          args: segment, batchSize, shotCount, segmentNotes
          code: >-
            // FULL PAGE VISION · SEGMENT PROGRESS

            // One line per finished segment. It exists because reading a long
            page is a minute of

            // silence otherwise: the readings themselves no longer go into the
            chat, so without this

            // there is nothing between "reading 7 segments" and the finished
            record. The bar is a

            // fixed ten cells wide whatever the segment count is, so the lines
            stay aligned under

            // each other and the eye only has to follow one moving edge.

            //

            // It also carries the honest status of each segment: a segment that
            had to be salvaged or

            // could not be read at all says so here, at the moment it happens,
            instead of only turning

            // up as a warning at the end.

            const CELLS = 10;


            // Everything is derived from the three numbers the loop already
            carries, so the line needs

            // no extra parameters set up around it.

            const SIZE = Math.max(1, Math.min(10, Number(args.batchSize) ||
            10));

            const shots = Math.max(1, Number(String(args.shotCount ||
            '').replace(/[^0-9]/g, '')) || 1);

            const done = Math.max(0, Number(String(args.segment ||
            '').replace(/[^0-9]/g, '')) || 0);

            const total = Math.max(1, Math.ceil(shots / SIZE));

            const from = (done - 1) * SIZE + 1;

            const to = Math.min(shots, done * SIZE);

            const label = 'frames ' + from + '-' + to;

            const body = String(args.segmentNotes == null ? '' :
            args.segmentNotes);


            const ratio = Math.max(0, Math.min(1, done / total));

            const filled = Math.round(ratio * CELLS);

            const bar = new Array(filled + 1).join('▰') + new Array(CELLS -
            filled + 1).join('▱');

            const percent = Math.round(ratio * 100);


            const words = body.replace(/[#*>`\-]+/g, ' ').replace(/\s+/g, '
            ').trim();

            const wordCount = words === '' ? 0 : words.split(' ').length;

            const frames = (body.match(/^#### Frames? /gm) || []).length;


            let status;

            if (/NOT READ/.test(body)) {
              status = '⚠️ not read';
            } else if (/the reading separated \d+ of the/.test(body) || /did not
            separate these/.test(body)) {
              status = wordCount + ' words · 🔧 salvaged';
            } else {
              status = wordCount + ' words';
            }


            return {
              line: '`' + bar + '` **' + done + '/' + total + '** · ' + label + ' · ' + status,
              percent: percent,
              words: wordCount,
              frames: frames
            };
          param: progress
          timeout: 30000
          onFailure: ''
          silent: true
        - label: REPORT SEGMENT PROGRESS
          type: say
          message: '{{progress.line}}'
        - label: APPEND SEGMENT RECORD
          type: js
          args: notes, segmentNotes, segment, range
          code: >-
            // FULL PAGE VISION · APPEND SEGMENT RECORD

            // Adds one finished segment to the growing record. It looks like a
            job for a CALC step, and

            // it used to be one:

            //

            //   calc set  notes = '{{notes}}\n\n===== SEGMENT n
            =====\n{{segmentNotes}}'

            //

            // That form is wrong for this particular value, for a documented
            reason. The shaper armours

            // the reading by rewriting `{{` to `{{\`, which is the engine's own
            escape - and the engine

            // defines that escape as consumed on interpolation: `{{\page}}` is
            REPLACED with the literal

            // `{{page}}`. A CALC value is interpolated. So the very first
            append stripped the armour off

            // the reading, and from the second segment onwards the record was
            carried through the

            // template engine unprotected, with `{{page}}` and `{{serp x}}`
            lifted verbatim off the page

            // sitting in it, live. Re-armouring at the end cannot undo a page
            fetch that already happened.

            //

            // Arguments are the way out, and it is the same rule the frame
            tokens already live by: a

            // value that must not be interpolated travels as a JS argument.
            Nothing here passes through

            // the template engine, the armour is re-applied to the result, and
            the escape is idempotent,

            // so a record that arrives already armoured stays at exactly one
            layer.

            //

            // It is also cheaper. The old form re-scanned the entire
            accumulated record for parameters on

            // every single segment - on a thirty segment walk that is a
            quadratic amount of template

            // scanning over a document that can pass half a million characters.

            function harden(value) {
              return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
            }


            const previous = String(args.notes == null ? '' : args.notes);

            const body = String(args.segmentNotes == null ? '' :
            args.segmentNotes).trim();

            const seg = Math.max(1, Number(String(args.segment ||
            '').replace(/[^0-9]/g, '')) || 1);


            let span = '';

            try {
              const r = args.range;
              if (r && typeof r === 'object' && r.label) span = String(r.label);
              else if (r) span = String(r);
            } catch (e) { span = ''; }


            const marker = '===== SEGMENT ' + seg + (span ? ' · ' + span : '') +
            ' =====';

            const block = marker + '\n' + (body || '- **Gap:** nothing was
            recorded for this segment.');


            if (previous.trim() === '') return harden(block);

            return harden(previous.replace(/\s+$/, '') + '\n\n' + block);
          param: notes
          timeout: 60000
          onFailure: ''
          silent: true
    - label: DETECT REPEATED CHROME
      type: js
      args: notes
      code: >-
        // FULL PAGE VISION · DETECT REPEATED CHROME

        // Finds the lines that come back on screen after screen: cookie
        banners, navigation

        // bars, sticky footers, share rails, and on serialised pages the
        chapter or series

        // header that is reprinted with every scroll. They are transcribed once
        per frame, so

        // on a hundred frame page they are paid for a hundred times - and paid
        for again in

        // every single answer, because the record is sent along with each one.

        //

        // This step only NOMINATES candidates. It never deletes anything,
        because a repeated

        // line is not automatically noise: a recurring table header or a price
        that genuinely

        // appears in every row is content. The judgement is left to the model
        in the next step,

        // and the surgery to the step after that.

        function harden(value) {
          // Text lifted off a page can contain the engine's own {{parameter}} syntax. Left as it is,
          // it would be executed the next time this text is interpolated - fetching pages, running
          // searches - once per occurrence and once per answer. The documented escape makes the
          // braces literal without changing how the text reads. Idempotent by design.
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        const raw = String(args.notes == null ? '' : args.notes);


        const segments = raw.split(/^=====.*=====$/m).filter(function (s) {
        return s.trim().length > 0; });

        const total = segments.length;


        const out = { count: 0, chars: 0, segments: total, list: '', ok: 'no' };

        if (total < 3) return out;


        function normalise(line) {
          return line
            .toLowerCase()
            .replace(/[\u2018\u2019\u201c\u201d]/g, "'")
            .replace(/[^a-z0-9\u00c0-\u024f\u0400-\u04ff\u0590-\u05ff\u0600-\u06ff\u4e00-\u9fff]+/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
        }


        const seen = {};

        for (let i = 0; i < total; i++) {
          const lines = segments[i].split('\n');
          const here = {};
          for (let j = 0; j < lines.length; j++) {
            const original = lines[j].trim();
            if (original.length < 3 || original.length > 200) continue;
            const key = normalise(original);
            if (key.length < 3) continue;
            if (here[key]) continue;
            here[key] = true;
            if (!seen[key]) seen[key] = { hits: 0, sample: original, occurrences: 0, chars: 0 };
            seen[key].hits += 1;
          }
          for (let j = 0; j < lines.length; j++) {
            const original = lines[j].trim();
            if (original.length < 3 || original.length > 200) continue;
            const key = normalise(original);
            if (key.length < 3 || !seen[key]) continue;
            seen[key].occurrences += 1;
            seen[key].chars += original.length + 1;
          }
        }


        const minHits = Math.max(3, Math.ceil(total * 0.5));

        const picked = [];

        for (const key in seen) {
          if (!Object.prototype.hasOwnProperty.call(seen, key)) continue;
          const rec = seen[key];
          if (rec.hits < minHits) continue;
          picked.push({ key: key, sample: rec.sample, hits: rec.hits, chars: rec.chars - rec.sample.length - 1 });
        }


        picked.sort(function (a, b) { return b.chars - a.chars; });

        const top = picked.slice(0, 40);


        out.count = top.length;

        out.chars = top.reduce(function (a, b) { return a + Math.max(0,
        b.chars); }, 0);

        out.list = harden(top.map(function (p) { return p.sample + '   [seen on
        ' + p.hits + ' of ' + total + ' screens]'; }).join('\n'));

        out.ok = top.length > 0 ? 'yes' : 'no';

        return out;
      param: chrome
      timeout: 30000
      onFailure: ''
      silent: true
    - condition: '{{chrome.count}} > 0'
      label: CLASSIFY REPEATED CHROME
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Answer in English regardless of
        any other setting.


        Below are lines that were transcribed from a long web page and came back
        on most of its screens. Some of them are page furniture: navigation
        bars, cookie or consent banners, sticky headers and footers, share
        rails, advertisement labels, back-to-top buttons, or the series and
        chapter title that a reading site reprints above every screen. Others
        are genuine content that simply happens to repeat: a recurring table
        header, a price that belongs to every row, a legal notice that carries
        meaning, a heading that structures the document.


        Decide for each line. Output one line per item you judge to be page
        furniture, in this exact form and nothing else:


        CHROME: <the line exactly as given, without the bracketed counter>


        Rules:

        - When in doubt, leave the line out. A wrongly kept line costs a few
        tokens; a wrongly removed line costs information.

        - Never output anything that carries a number, a date, a name or a
        statement about the subject of the page.

        - No preamble, no explanation, no closing remark. If none of them are
        furniture, output nothing at all.


        [CANDIDATE LINES]

        {{chrome.list}}


        [VERDICT]:
      param: chromeVerdict
      isolated: true
      silent: true
    - condition: '{{chrome.count}} > 0'
      label: STRIP CLASSIFIED CHROME
      type: js
      args: notes, chromeVerdict
      code: >-
        // FULL PAGE VISION · STRIP CLASSIFIED CHROME

        // Removes exactly the lines the model classified as page furniture and
        keeps one copy

        // of each in a PAGE CHROME block at the head of the record. Nothing is
        destroyed: the

        // text is still there, it is simply there once instead of once per
        screen, so citations

        // still resolve and the count of segments does not change.

        //

        // If the classification step returned nothing usable, this step is a
        no-op and hands the

        // record back untouched. A failed cleanup must never cost content.

        function harden(value) {
          // Text lifted off a page can contain the engine's own {{parameter}} syntax. Left as it is,
          // it would be executed the next time this text is interpolated - fetching pages, running
          // searches - once per occurrence and once per answer. The documented escape makes the
          // braces literal without changing how the text reads. Idempotent by design.
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        const raw = String(args.notes == null ? '' : args.notes);

        const verdict = String(args.chromeVerdict == null ? '' :
        args.chromeVerdict);


        function normalise(line) {
          return line
            .toLowerCase()
            .replace(/[\u2018\u2019\u201c\u201d]/g, "'")
            .replace(/[^a-z0-9\u00c0-\u024f\u0400-\u04ff\u0590-\u05ff\u0600-\u06ff\u4e00-\u9fff]+/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
        }


        const kill = {};

        const keptSamples = [];

        const verdictLines = verdict.split('\n');

        for (let i = 0; i < verdictLines.length; i++) {
          let line = verdictLines[i].trim();
          if (line.indexOf('CHROME:') !== 0) continue;
          line = line.slice(7).trim().replace(/\s*\[seen on .*$/, '').trim();
          const key = normalise(line);
          if (key.length < 3) continue;
          if (!kill[key]) {
            kill[key] = true;
            keptSamples.push(line);
          }
        }


        if (keptSamples.length === 0) return raw;


        const lines = raw.split('\n');

        const out = [];

        let removed = 0;

        for (let i = 0; i < lines.length; i++) {
          const trimmed = lines[i].trim();
          if (trimmed.length >= 3 && trimmed.length <= 200 && kill[normalise(trimmed)]) {
            removed++;
            continue;
          }
          out.push(lines[i]);
        }


        if (removed === 0) return raw;


        const header = [
          '===== PAGE CHROME · repeated on every screen, listed once =====',
          ''
        ].concat(keptSamples.map(function (s) { return '- ' + s;
        })).concat(['']).join('\n');


        return harden(header + out.join('\n').replace(/\n{4,}/g, '\n\n\n'));
      param: notes
      timeout: 30000
      onFailure: ''
      silent: true
    - condition: '{{chrome.count}} > 0'
      label: REPORT CHROME CLEANUP
      type: say
      message: >-
        🧹 {{chrome.count}} repeated line(s) folded into one block
        (~{{chrome.chars}} characters saved on every later answer).
    - label: MEASURE RECORD
      type: js
      args: notes
      code: >-
        // FULL PAGE VISION · MEASURE RECORD

        // Sizes the merged vision record so the command can decide whether a
        single prompt can

        // carry it or whether it has to be condensed slice by slice first.

        const raw = String(args.notes == null ? '' : args.notes);

        const body = raw.replace(/=====[^=]*=====/g, ' ').replace(/\s+/g, '
        ').trim();

        const segments = (raw.match(/=====\s*SEGMENT\s+\d+/g) || []).length;

        const gaps = (raw.match(/NOT READ/g) || []).length;

        const chars = body.length;


        return {
          ok: chars > 0 ? 'yes' : 'no',
          chars: chars,
          words: body ? body.split(' ').length : 0,
          tokens: Math.ceil(chars / 3.6),
          segments: segments,
          gaps: gaps
        };
      param: record
      timeout: 30000
      onFailure: ''
      silent: true
    - label: USE THE FULL RECORD
      type: calc
      func: set
      param: payload
      value: '{{notes}}'
      format: ''
    - label: REPORT RECORD
      type: say
      message: >-
        ✅ Record: {{shotCount}} frames · {{record.segments}} segment(s) ·
        ~{{record.words}} words
    - condition: '{{failedSegments}} > 0'
      label: WARN FAILED SEGMENTS
      type: say
      message: >-
        ⚠️ **{{failedSegments}}** segment(s) could not be read and are marked as
        gaps in the record. Everything else is intact — pick RETRY in the
        follow-up menu if you want the run repeated.
- label: HARDEN THE RECORD
  type: group
  steps:
    - label: HARDEN PAYLOAD
      type: js
      args: payload
      code: >-
        // FULL PAGE VISION · HARDEN TEXT

        // The one piece of armour this command cannot do without.

        //

        // The frames are read verbatim, so whatever stands on the page ends up
        inside a parameter -

        // and on a page that documents a template language, that includes
        things like {{page}},

        // {{serp query}} or {{transcript}}. The moment such a record is
        interpolated into the next

        // prompt, those look exactly like real system parameters: the engine
        goes off and fetches a

        // whole page, runs a web search or pulls a transcript, once per
        occurrence, for every answer

        // that carries the record. A documentation page with forty examples in
        it turns a two second

        // step into minutes of work that nobody asked for. It looks like a
        hang; it is a record

        // quoting the engine's own syntax back at it.

        //

        // The cure is the escape the engine itself defines: a backslash after
        the opening braces

        // marks them as literal. The text still reads as {{page}} for a human
        and for the model, it

        // simply stops being a command. Idempotent, so text that has already
        been hardened is left

        // alone rather than growing a second backslash on every pass.

        function harden(value) {
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        // The step is used on several different slots, so it accepts whichever
        one it was given.

        const candidates = ['payload', 'gpt', 'notes', 'text', 'condensed',
        'segmentNotes'];

        let raw = '';

        for (let i = 0; i < candidates.length; i++) {
          if (args[candidates[i]] != null && String(args[candidates[i]]) !== '') {
            raw = args[candidates[i]];
            break;
          }
        }

        return harden(raw);
      param: payload
      timeout: 30000
      onFailure: ''
      silent: true
    - label: HARDEN NOTES
      type: js
      args: notes
      code: >-
        // FULL PAGE VISION · HARDEN TEXT

        // The one piece of armour this command cannot do without.

        //

        // The frames are read verbatim, so whatever stands on the page ends up
        inside a parameter -

        // and on a page that documents a template language, that includes
        things like {{page}},

        // {{serp query}} or {{transcript}}. The moment such a record is
        interpolated into the next

        // prompt, those look exactly like real system parameters: the engine
        goes off and fetches a

        // whole page, runs a web search or pulls a transcript, once per
        occurrence, for every answer

        // that carries the record. A documentation page with forty examples in
        it turns a two second

        // step into minutes of work that nobody asked for. It looks like a
        hang; it is a record

        // quoting the engine's own syntax back at it.

        //

        // The cure is the escape the engine itself defines: a backslash after
        the opening braces

        // marks them as literal. The text still reads as {{page}} for a human
        and for the model, it

        // simply stops being a command. Idempotent, so text that has already
        been hardened is left

        // alone rather than growing a second backslash on every pass.

        function harden(value) {
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        // The step is used on several different slots, so it accepts whichever
        one it was given.

        const candidates = ['payload', 'gpt', 'notes', 'text', 'condensed',
        'segmentNotes'];

        let raw = '';

        for (let i = 0; i < candidates.length; i++) {
          if (args[candidates[i]] != null && String(args[candidates[i]]) !== '') {
            raw = args[candidates[i]];
            break;
          }
        }

        return harden(raw);
      param: notes
      timeout: 30000
      onFailure: ''
      silent: true
- condition: '{{record.chars}} > 60000'
  label: CHOOSE HOW THE RECORD IS READ
  type: group
  steps:
    - label: INDEX THE RECORD INTO PASSAGES
      type: js
      args: payload
      code: >-
        // FULL PAGE VISION · PASSAGE INDEX

        // Cuts the finished record into numbered passages so that a later
        request can be given the

        // part it needs instead of the whole thing.

        //

        // The cut follows the record's own seams. Every passage is exactly one
        `#### Frame N` block,

        // which means a passage never straddles two frames and the frame number
        is therefore an

        // honest citation: a reader can scroll back to that screen and check
        the claim. A frame that

        // is longer than MAX_PASSAGE on its own is split into parts that keep
        the same frame number,

        // because the alternative - a passage that quietly spans frames 7 to 9
        - would make every

        // citation a guess.

        //

        // Nothing is thrown away here. The index is a second view of the
        record, not a replacement:

        // the full record stays in `payload` and is still what the export
        writes and what FULL RECORD

        // sends.

        const MAX_PASSAGE = 2400;


        const raw = String(args.payload == null ? '' : args.payload);

        if (raw.trim() === '') {
          return { chunks: [], totalChunks: 0, totalChars: 0, frames: 0, ok: 'no' };
        }


        // Frame headings are the seam. Segment markers are dropped: they carry
        no content and would

        // otherwise become passages of their own.

        const parts = raw.split(/\n(?=#### Frames? )/);

        const chunks = [];

        let frames = 0;

        let totalChars = 0;


        for (let p = 0; p < parts.length; p++) {
          const block = parts[p].replace(/^={3,}[^\n]*={3,}\n?/gm, '').trim();
          if (block === '') continue;

          const head = block.match(/^#### (Frames? [\d\-]+)/);
          const label = head ? head[1] : 'Preamble';
          if (head) frames++;

          totalChars += block.length;

          if (block.length <= MAX_PASSAGE) {
            chunks.push({ id: 'P' + (chunks.length + 1), frame: label, text: block, part: 0 });
            continue;
          }

          // A long frame is cut on bullet boundaries, never mid-bullet: a half bullet is a half fact.
          const lines = block.split('\n');
          let buf = '';
          let part = 1;
          for (let i = 0; i < lines.length; i++) {
            const line = lines[i];
            if (buf !== '' && buf.length + line.length + 1 > MAX_PASSAGE) {
              chunks.push({
                id: 'P' + (chunks.length + 1),
                frame: label,
                text: (part > 1 ? '#### ' + label + ' (part ' + part + ')\n' : '') + buf.trim(),
                part: part
              });
              part++;
              buf = '';
            }
            buf += (buf === '' ? '' : '\n') + line;
          }
          if (buf.trim() !== '') {
            chunks.push({
              id: 'P' + (chunks.length + 1),
              frame: label,
              text: (part > 1 ? '#### ' + label + ' (part ' + part + ')\n' : '') + buf.trim(),
              part: part
            });
          }
        }


        return {
          chunks: chunks,
          totalChunks: chunks.length,
          totalChars: totalChars,
          frames: frames,
          ok: chunks.length > 0 ? 'yes' : 'no'
        };
      param: passages
      timeout: 60000
      onFailure: ''
      silent: true
    - label: READ THE RECORD SIZE
      type: calc
      func: set
      param: recordChars
      value: '{{record.chars}}'
      format: ''
    - label: READ THE PASSAGE COUNT
      type: calc
      func: set
      param: passageCount
      value: '{{passages.totalChunks}}'
      format: ''
    - label: READ THE FRAME COUNT
      type: calc
      func: set
      param: frameCount
      value: '{{passages.frames}}'
      format: ''
    - label: WEIGH THE TWO METHODS
      type: js
      args: recordChars, passageCount, frameCount
      code: >-
        // FULL PAGE VISION · WHICH READING METHOD

        // Two ways to put a large record in front of a model, and a
        recommendation between them.

        //

        // WHAT THIS IS NOT ABOUT. It is not about the context window. Current
        models take hundreds of

        // thousands of tokens, some of them millions, and a record of this size
        is nowhere near any

        // of those limits. An earlier version of this hint claimed the request
        would be "cut off at

        // the end". That was simply false, and a false reason for a real
        recommendation is worse than

        // no reason at all.

        //

        // WHAT IT IS ABOUT. Accuracy falls as the context grows, long before
        the window does. The

        // effect is measured and consistent across model families: information
        in the middle of a

        // long input is retrieved far less reliably than information at either
        end, and the drop is

        // steep - the same models that answer near-perfectly at a couple of
        thousand tokens lose a

        // large part of that accuracy by the tens of thousands. The usable
        context is reliably

        // shorter than the advertised one.

        //

        // So the trade is not "fits" against "does not fit". It is:

        //

        //   FULL RECORD  everything is visible, and nothing is chosen for the
        model - but every fact

        //                it needs is buried among tens of thousands of tokens
        it does not.

        //   RETRIEVAL    only the passages this request needs travel, so the
        thing being asked about

        //                sits in a short context instead of in the middle of a
        long one - and each

        //                passage carries the frame it came from, so the answer
        can be checked.

        //

        // Below FLIP the record is short enough that position effects stay
        small and seeing

        // everything wins. Above it, a short focused context wins.

        const FLIP = 80000;


        const chars = Math.max(0, Number(args.recordChars) || 0);

        const passages = Math.max(0, Number(args.passageCount) || 0);

        const frames = Math.max(0, Number(args.frameCount) || 0);


        const tokens = Math.round(chars / 3.6);

        const recommended = chars > FLIP ? 'retrieval' : 'full';


        function n(v) { return v.toLocaleString('en-US'); }


        const head = '🧮 The record is **' + n(chars) + '** characters (~' +
        n(tokens) +
          ' tokens) across **' + n(frames) + '** frame(s), indexed into **' + n(passages) +
          '** passages.';

        const why = '\n\nThis is well inside any current context window — size
        is not the problem. ' +
          'Accuracy is: retrieval quality falls as the context grows, and anything sitting in the ' +
          'middle of a long input is the first thing a model loses.';

        const verdict = recommended === 'retrieval'
          ? '\n\n💡 **Retrieval is recommended at this length.** A short, focused context is read more ' +
            'reliably than a long one, and every passage names the frame it came from, so each claim ' +
            'can be checked against a screen.'
          : '\n\n💡 **Full record is recommended at this length.** It is still short enough that ' +
            'position effects stay small, and a model that sees every frame beats one that sees a ' +
            'good selection.';

        return { recommended: recommended, chars: chars, tokens: tokens,
        passages: passages,
                 frames: frames, hint: head + why + verdict };
      param: methodHint
      timeout: 30000
      onFailure: ''
      silent: true
    - condition: '{{methodHint.recommended}} = full'
      label: OFFER THE FULL RECORD FIRST
      type: ask
      message: >-
        {{methodHint.hint}}


        **📄 FULL RECORD** sends every frame, in order, in one request.

        **🔬 RETRIEVAL** cuts the record into passages at the frame boundaries,
        ranks them against the words of each request, widens that with the terms
        the record itself uses around the best hits, and sends only the
        strongest — each one labelled with the frame it came from. Quotes are
        checked against the record afterwards and corrected.
      param: method
      options:
        - label: 📄 FULL RECORD · every frame in a single request
          value: full
        - label: 🔬 RETRIEVAL · only the passages each request needs
          value: retrieval
      default: full
      vision:
        enabled: false
        mode: area
        hint: ''
        send: true
      optionsInvalid: false
    - condition: '{{methodHint.recommended}} = retrieval'
      label: OFFER RETRIEVAL FIRST
      type: ask
      message: >-
        {{methodHint.hint}}


        **📄 FULL RECORD** sends every frame, in order, in one request.

        **🔬 RETRIEVAL** cuts the record into passages at the frame boundaries,
        ranks them against the words of each request, widens that with the terms
        the record itself uses around the best hits, and sends only the
        strongest — each one labelled with the frame it came from. Quotes are
        checked against the record afterwards and corrected.
      param: method
      options:
        - label: 🔬 RETRIEVAL · only the passages each request needs
          value: retrieval
        - label: 📄 FULL RECORD · every frame in a single request
          value: full
      default: retrieval
      vision:
        enabled: false
        mode: area
        hint: ''
        send: true
      optionsInvalid: false
    - label: SETTLE THE READING METHOD
      type: js
      args: method, methodHint
      code: |
        const m = String(args.method || '').trim().toLowerCase();
        if (m === 'full' || m === 'retrieval') return m;
        try {
          const r = args.methodHint;
          if (r && typeof r === 'object' && r.recommended === 'full') return 'full';
        } catch (e) { /* fall through */ }
        return 'retrieval';
      param: method
      timeout: 30000
      onFailure: ''
      silent: true
    - condition: '{{method}} = retrieval'
      label: TURN ON THE CITATIONS
      type: calc
      func: set
      param: citationRule
      value: >
        [CITING THE EVIDENCE]

        The evidence above is a SELECTION from the record, not all of it. Every
        passage carries a number in square brackets.

        - Close every distinct claim with its own citation, written `[N]` with
        the passage number and nothing else. One claim, one citation; never
        combine numbers into `[1, 2]`, and never write a frame name next to the
        number.

        - Use only numbers that appear in the evidence.

        - End with a `**Source References**` block: one blockquote per distinct
        citation you used, holding a quote copied character for character out of
        the evidence, followed by `**[N]**`. Never paraphrase inside a quote and
        never shorten one with an ellipsis — every quote is located in the
        record afterwards, restored to its exact wording and re-pointed at the
        passage it truly sits in, so copying exactly is always in your interest.

        - If the request asks for code, a file, a document or a page rebuild,
        put NO citation markers inside the artifact itself — output it clean,
        and place the Source References block after it.

        - What this selection does not cover, name as not covered. Never fill it
        from anywhere else.
      format: ''
    - condition: '{{method}} = full'
      label: TURN OFF THE CITATIONS
      type: calc
      func: set
      param: citationRule
      value: ''
      format: ''
- condition: '{{method}} = full'
  label: USE THE WHOLE RECORD AS EVIDENCE
  type: group
  steps:
    - label: COPY THE RECORD INTO THE EVIDENCE SLOT
      type: calc
      func: set
      param: evidence
      value: '{{payload}}'
      format: ''
    - label: HARDEN THE EVIDENCE
      type: js
      args: evidence
      code: >-
        // FULL PAGE VISION · RE-ARMOUR THE EVIDENCE

        // The evidence arrives through a CALC step, and a CALC value is
        interpolated, which the

        // engine defines as consuming one layer of the `{{\` escape. So the
        text that reaches this

        // step has none left, and the prompt that receives it next would
        execute any `{{page}}` or

        // `{{serp x}}` the page happened to display. One layer goes back on,
        and the escape is

        // idempotent, so a value that still had one keeps exactly one.

        function harden(value) {
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        return harden(args.evidence);
      param: evidence
      timeout: 60000
      onFailure: ''
      silent: true
- condition: '{{pendingAnswer}} = yes'
  label: PRESELECT FOCUSED ANSWER
  type: group
  steps:
    - label: DISARM THE INSTRUCTION ANSWER
      type: calc
      func: set
      param: pendingAnswer
      value: 'no'
      format: ''
    - label: ROUTE TO THE FOCUSED ANSWER
      type: calc
      func: set
      param: format
      value: focus
      format: ''
    - label: SKIP THE OUTPUT MENU
      type: jump
      to: RESET OUTPUT STATE
- label: SELECT OUTPUT
  type: ask
  message: >-
    The whole page has been seen. What should be produced from it? Pick one, or
    type your own instruction or question about the page.
  param: format
  options:
    - label: ⚡️ QUICK · key takeaway plus summary
      value: quick
    - label: 📖 FULL · long-form report over the entire record
      value: full
    - label: 🧾 STRUCTURED · sectioned report with facts and figures
      value: structured
    - label: 🖼️ VISUALS · what the images, charts and diagrams show
      value: visuals
    - label: 📊 DATA · every number, table and chart as markdown tables
      value: data
    - label: 🎨 UI AUDIT · layout, hierarchy, calls to action, accessibility
      value: audit
    - label: 🖨️ REBUILD · the whole page rebuilt as a markdown document
      value: rebuild
    - label: 🧭 LINKS · every link, button and form field in its section
      value: links
    - label: 🗨️ QUOTING · verbatim citations with segment anchors
      value: quoting
    - label: 📜 RECORD · show the frame-by-frame reading itself
      value: record
    - label: 📦 EXPORT · download the answer or the full record
      value: export
    - label: ♻️ REPURPOSE · hand over to the Repurpose text command
      value: repurpose
    - value: $custom
  default: quick
  vision:
    enabled: false
    mode: area
    hint: ''
    send: true
  optionsInvalid: false
- label: RESET OUTPUT STATE
  type: group
  steps:
    - label: CLEAR FOLLOW UP STATE
      type: calc
      func: set
      param: change
      value: ''
      format: ''
    - label: CLEAR PREVIOUS ANSWER
      type: calc
      func: set
      param: gpt
      value: ''
      format: ''
- condition: '{{method}} = retrieval'
  label: PREPARE THE EVIDENCE
  type: group
  steps:
    - label: BUILD THE RETRIEVAL QUERY
      type: js
      args: format, focus, change
      code: >-
        // FULL PAGE VISION · WHAT THIS REQUEST IS LOOKING FOR

        // Turns the request at hand into something the index can be searched
        with. Three sources, in

        // order of precedence: a follow-up the user just typed, the instruction
        given before the

        // reading, and otherwise the output mode that was picked from the menu.

        //

        // The output modes are not questions, so they cannot be searched for as
        if they were. Each

        // one is described by the record labels it lives in and by the words
        the record uses inside

        // those labels - `**Visual:**` for a visual analysis, `**Table:**` and
        `**Data:**` for a data

        // extraction - and the ones that are inherently about the whole page
        are marked `spread`,

        // which makes the selector sample evenly across every frame instead of
        piling up wherever the

        // keywords happen to cluster. A summary built from the top of a ranking
        is not a summary.

        function clean(v) {
          return String(v == null ? '' : v).replace(/\s+/g, ' ').trim();
        }


        const MODES = {
          quick:      { intent: 'SUMMARY', spread: true,  labels: [], text: '' },
          full:       { intent: 'SUMMARY', spread: true,  labels: [], text: '' },
          structured: { intent: 'SUMMARY', spread: true,  labels: [], text: '' },
          rebuild:    { intent: 'SUMMARY', spread: true,  labels: [], text: '' },
          quoting:    { intent: 'SUMMARY', spread: true,  labels: ['Text', 'Heading'], text: '' },
          visuals:    { intent: 'TARGET',  spread: false, labels: ['Visual'],
                        text: 'image illustration photograph icon logo avatar chart diagram map screenshot figure colour layout typeface' },
          data:       { intent: 'TARGET',  spread: false, labels: ['Table', 'Data'],
                        text: 'number value price date percent currency unit table row column axis series total amount rating count' },
          audit:      { intent: 'TARGET',  spread: false, labels: ['UI'],
                        text: 'button link tab menu field form input checkbox dropdown search navigation label placeholder state disabled' },
          links:      { intent: 'TARGET',  spread: false, labels: ['UI'],
                        text: 'link button action navigation download tab menu href anchor call to action' }
        };


        // The values the two menus can put into these slots. A menu value is a
        routing token, not

        // something to search the record for: retrieving the passages that
        contain the word "shorten"

        // would answer a question nobody asked.

        const MENU = {
          shorten: 1, simplify: 1, clarify: 1, expand: 1, translate: 1, factCheck: 1,
          export: 1, repurpose: 1, return: 1, retry: 1, no: 1, record: 1, focus: 1
        };


        const followUp = clean(args.change);

        const mode = clean(args.format).toLowerCase();


        // A follow-up the user just typed outranks everything: it is the newest
        thing they said.

        if (followUp !== '' &&
            !Object.prototype.hasOwnProperty.call(MENU, followUp) &&
            !Object.prototype.hasOwnProperty.call(MODES, followUp)) {
          return { text: followUp, intent: 'QUERY', spread: false, labels: [], skip: false,
                   source: 'follow-up' };
        }


        // The instruction given before the reading, when the focused answer is
        what is being produced.

        let request = '';

        try {
          const f = args.focus;
          if (f && typeof f === 'object' && f.request) request = clean(f.request);
        } catch (e) { request = ''; }


        if (mode === 'focus' && request !== '') {
          return { text: request, intent: 'QUERY', spread: false, labels: [], skip: false,
                   source: 'instruction' };
        }


        // Two outputs never read the evidence at all: SHOW THE RECORD prints
        the whole record and

        // the export writes it to a file. Running a selection for them would
        burn a BM25 pass over

        // every passage to produce something nobody looks at.

        if (mode === 'record' || mode === 'export') {
          return { text: '', intent: 'NONE', spread: true, labels: [], skip: true,
                   source: 'not needed' };
        }


        const preset = Object.prototype.hasOwnProperty.call(MODES, mode) ?
        MODES[mode] : null;

        if (preset) {
          // The instruction from before the reading still colours a preset output, because it is
          // what the record was written to serve.
          const text = (preset.text + ' ' + request).trim();
          return { text: text, intent: preset.intent, spread: preset.spread,
                   labels: preset.labels, skip: false, source: 'output mode' };
        }


        // Anything else in the output menu is a free instruction typed by the
        user.

        if (mode !== '') {
          return { text: clean(args.format), intent: 'QUERY', spread: false, labels: [],
                   skip: false, source: 'instruction' };
        }


        return { text: request, intent: 'SUMMARY', spread: true, labels: [],
        skip: false,
                 source: 'whole record' };
      param: query
      timeout: 30000
      onFailure: ''
      silent: true
    - label: SELECT THE EVIDENCE
      type: js
      args: passages, query, payload
      code: >-
        // FULL PAGE VISION · EVIDENCE SELECTION

        // Picks the passages this one request needs and lays them out with a
        map that binds every

        // passage number to the frame it came from.

        //

        // The scoring is BM25 over the passage index, run once per keyword set
        and fused by

        // reciprocal rank, which is the standard way to combine several
        rankings without having to

        // make their scores comparable. Three sets are used:

        //

        //   A · the request's own words.

        //   B · dictionary-free prefix stems of them, so an inflected language
        still matches -

        //       "Schriftarten" in the request finds "Schriftart" in the record
        without anybody

        //       shipping a stemmer.

        //   C · the words the record itself uses around the first-pass hits.
        This is the classic

        //       pseudo-relevance feedback step: take the strongest passages
        from pass one, find the

        //       terms that are common inside them and rare everywhere else, and
        search again with

        //       those. It is the same job the source command gives to a model -
        "keywords that would

        //       appear inside the ANSWER passage itself, phrased the way this
        corpus phrases things" -

        //       except that here the corpus is asked instead of guessed, which
        costs nothing, cannot

        //       hallucinate a term, and is automatically in the record's own
        language and wording.

        //

        // Three things make this different from a plain search:

        //

        //   * LABEL BOOST. Our passages are not prose, they are labelled
        bullets. A data extraction

        //     wants the passages carrying `**Table:**` and `**Data:**`, and
        saying so directly beats

        //     hoping the word "table" happens to appear.

        //

        //   * SPREAD. A summary, a report or a page rebuild is about the whole
        page. Ranking cannot

        //     answer that question - the top of any ranking is wherever the
        keywords cluster - so

        //     these sample evenly across the frames instead, from the first to
        the last.

        //

        //   * NEIGHBOURS. A bullet cut at a passage border finishes in the next
        one. After the

        //     selection is made, a few immediate neighbours of the strongest
        hits are pulled in, in

        //     reading order, so a `**Cut:**` never arrives without its other
        half.

        //

        // The output is always in reading order, never in score order: the
        record's own sequence is

        // information, and a model handed frames 12, 3, 40, 7 has to
        reconstruct it before it can use

        // anything.

        const BUDGET = 40000;

        const RRF_K = 60;

        const MAX_PICK = 60;


        function norm(s) {
          return String(s == null ? '' : s)
            .normalize('NFKC')
            .replace(/[\u2010-\u2015\u2212]/g, '-')
            .replace(/[\u2018\u2019\u201C\u201D\u00AB\u00BB]/g, '"')
            .replace(/\s+/g, ' ')
            .toLowerCase()
            .trim();
        }


        const CJK =
        /[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\u0E00-\u0E7F]/;


        function words(s) {
          const out = [];
          const parts = norm(s).split(/[^\p{L}\p{N}\p{M}]+/u);
          for (let i = 0; i < parts.length; i++) {
            const p = parts[i];
            if (!p) continue;
            if (CJK.test(p)) {
              if (p.length <= 2) { out.push(p); continue; }
              for (let j = 0; j + 2 <= p.length; j++) out.push(p.substr(j, 2));
            } else if (p.length >= 3) {
              out.push(p);
            }
          }
          return out;
        }


        function uniq(list, cap) {
          const seen = {}, out = [];
          for (let i = 0; i < list.length; i++) {
            const k = list[i];
            if (!k || seen[k]) continue;
            seen[k] = 1;
            out.push(k);
            if (cap && out.length >= cap) break;
          }
          return out;
        }


        // A hit only counts when the term starts at a word boundary. Without
        that guard "art" scores

        // on "start", "particular" and "Artefakte", and a request about
        typefaces retrieves the

        // sidebar.

        const WORDCH = /[0-9\p{L}\p{M}]/u;

        function tf(hay, kw) {
          let n = 0, i = 0;
          while ((i = hay.indexOf(kw, i)) !== -1) {
            if (i === 0 || !WORDCH.test(hay.charAt(i - 1))) n++;
            i += kw.length;
          }
          return n;
        }


        let index = { chunks: [], totalChunks: 0, totalChars: 0 };

        try {
          const p = args.passages;
          if (p && typeof p === 'object' && p.chunks) index = p;
        } catch (e) { /* fall through to the whole record */ }


        const chunks = index.chunks || [];

        const fallback = String(args.payload == null ? '' : args.payload);


        if (!chunks.length) {
          return { text: fallback, stats: '', picked: 0, total: 0, ok: 'no', mode: 'whole record' };
        }


        let q = { text: '', intent: 'SUMMARY', spread: true, labels: [] };

        try {
          const raw = args.query;
          if (raw && typeof raw === 'object') q = raw;
        } catch (e) { /* keep the default */ }


        const labels = Array.isArray(q.labels) ? q.labels : [];

        const spread = q.spread === true || q.spread === 'true';


        // Some outputs do not read the evidence at all. Selecting for them
        would be a full BM25 pass

        // over every passage to produce something nobody looks at.

        if (q.skip === true || q.skip === 'true') {
          return { text: fallback, stats: '', picked: 0, total: 0, ok: 'no', mode: 'not needed',
                   views: 0, expanded: 0 };
        }


        const N = chunks.length;

        const lower = chunks.map(function (c) { return norm(c.text); });

        const lens = chunks.map(function (c) { return String(c.text).length; });

        let avgdl = 0;

        for (let i = 0; i < N; i++) avgdl += lens[i];

        avgdl = avgdl / Math.max(N, 1);


        // Which passages carry the labels this output lives in.

        const labelHit = chunks.map(function (c) {
          if (!labels.length) return false;
          for (let i = 0; i < labels.length; i++) {
            if (String(c.text).indexOf('**' + labels[i] + ':**') !== -1) return true;
          }
          return false;
        });


        function rank(kws) {
          const k1 = 1.5, b = 0.75, df = {};
          kws.forEach(function (kw) {
            let c = 0;
            for (let i = 0; i < N; i++) if (lower[i].indexOf(kw) !== -1) c++;
            df[kw] = c;
          });
          const scored = [];
          for (let i = 0; i < N; i++) {
            let sc = 0;
            for (let j = 0; j < kws.length; j++) {
              const t = tf(lower[i], kws[j]);
              if (t <= 0) continue;
              const idf = Math.log((N - df[kws[j]] + 0.5) / (df[kws[j]] + 0.5) + 1);
              sc += idf * (t * (k1 + 1)) / (t + k1 * (1 - b + b * lens[i] / avgdl));
            }
            if (sc > 0) scored.push({ i: i, s: sc });
          }
          scored.sort(function (a, b2) { return b2.s - a.s; });
          return scored;
        }


        const surface = uniq(words(q.text || ''), 24);

        const stems = uniq(surface.map(function (w) {
          if (w.length < 6) return '';
          const cut = Math.max(5, Math.ceil(w.length * 0.75));
          return cut < w.length ? w.slice(0, cut) : '';
        }).filter(Boolean), 24).filter(function (x) { return surface.indexOf(x)
        === -1; });


        const sets = [];

        if (surface.length) sets.push(surface);

        if (stems.length) sets.push(stems);


        const rrf = {};

        let bestRaw = 0;

        let views = 0;

        let firstPass = [];


        function fuse(kws) {
          const ranked = rank(kws);
          if (!ranked.length) return [];
          if (ranked[0].s > bestRaw) bestRaw = ranked[0].s;
          for (let r = 0; r < ranked.length && r < 80; r++) {
            rrf[ranked[r].i] = (rrf[ranked[r].i] || 0) + 1 / (RRF_K + r + 1);
          }
          views++;
          return ranked;
        }


        for (let si = 0; si < sets.length; si++) {
          const ranked = fuse(sets[si]);
          if (si === 0) firstPass = ranked;
        }


        // Set C · pseudo-relevance feedback. Only for a question somebody
        actually typed: a canned

        // word list for a preset output has nothing to expand, and expanding a
        spread query would

        // pull the sample towards whatever the first pass happened to like.

        let expansion = [];

        if (String(q.intent || '') === 'QUERY' && firstPass.length) {
          const top = firstPass.slice(0, 5).map(function (x) { return x.i; });
          const inTop = {};
          top.forEach(function (i) {
            const seen = {};
            words(chunks[i].text).forEach(function (w) {
              if (seen[w]) return;
              seen[w] = 1;
              inTop[w] = (inTop[w] || 0) + 1;
            });
          });
          const already = {};
          sets.forEach(function (set) { set.forEach(function (w) { already[w] = 1; }); });
          const cand = [];
          Object.keys(inTop).forEach(function (w) {
            if (already[w] || w.length < 4 || inTop[w] < 2) return;
            let df = 0;
            for (let i = 0; i < N; i++) if (lower[i].indexOf(w) !== -1) df++;
            if (df === 0 || df > N * 0.5) return;
            cand.push({ w: w, s: inTop[w] * Math.log(N / df) });
          });
          cand.sort(function (a, b2) { return b2.s - a.s; });
          expansion = cand.slice(0, 12).map(function (c) { return c.w; });
          if (expansion.length) fuse(expansion);
        }


        // The label boost is additive and deliberately large enough to outrank
        a weak lexical hit:

        // for a labelled output the label IS the query, and the words are only
        a tie-breaker.

        if (labels.length) {
          for (let i = 0; i < N; i++) {
            if (labelHit[i]) rrf[i] = (rrf[i] || 0) + 1 / RRF_K;
          }
        }


        let fused = Object.keys(rrf).map(function (k) {
          return { i: parseInt(k, 10), s: rrf[k] };
        });

        fused.sort(function (a, b2) { return b2.s - a.s; });


        let picked = [];

        let mode;


        if (spread || !fused.length) {
          // Even coverage of the whole record, in reading order, as many as the budget allows.
          // "no lexical match" is only honest when there were words to match with: a summary has no
          // question behind it, so nothing failed - it was never a search in the first place.
          mode = fused.length ? 'spread'
            : (sets.length ? 'spread (no lexical match)' : 'spread (whole record)');
          const step = Math.max(1, Math.floor(N / Math.max(1, Math.min(MAX_PICK, N))));
          for (let i = 0; i < N && picked.length < MAX_PICK; i += step) picked.push(i);
          // The first and the last frame anchor a summary: the page starts and ends somewhere.
          if (picked.indexOf(0) === -1) picked.unshift(0);
          if (picked.indexOf(N - 1) === -1) picked.push(N - 1);
          // Strong lexical hits are added on top, because a spread is a floor and not a ceiling.
          for (let f = 0; f < fused.length && f < 12; f++) {
            if (picked.indexOf(fused[f].i) === -1) picked.push(fused[f].i);
          }
        } else {
          mode = labels.length ? 'labelled + ranked' : 'ranked';
          for (let f = 0; f < fused.length && picked.length < MAX_PICK; f++) picked.push(fused[f].i);
        }


        // Neighbour recovery for the strongest hits, so a bullet cut at a
        border keeps its other half.

        const chosen = {};

        picked.forEach(function (i) { chosen[i] = 1; });

        let grown = 0;

        for (let p = 0; p < picked.length && grown < 8; p++) {
          const i = picked[p];
          if (i > 0 && !chosen[i - 1]) { chosen[i - 1] = 1; picked.push(i - 1); grown++; }
          if (grown >= 8) break;
          if (i + 1 < N && !chosen[i + 1]) { chosen[i + 1] = 1; picked.push(i + 1); grown++; }
        }


        // Budget in order of strength, then restore reading order.

        const strength = {};

        picked.forEach(function (i, r) { strength[i] = r; });

        picked.sort(function (a, b2) { return strength[a] - strength[b2]; });


        const selected = [];

        let used = 0;

        for (let p = 0; p < picked.length; p++) {
          const i = picked[p];
          if (used + lens[i] > BUDGET && selected.length > 0) continue;
          selected.push(i);
          used += lens[i];
        }

        selected.sort(function (a, b2) { return a - b2; });


        if (!selected.length) {
          return { text: fallback, stats: '', picked: 0, total: N, ok: 'no', mode: 'whole record' };
        }


        let map = '';

        let body = '';

        selected.forEach(function (i, k) {
          const n = k + 1;
          map += '[' + n + '] = ' + chunks[i].frame + '\n';
          body += '[' + n + '] ' + chunks[i].text + '\n\n---\n\n';
        });


        const text =
          '━━━ EVIDENCE MAP ━━━\n' + map +
          '━━━━━━━━━━━━━━━━━━━━\n\n' + body.replace(/\n+---\n+$/, '\n');

        const gaps = N - selected.length;

        const stats = '`' + selected.length + '/' + N + ' passages · ' +
          used.toLocaleString('en-US') + ' chars · ' + mode + ' · ' + views +
          ' query view' + (views === 1 ? '' : 's') +
          (expansion.length ? ' · expanded' : '') +
          (gaps > 0 ? ' · ' + gaps + ' passage(s) not sent' : ' · complete') + '`';

        return { text: text, stats: stats, picked: selected.length, total: N,
        ok: 'yes',
                 mode: mode, views: views, expanded: expansion.length };
      param: selection
      timeout: 60000
      onFailure: ''
      silent: true
    - label: ADOPT THE SELECTED EVIDENCE
      type: calc
      func: set
      param: evidence
      value: '{{selection.text}}'
      format: ''
    - label: HARDEN THE SELECTED EVIDENCE
      type: js
      args: evidence
      code: >-
        // FULL PAGE VISION · RE-ARMOUR THE EVIDENCE

        // The evidence arrives through a CALC step, and a CALC value is
        interpolated, which the

        // engine defines as consuming one layer of the `{{\` escape. So the
        text that reaches this

        // step has none left, and the prompt that receives it next would
        execute any `{{page}}` or

        // `{{serp x}}` the page happened to display. One layer goes back on,
        and the escape is

        // idempotent, so a value that still had one keeps exactly one.

        function harden(value) {
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        return harden(args.evidence);
      param: evidence
      timeout: 60000
      onFailure: ''
      silent: true
    - label: REPORT THE SELECTION
      type: say
      message: 🔎 {{selection.stats}}
- condition: '{{format}} = focus'
  label: FOCUSED ANSWER
  type: group
  steps:
    - label: GENERATE FOCUSED ANSWER
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        {{buildRule}}


        Whatever stands under [YOUR REQUEST] is what you have to do with the
        record. The frames were read with this request already in mind, so the
        record was written to serve it.


        [RULES]

        - Do what the request says and output the result alone: no preamble, no
        account of what you did, no closing remark. Where it asks for code or a
        document, the answer is that code or that document and nothing else.

        - Never reply that the record "does not contain" what was asked for. The
        record contains the page; what was asked for is what you build out of
        it. A request to build something can always be carried out, because the
        material is already there.

        - Take every word, number, heading, label, price, name and image
        description from the record and reproduce it exactly.

        - Use every piece of visual form the record gives you - typefaces,
        sizes, colours, spacing, alignment, layout, order. Where the record is
        silent, choose something consistent with what it does say and carry on;
        do not stop to ask and do not leave a placeholder for a decision you can
        make yourself.

        - Where the record marks a region `[unreadable]` or `NOT READ`, put a
        clearly marked placeholder at that spot and continue. A gap never stops
        the work.

        - If the request is a question rather than a task, answer it from the
        record and name the frame it came from.


        [YOUR REQUEST]:

        {{focus.request}}


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [ANSWER]:
      param: gpt
      isolated: true
    - label: AFTER FOCUSED ANSWER
      type: jump
      to: VERIFY OUTPUT
- condition: '{{format}} = quick'
  label: QUICK SUMMARY
  type: group
  steps:
    - label: GENERATE QUICK SUMMARY
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        {{groundRule}}


        [REPORT FORMAT]


        ### Key Takeaway

        The single most important point, one sentence.


        ### Summary

        Bullet points covering every substantive idea, fact and figure on the
        page, including what the visuals contribute. No word limit; completeness
        beats brevity, but do not pad. If the page holds a dialogue or thread,
        extract the main discussion points and name the most active
        participants. Do not use emoji.


        **Related queries:**


        ```markdown

        Short related query

        ```


        ```markdown

        Short related query

        ```


        ```markdown

        Short related query

        ```


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [REPORT FOLLOWED BY RELATED QUERIES]:
      param: gpt
      isolated: true
    - label: AFTER QUICK SUMMARY
      type: jump
      to: VERIFY OUTPUT
- condition: '{{format}} = full'
  label: FULL REPORT
  type: group
  steps:
    - label: GENERATE FULL REPORT
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        {{groundRule}}


        Write the long-form report. Work through the record from the first
        segment to the last so that no part of the page is under-represented.


        [REPORT FORMAT]


        ### Key Takeaway

        One sentence.


        ### Executive Summary

        Five to nine bullets.


        ### Detailed Findings

        Grouped by theme, one `####` subheading per theme, bullets underneath.
        Name the segment a finding came from where it helps.


        ### What The Visuals Show

        Bullets covering images, charts, diagrams and layout signals.


        ### Numbers, Dates And Named Entities

        A markdown table with the columns Item, Value, Context.


        ### Open Questions And Unreadable Regions

        Bullets. Include anything the record marks as `[unreadable]` or `NOT
        READ`.


        **Related queries:**


        ```markdown

        Short related query

        ```


        ```markdown

        Short related query

        ```


        ```markdown

        Short related query

        ```


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [FINAL REPORT]:
      param: gpt
      isolated: true
    - label: AFTER FULL REPORT
      type: jump
      to: VERIFY OUTPUT
- condition: '{{format}} = structured'
  label: STRUCTURED REPORT
  type: group
  steps:
    - label: GENERATE STRUCTURED REPORT
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        {{groundRule}}


        Turn the record into a structured analyst report. Every line must be
        traceable to it. Leave a section out entirely if the page contains
        nothing for it.


        [REPORT FORMAT]


        ### Subject And Purpose

        ### Core Statements

        ### Facts And Figures

        Markdown table: Item | Value | Context


        ### Visual Inventory

        Markdown table: Element | Type | What it shows


        ### Named Entities

        Markdown table: Name | Type | Role on the page


        ### Actions, Offers And Calls To Action

        ### Gaps, Caveats And Unreadable Regions


        **Related queries:**


        ```markdown

        Short related query

        ```


        ```markdown

        Short related query

        ```


        ```markdown

        Short related query

        ```


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [STRUCTURED REPORT]:
      param: gpt
      isolated: true
    - label: AFTER STRUCTURED REPORT
      type: jump
      to: VERIFY OUTPUT
- condition: '{{format}} = visuals'
  label: VISUAL ANALYSIS
  type: group
  steps:
    - label: GENERATE VISUAL ANALYSIS
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        {{groundRule}}


        Report exclusively on what was *seen* rather than read: photographs,
        illustrations, icons, charts, diagrams, maps, screenshots, layout and
        visual hierarchy. This is the part a plain text extraction would have
        thrown away.


        [REPORT FORMAT]


        ### What The Page Looks Like

        Two or three sentences on layout, structure and visual tone.


        ### Visual Elements

        One `####` block per element: what it depicts, what it communicates,
        where it sits and how it relates to the surrounding text.


        ### Data In Charts And Diagrams

        Axes, series and readable values as a markdown table where possible.
        Write "none present" if there are no charts.


        ### Anything Unreadable

        Regions the record marks as cut off, blurred or illegible.


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [VISUAL ANALYSIS]:
      param: gpt
      isolated: true
    - label: AFTER VISUAL ANALYSIS
      type: jump
      to: VERIFY OUTPUT
- condition: '{{format}} = data'
  label: DATA EXTRACTION
  type: group
  steps:
    - label: GENERATE DATA EXTRACTION
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        {{groundRule}}


        Pull every quantitative thing on the page out of the record and lay it
        out as data. Charts count: the record read their axes, series and labels
        off the pixels, and a plain text extraction could never have done that.


        [RULES]

        - One markdown table per source. Put the source above it as a `####`
        heading, e.g. `#### Pricing table (segment 2)`.

        - Reproduce every number, unit, currency and date exactly as recorded.
        Never round, never convert, never re-order rows.

        - For charts and diagrams, give the columns Series, Category, Value,
        Unit. Mark any value that was read off a bar or a line rather than
        printed as a label with `~` in front of it.

        - For specification lists, key-value blocks, pricing tiers and
        comparison grids, use the columns Item, Value, Context.

        - Where a cell was unreadable write `[unreadable]`, never a guess.

        - After the tables add a short `### Notes` section: units, time frames,
        footnotes, and anything that makes the numbers comparable or not
        comparable.

        - If the record contains no quantitative content at all, say exactly
        that in one sentence and stop.


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [DATA TABLES]:
      param: gpt
      isolated: true
    - label: AFTER DATA EXTRACTION
      type: jump
      to: VERIFY OUTPUT
- condition: '{{format}} = audit'
  label: UI AUDIT
  type: group
  steps:
    - label: GENERATE UI AUDIT
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        {{groundRule}}


        Audit this page the way a designer would who is looking at a screenshot,
        not at the markup. Judge only what the record actually describes:
        placement, prominence, hierarchy, density and visual tone.


        [REPORT FORMAT]


        ### First Five Seconds

        What a first-time visitor sees and understands before scrolling, in
        three sentences.


        ### Layout And Hierarchy

        How the page is built up, where the eye is led, whether the visual
        weight matches the importance of the content.


        ### Calls To Action

        Markdown table: Label | Where it sits | How prominent | What it seems to
        promise.


        ### Navigation And Orientation

        Menus, breadcrumbs, section markers, sticky elements, and whether a
        visitor can tell where they are.


        ### Friction And Interruptions

        Cookie dialogs, paywalls, modals, banners, ads, empty states,
        placeholders — anything that stands between the visitor and the content.


        ### Accessibility Signals

        Only what is visible in the screenshots: small text, thin contrast,
        icon-only controls without labels, colour used as the only distinction,
        cramped touch targets. Say plainly that this is a visual check, not a
        technical audit.


        ### Five Improvements

        Ranked, each one sentence, each tied to something concrete on the page.


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [UI AUDIT]:
      param: gpt
      isolated: true
    - label: AFTER UI AUDIT
      type: jump
      to: VERIFY OUTPUT
- condition: '{{format}} = rebuild'
  label: PAGE REBUILD
  type: group
  steps:
    - label: GENERATE PAGE REBUILD
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        {{groundRule}}


        Rebuild the page as a self-contained markdown document that a reader
        could use instead of the page itself. This is a reconstruction, not a
        summary: nothing is compressed, nothing is commented on.


        [RULES]

        - Keep the original reading order, from the first segment to the last.

        - Keep the heading hierarchy: page headings become `#`, `##`, `###` in
        the same nesting they had on screen.

        - Keep the body text as text, lists as lists, tables as markdown tables,
        code as fenced code blocks.

        - Put every image, chart, diagram, icon-with-meaning and photograph at
        the exact place it appeared, as a blockquote line starting with `> 🖼️`
        followed by what it shows.

        - Merge the overlap between consecutive frames silently: repeated
        headers, footers and navigation appear once, at the top, and are not
        repeated per segment.

        - Do not write segment markers, do not mention screenshots, do not add a
        preface or a conclusion.

        - Mark gaps inline as `> ⚠️ [unreadable]` where the record marks them.


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [REBUILT PAGE]:
      param: gpt
      isolated: true
    - label: AFTER PAGE REBUILD
      type: jump
      to: VERIFY OUTPUT
- condition: '{{format}} = links'
  label: LINKS AND ACTIONS
  type: group
  steps:
    - label: GENERATE LINKS AND ACTIONS
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        {{groundRule}}


        List everything on this page that a visitor could click, tap, type into
        or follow, grouped by the part of the page it sits in.


        [REPORT FORMAT]


        ### Inventory

        Markdown table: Label | Type | Section | What it appears to do.

        Type is one of: link, button, tab, menu item, form field, dropdown,
        checkbox, search, download, social, other.


        ### Primary Paths

        The three routes the page most obviously wants a visitor to take, each
        one sentence.


        ### Dead Ends And Doubts

        Controls whose purpose is not clear from the screenshot, labels that
        repeat with different targets, and anything the record marks as
        unreadable.


        Only list what the record actually contains. Never invent a URL: the
        record holds labels, not addresses, so write the label and say where it
        sits.


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [LINKS AND ACTIONS]:
      param: gpt
      isolated: true
    - label: AFTER LINKS AND ACTIONS
      type: jump
      to: VERIFY OUTPUT
- condition: '{{format}} = quoting'
  label: QUOTE EXTRACTION
  type: group
  steps:
    - label: ASK QUOTE TARGET
      type: ask
      message: >-
        Please ask a specific question so that I can quote something from the
        frames:
      param: quote
      options: null
      default: ''
      vision:
        enabled: false
        mode: area
        hint: ''
        send: true
      optionsInvalid: false
    - label: GENERATE QUOTE EXTRACTION
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        [INSTRUCTIONS FOR VERBATIM CITATION]

        1. Quote exclusively from the [EVIDENCE], which is a verbatim reading of
        the frames.

        2. Do not fabricate or invent any text content.

        3. Reproduce each quote exactly as recorded, without modification.

        4. Name the exact frame each quote came from, using the `#### Frame N`
        heading it sits under, and the segment marker above it.

        5. Quote complete units of meaning. Never cut a sentence or an argument
        in half; quote everything that belongs together.

        6. If nothing in the record matches, say so plainly instead of inventing
        a quote.


        [CITATION FORMAT]


        Frame: `[FRAME NUMBER]` (segment `[SEGMENT NUMBER]`)

        Quote:

        ```

        [EXACT QUOTE]

        ```

        Context: one sentence on where this sits on the page.


        [YOUR QUESTION]: {{quote}}


        [EVIDENCE]:

        {{evidence}}


        [CITATIONS]:
      param: gpt
      isolated: true
    - label: AFTER QUOTE EXTRACTION
      type: jump
      to: VERIFY OUTPUT
- condition: '{{format}} = record'
  label: SHOW THE RECORD
  type: group
  steps:
    - label: PRINT RECORD HEADER
      type: say
      message: >-
        📜 The frame-by-frame reading, {{shotCount}} frames in
        {{record.segments}} segment(s). This costs no request — it is already on
        disk.
    - label: PRINT RECORD
      type: say
      message: '{{payload}}'
    - label: AFTER RECORD
      type: jump
      to: FOLLOW UP MENU
- condition: '{{format-option}} = $custom'
  label: CUSTOM INSTRUCTION
  type: group
  steps:
    - label: APPLY CUSTOM INSTRUCTION
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write your entire answer in
        {{outputLanguage}}.


        {{promptHeader}}


        {{buildRule}}


        Whatever stands under [YOUR REQUEST] is what you have to do with the
        record.


        [RULES]

        - Do what the request says and output the result alone: no preamble, no
        account of what you did, no closing remark. Where it asks for code or a
        document, the answer is that code or that document and nothing else.

        - Never reply that the record "does not contain" what was asked for. The
        record contains the page; what was asked for is what you build out of
        it. A request to build something can always be carried out, because the
        material is already there.

        - Take every word, number, heading, label, price, name and image
        description from the record and reproduce it exactly.

        - Use every piece of visual form the record gives you - typefaces,
        sizes, colours, spacing, alignment, layout, order. Where the record is
        silent, choose something consistent with what it does say and carry on;
        do not stop to ask and do not leave a placeholder for a decision you can
        make yourself.

        - Where the record marks a region `[unreadable]` or `NOT READ`, put a
        clearly marked placeholder at that spot and continue. A gap never stops
        the work.

        - If the request is a question rather than a task, answer it from the
        record and name the frame it came from.


        [YOUR REQUEST]:

        {{format}}


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [ANSWER]:
      param: gpt
      isolated: true
    - label: AFTER CUSTOM INSTRUCTION
      type: jump
      to: VERIFY OUTPUT
- condition:
    - '{{format}} = export'
    - '{{change}} = export'
  label: EXPORT ARTIFACTS
  type: group
  steps:
    - label: ASK EXPORT FORMAT
      type: ask
      message: Which artifact should be written to disk?
      param: exportFormat
      options:
        - label: 📝 REPORT · the current answer as Markdown
          value: md
        - label: 🖼️ RECORD · the full frame-by-frame reading
          value: transcript
        - label: 🧩 JSON · answer and record combined
          value: json
        - label: 📃 PLAIN · everything as plain text
          value: txt
        - label: 📋 CLIPBOARD · copy the answer instead of downloading
          value: clipboard
      default: md
      vision:
        enabled: false
        mode: area
        hint: ''
        send: true
      optionsInvalid: false
    - label: WRITE EXPORT FILE
      type: js
      args: exportFormat, title, url, gpt, notes, shotCount, segment
      code: >-
        // FULL PAGE VISION · EXPORT

        // The native EXPORT step is unreliable, so everything is built here,
        and it is built

        // as a ladder rather than a single attempt:

        //   1. write a Blob and click a hidden anchor - the normal path;

        //   2. if the page forbids that, open the content in a new tab so it
        can still be

        //      saved by hand;

        //   3. if that is blocked too, copy the content to the clipboard;

        //   4. if even that fails, report honestly instead of pretending
        success.

        // The clipboard format skips straight to step three by design.

        function s(v) {
          // The record carries hardened braces so the engine never executes text lifted off a page.
          // A file on disk is never interpolated, so the armour comes off again here and the export
          // shows exactly what stood on the page.
          return String(v == null ? '' : v).replace(/\{\{\\/g, '{{');
        }


        function pad(v) { return String(v).padStart(2, '0'); }


        async function toClipboard(text) {
          try {
            if (navigator.clipboard && navigator.clipboard.writeText) {
              await navigator.clipboard.writeText(text);
              return true;
            }
          } catch (e) { /* fall through to the legacy path */ }
          try {
            const ta = document.createElement('textarea');
            ta.value = text;
            ta.setAttribute('readonly', '');
            ta.style.position = 'fixed';
            ta.style.top = '-1000px';
            document.body.appendChild(ta);
            ta.select();
            const done = document.execCommand('copy');
            document.body.removeChild(ta);
            return done === true;
          } catch (e) {
            return false;
          }
        }


        const format = s(args.exportFormat).toLowerCase() || 'md';

        const title = s(args.title).trim();

        const url = s(args.url).trim();

        const summary = s(args.gpt).trim();

        const transcript = s(args.notes).trim();


        const stamp = new Date();

        const slug = (title || 'full-page-vision')
          .toLowerCase()
          .replace(/[^a-z0-9]+/g, '-')
          .replace(/^-+|-+$/g, '')
          .slice(0, 60) || 'full-page-vision';
        const stampStr = stamp.getFullYear() + pad(stamp.getMonth() + 1) +
        pad(stamp.getDate()) +
          '-' + pad(stamp.getHours()) + pad(stamp.getMinutes());
        const isoDay = stamp.getFullYear() + '-' + pad(stamp.getMonth() + 1) +
        '-' + pad(stamp.getDate());


        // Front matter makes the file land in Obsidian, Logseq or Notion as a
        real note

        // instead of as a loose text blob.

        const frontMatter = [
          '---',
          'title: "' + title.replace(/"/g, '\\"') + '"',
          'source: ' + url,
          'captured: ' + isoDay,
          'frames: ' + s(args.shotCount),
          'segments: ' + s(args.segment),
          'reader: full-page-vision',
          '---',
          ''
        ].join('\n');


        let content = '';

        let mime = 'text/markdown;charset=utf-8';

        let ext = 'md';


        if (format === 'json') {
          mime = 'application/json;charset=utf-8';
          ext = 'json';
          content = JSON.stringify({
            title: title,
            url: url,
            generatedAt: stamp.toISOString(),
            frames: Number(s(args.shotCount).replace(/[^0-9]/g, '')) || 0,
            segments: Number(s(args.segment).replace(/[^0-9]/g, '')) || 0,
            answer: summary,
            visionRecord: transcript
          }, null, 2);
        } else if (format === 'txt') {
          mime = 'text/plain;charset=utf-8';
          ext = 'txt';
          content = [title, url, isoDay, '', summary, '', transcript].join('\n');
        } else if (format === 'transcript') {
          content = frontMatter + ['# ' + (title || 'Vision record'), '', summary ? '' : '', '---', '', transcript].join('\n');
        } else if (format === 'clipboard') {
          content = summary || transcript;
        } else {
          content = frontMatter + ['# ' + (title || 'Full page summary'), '', summary || transcript, ''].join('\n');
        }


        const filename = slug + '-' + stampStr + '.' + ext;

        const result = { ok: 'no', filename: filename, bytes: content.length,
        format: ext, via: 'none', message: '' };


        if (format === 'clipboard') {
          const copied = await toClipboard(content);
          result.ok = copied ? 'yes' : 'no';
          result.via = 'clipboard';
          result.message = copied
            ? 'Copied to the clipboard (' + content.length + ' characters).'
            : 'The clipboard was refused by this page. Try the Markdown download instead.';
          return result;
        }


        // Step one: the download.

        try {
          const blob = new Blob([content], { type: mime });
          const href = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = href;
          a.download = filename;
          a.style.display = 'none';
          document.body.appendChild(a);
          a.click();
          setTimeout(function () {
            try { document.body.removeChild(a); } catch (e) { /* ignore */ }
            try { URL.revokeObjectURL(href); } catch (e) { /* ignore */ }
          }, 1500);
          result.ok = 'yes';
          result.via = 'download';
          result.bytes = blob.size;
          result.message = 'Saved as ' + filename + ' (' + blob.size + ' bytes).';
          return result;
        } catch (e) {
          result.error = String((e && e.message) || e);
        }


        // Step two: a new tab the user can save by hand.

        try {
          const blob = new Blob([content], { type: mime });
          const href = URL.createObjectURL(blob);
          const win = window.open(href, '_blank');
          if (win) {
            result.ok = 'yes';
            result.via = 'tab';
            result.message = 'The download was blocked, so ' + filename + ' was opened in a new tab. Save it from there.';
            return result;
          }
        } catch (e) { /* fall through */ }


        // Step three: the clipboard.

        const copied = await toClipboard(content);

        result.ok = copied ? 'yes' : 'no';

        result.via = copied ? 'clipboard' : 'none';

        result.message = copied
          ? 'Downloads are blocked on this page, so the content was copied to the clipboard instead.'
          : 'This page blocks downloads, new tabs and the clipboard. Choose EXPORT again on a normal page.';
        return result;
      param: exportResult
      timeout: 60000
      onFailure: ''
      silent: true
    - condition: '{{exportResult.ok}} = yes'
      label: CONFIRM EXPORT
      type: say
      message: 📦 {{exportResult.message}}
    - condition: '{{exportResult.ok}} = no'
      label: REPORT EXPORT FAILURE
      type: say
      message: ⚠️ {{exportResult.message}}
    - label: AFTER EXPORT
      type: jump
      to: FOLLOW UP MENU
- condition:
    - '{{format}} = repurpose'
    - '{{change}} = repurpose'
  label: REPURPOSE HANDOFF
  type: group
  steps:
    - label: STAGE REPURPOSE INPUT
      type: js
      args: gpt, payload
      code: >-
        // FULL PAGE VISION · STAGE A HANDOVER

        // Picks what to hand to the other command - the current answer if there
        is one, the record

        // otherwise - and armours it for exactly one interpolation hop.

        //

        // This used to be a CALC step, `handoff = '{{gpt}}'`, with a second
        CALC falling back to

        // `{{payload}}`. Both of those are interpolations, and the engine
        consumes one layer of the

        // `{{\` escape per interpolation. So the staged text arrived stripped,
        and the very next hop

        // - `inputs: ['{{handoff}}']` - interpolated it again, this time with
        nothing protecting it:

        // a `{{page}}` or `{{serp x}}` transcribed off the page went live on
        the way into the other

        // command. Selecting the source in JS means no interpolation happens
        here at all, and the one

        // layer added at the end is spent by the handover itself, so the
        receiving command is given

        // the literal text that stood on the page.

        function strip(value) {
          return String(value == null ? '' : value).replace(/\{\{\\+/g, '{{');
        }


        function harden(value) {
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        const answer = strip(args.gpt).trim();

        const record = strip(args.payload).trim();

        const text = answer !== '' ? answer : record;


        if (text === '') return '';

        return harden(text);
      param: handoff
      timeout: 30000
      onFailure: ''
      silent: true
    - label: CALL REPURPOSE COMMAND
      type: command
      name: Repurpose text
      inputs:
        - '{{handoff}}'
      silent: false
    - label: AFTER REPURPOSE
      type: jump
      to: FOLLOW UP MENU
- label: VERIFY OUTPUT
  type: group
  steps:
    - label: HARDEN ANSWER
      type: js
      args: gpt
      code: >-
        // FULL PAGE VISION · HARDEN TEXT

        // The one piece of armour this command cannot do without.

        //

        // The frames are read verbatim, so whatever stands on the page ends up
        inside a parameter -

        // and on a page that documents a template language, that includes
        things like {{page}},

        // {{serp query}} or {{transcript}}. The moment such a record is
        interpolated into the next

        // prompt, those look exactly like real system parameters: the engine
        goes off and fetches a

        // whole page, runs a web search or pulls a transcript, once per
        occurrence, for every answer

        // that carries the record. A documentation page with forty examples in
        it turns a two second

        // step into minutes of work that nobody asked for. It looks like a
        hang; it is a record

        // quoting the engine's own syntax back at it.

        //

        // The cure is the escape the engine itself defines: a backslash after
        the opening braces

        // marks them as literal. The text still reads as {{page}} for a human
        and for the model, it

        // simply stops being a command. Idempotent, so text that has already
        been hardened is left

        // alone rather than growing a second backslash on every pass.

        function harden(value) {
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        // The step is used on several different slots, so it accepts whichever
        one it was given.

        const candidates = ['payload', 'gpt', 'notes', 'text', 'condensed',
        'segmentNotes'];

        let raw = '';

        for (let i = 0; i < candidates.length; i++) {
          if (args[candidates[i]] != null && String(args[candidates[i]]) !== '') {
            raw = args[candidates[i]];
            break;
          }
        }

        return harden(raw);
      param: gpt
      timeout: 30000
      onFailure: ''
      silent: true
    - condition: '{{method}} = retrieval'
      label: VERIFY THE FRAME CITATIONS
      type: js
      args: gpt, evidence
      code: >-
        // FULL PAGE VISION · CITATION REPAIR

        // Runs after an answer that was written from a selection rather than
        from the whole record.

        // It takes every quote the answer put in its Source References block,
        finds where that text

        // really sits in the evidence, and rewrites the citation to point at
        the passage it actually

        // came from. Quotes it cannot find anywhere are removed and named.

        //

        // The reason for repairing rather than merely checking: a model that is
        told its quotes will

        // be located and corrected has no incentive to approximate, and a
        citation that survives this

        // step is one a reader can act on - scroll to that frame, look at that
        screen, see the thing.

        // A citation that is only asserted is worth nothing.

        //

        // Two stages, and the separation is the whole design.

        //

        //   LOCATE works on characters, and is allowed to be generous. Its only
        job is to find the

        //   passage; a stray digit or a collapsed line break must not hide text
        that is really there.

        //

        //   JUDGE works on ordered content tokens, and is not generous at all.
        Character similarity

        //   cannot tell "the button is disabled" from "the button is not
        disabled" - two characters

        //   apart, opposite meanings. Ordered tokens can, and the numeric,
        negation and modal guards

        //   below catch the rest: swapping a number, dropping a "not", or
        turning "may" into "must"

        //   all fail even when every other word matches.

        function norm(s) {
          return String(s == null ? '' : s)
            .normalize('NFKC')
            .replace(/[\u200B-\u200F\u00AD\uFEFF]/g, '')
            .replace(/[\u2018\u2019\u201C\u201D\u00AB\u00BB\u201E]/g, '"')
            .replace(/[\u2010-\u2015\u2212]/g, '-')
            .replace(/\s+/g, ' ')
            .toLowerCase()
            .trim();
        }


        function lev(a, b) {
          const m = a.length, n = b.length;
          if (!m) return n;
          if (!n) return m;
          let prev = new Array(n + 1), cur = new Array(n + 1);
          for (let j = 0; j <= n; j++) prev[j] = j;
          for (let i = 1; i <= m; i++) {
            cur[0] = i;
            for (let j = 1; j <= n; j++) {
              cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1,
                prev[j - 1] + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1));
            }
            const t = prev; prev = cur; cur = t;
          }
          return prev[n];
        }


        function lcs(a, b) {
          const m = a.length, n = b.length;
          if (!m || !n) return 0;
          let prev = new Array(n + 1).fill(0), cur = new Array(n + 1).fill(0);
          for (let i = 1; i <= m; i++) {
            cur[0] = 0;
            for (let j = 1; j <= n; j++) {
              cur[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1]);
            }
            const t = prev; prev = cur; cur = t;
          }
          return prev[n];
        }


        const STOP = { the: 1, a: 1, an: 1, of: 1, to: 1, in: 1, and: 1, or: 1,
        is: 1, are: 1, be: 1,
          as: 1, that: 1, this: 1, it: 1, for: 1, on: 1, with: 1, by: 1, at: 1, from: 1, was: 1,
          were: 1, der: 1, die: 1, das: 1, und: 1, ist: 1, von: 1, zu: 1, den: 1, dem: 1, ein: 1,
          eine: 1, im: 1, auf: 1, mit: 1, als: 1 };
        const NEG =
        /^(?:not|no|never|none|nor|without|neither|cannot|nicht|kein|keine|keiner|nie|niemals|ohne|weder)$/;

        const CRIT =
        /^(?:must|shall|should|may|might|can|could|will|would|always|never|all|every|any|none|some|most|few|only|more|less|higher|lower|larger|smaller|enabled|disabled|active|inactive|muss|kann|soll|darf|immer|nie|alle|jeder|einige|nur|mehr|weniger)$/;


        function tokens(s) {
          return norm(s).split(/[^\p{L}\p{N}\p{M}]+/u).filter(Boolean);
        }

        function content(s) {
          return tokens(s).filter(function (w) { return !STOP[w]; });
        }


        // A character map back into the original text, so a located quote can
        be restored to the

        // exact wording the record carries rather than to the model's rendering
        of it.

        function mapped(src) {
          const raw = String(src == null ? '' : src);
          let out = '';
          const map = [];
          for (let i = 0; i < raw.length; i++) {
            let c = raw.charAt(i);
            if (/\s/.test(c)) {
              if (out.charAt(out.length - 1) === ' ') continue;
              c = ' ';
            } else {
              c = norm(c);
              if (!c) continue;
            }
            out += c;
            map.push(i);
          }
          return { text: out, map: map };
        }


        const answer = String(args.gpt == null ? '' : args.gpt);

        const evidence = String(args.evidence == null ? '' : args.evidence);


        if (answer.trim() === '' || evidence.trim() === '') {
          return { answer: answer, audit: '', total: 0, verified: 0, ok: 'no' };
        }


        // The passage boundaries, so a located offset can be turned back into a
        passage number, and

        // the map that binds each number to a frame.

        const marks = [];

        evidence.replace(/\[(\d+)\]\s/g, function (m, n, off) {
          marks.push({ at: off, n: parseInt(n, 10) });
          return m;
        });

        const stripped = evidence.replace(/\[(\d+)\]\s/g, function (m) {
          return new Array(m.length + 1).join(' ');
        });

        function passageAt(off) {
          let p = 0;
          for (let i = 0; i < marks.length; i++) {
            if (marks[i].at <= off) p = marks[i].n; else break;
          }
          return p;
        }


        const FRAME = {};

        String(evidence).split('\n').slice(0, 400).forEach(function (line) {
          const m = line.match(/^\[(\d+)\]\s*=\s*(.+)$/);
          if (m) FRAME[m[1]] = m[2].trim();
        });


        const H = mapped(stripped);


        function locate(quote) {
          const q = norm(quote);
          if (q.length < 12) return null;
          const at = H.text.indexOf(q);
          if (at > -1) return { at: at, len: q.length, sc: 1 };

          const w = q.split(' ').filter(Boolean);
          const mid = w.length >> 1;
          const anchors = [];
          [w.slice(0, 4).join(' '), w.slice(-4).join(' '), w.slice(mid, mid + 4).join(' ')]
            .forEach(function (a) { if (a.length >= 6 && anchors.indexOf(a) === -1) anchors.push(a); });

          let best = null, tries = 0;
          const factors = [0.9, 1.0, 1.15, 1.35];
          for (let ai = 0; ai < anchors.length && tries < 600; ai++) {
            let i = -1;
            while ((i = H.text.indexOf(anchors[ai], i + 1)) !== -1 && tries < 600) {
              const shifts = [0, -(q.length >> 2), -(q.length >> 1)];
              for (let si = 0; si < shifts.length; si++) {
                const st = Math.max(0, i + shifts[si]);
                for (let fi = 0; fi < factors.length; fi++) {
                  const seg = H.text.substr(st, Math.round(q.length * factors[fi]));
                  if (seg.length < 8) continue;
                  tries++;
                  const sc = 1 - lev(q, seg) / Math.max(q.length, seg.length);
                  if (!best || sc > best.sc) best = { at: st, len: seg.length, sc: sc };
                }
              }
            }
          }
          return best && best.sc >= 0.55 ? best : null;
        }


        function judge(quote, window) {
          const q = norm(quote), w = norm(window);
          const charSim = 1 - lev(q, w) / Math.max(q.length, w.length);
          const cq = content(quote), cw = content(window);
          if (!cq.length) return 'unsupported';

          const order = lcs(cq, cw) / cq.length;
          const bag = {};
          let hits = 0;
          cw.forEach(function (t) { bag[t] = (bag[t] || 0) + 1; });
          cq.forEach(function (t) { if (bag[t]) { bag[t]--; hits++; } });
          const coverage = hits / cq.length;

          // Same words in a different order is the signature of an inverted claim. For a real quote
          // the two measures move together, so only genuine reordering separates them.
          const dice = (2 * hits) / (cq.length + cw.length);
          const ordDice = (2 * lcs(cq, cw)) / (cq.length + cw.length);
          if (dice - ordDice > 0.10) return 'unsupported';

          const dq = q.match(/\d+(?:[.,]\d+)?/g) || [];
          const dw = w.match(/\d+(?:[.,]\d+)?/g) || [];
          if (!dq.every(function (x) { return dw.indexOf(x) !== -1; })) return 'unsupported';

          const tq = tokens(q), tw = tokens(w);
          const have = {};
          tw.forEach(function (x) { have[x] = 1; });
          if (!tq.filter(function (x) { return CRIT.test(x); })
                .every(function (x) { return have[x]; })) return 'unsupported';
          if (tq.filter(function (x) { return NEG.test(x); }).length !==
              tw.filter(function (x) { return NEG.test(x); }).length) return 'unsupported';

          if (charSim >= 0.97 && order >= 0.97) return 'verbatim';
          if (cq.length >= 3 && order >= 0.80 && coverage >= 0.85) return 'near';
          return 'unsupported';
        }


        // `[1]` is the citation form. The optional tail matches the older `[1 ·
        Frame 3]` shape so

        // that a model which produces it anyway is repaired into the plain form
        rather than left

        // alone: one scheme in the answer, always.

        const CITE = /\[\s*(\d+)\s*(?:[·:\-\u2013][^\]]*)?\]/g;

        const WRAP =
        /^[\s"'\u00AB\u00BB\u201C\u201D\u201E\u2018\u2019]+|[\s"'\u00AB\u00BB\u201C\u201D\u201E\u2018\u2019]+$/g;


        const lines = answer.split(/\r?\n/);

        const keep = [];

        let total = 0, exact = 0, near = 0, recited = 0;

        const dropped = [];

        const good = {}, bad = {};


        for (let li = 0; li < lines.length; li++) {
          const line = lines[li];
          if (!/^\s*>/.test(line)) { keep.push(line); continue; }

          const body = line.replace(/^\s*>\s?/, '');
          const ids = [];
          let m;
          CITE.lastIndex = 0;
          while ((m = CITE.exec(body)) !== null) ids.push(m[1]);

          const bare = body.replace(CITE, '').replace(/\*\*/g, '')
            .replace(/\[[^\]]*\]/g, '').replace(WRAP, '').trim();
          if (bare.length < 12) { keep.push(line); continue; }

          total++;
          const hit = locate(bare);
          const tier = hit ? judge(bare, H.text.substr(hit.at, hit.len)) : 'unsupported';

          if (tier === 'unsupported') {
            ids.forEach(function (id) { bad[id] = 1; });
            dropped.push(bare.length > 88 ? bare.slice(0, 88) + '…' : bare);
            continue;
          }

          const s0 = H.map[hit.at];
          const e0 = H.map[Math.min(hit.at + hit.len - 1, H.map.length - 1)];
          let original = stripped.slice(s0, e0 + 1).replace(/\s+/g, ' ').replace(WRAP, '').trim();
          if (!original) original = bare;

          // The citation the reader sees is the passage number and nothing else. The frame stays on
          // the evidence side, where it grounds the model and lets this step repair a wrong number;
          // it is not repeated in the answer, where it would only be noise beside the number that
          // already identifies the passage.
          const trueId = String(passageAt(s0));
          const cite = '[' + trueId + ']';
          if (ids.length && ids[0] !== trueId) recited++;
          ids.forEach(function (id) { good[id] = 1; });
          good[trueId] = 1;

          if (tier === 'verbatim') { exact++; keep.push('> "' + original + '" **' + cite + '**'); }
          else { near++; keep.push('> "' + original + '" **' + cite + '** 〰️'); }
        }


        let clean = keep.join('\n').replace(/\n{3,}/g, '\n\n').trim();


        let marked = 0;

        if (total > 0) {
          clean = clean.replace(CITE, function (full, id) {
            if (bad[id] && !good[id]) { marked++; return '[⚠️ unverified]'; }
            return full;
          });
        }

        clean = clean.replace(/\s+([.,;:!?])/g, '$1').replace(/[ \t]{2,}/g, '
        ').trim();


        const verified = exact + near;

        let audit;

        if (total === 0) {
          audit = '';
        } else {
          const bits = [exact + ' verbatim'];
          if (near) bits.push(near + ' near-verbatim 〰️');
          if (dropped.length) bits.push(dropped.length + ' unsupported');
          audit = '🛡️ **Citation audit** · ' + verified + '/' + total + ' verified · ' + bits.join(' · ');
          if (recited) {
            audit += '\n> 🔢 ' + recited + ' citation number' + (recited === 1 ? '' : 's') +
              ' corrected to the passage the quote really sits in';
          }
          const mapped = Object.keys(FRAME).length;
          if (mapped) audit += '\n> 🗺️ ' + mapped + ' passage(s) in the evidence map';
          if (marked) {
            audit += '\n> ⚠️ ' + marked + ' claim' + (marked === 1 ? '' : 's') +
              ' left without support — marked inline';
          }
          dropped.forEach(function (d) { audit += '\n> ❌ not found in the evidence: ' + d; });
        }


        // The record is armoured; the audit quotes it back into a chat message,
        so it is armoured too.

        audit = audit.replace(/\{\{(?!\\)/g, '{{\\');


        return { answer: clean, audit: audit, total: total, verified: verified,
                 ok: total > 0 ? 'yes' : 'no' };
      param: citationCheck
      timeout: 60000
      onFailure: ''
      silent: true
    - condition: '{{citationCheck.ok}} = yes'
      label: ADOPT THE REPAIRED ANSWER
      type: calc
      func: set
      param: gpt
      value: '{{citationCheck.answer}}'
      format: ''
    - condition: '{{citationCheck.ok}} = yes'
      label: REARMOUR THE REPAIRED ANSWER
      type: js
      args: gpt
      code: >-
        // FULL PAGE VISION · RE-ARMOUR AN ANSWER

        // One layer of the `{{\` escape, applied idempotently. Used wherever an
        answer has just

        // passed through a CALC value, which the engine defines as consuming
        exactly one layer.

        function harden(value) {
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        return harden(args.gpt);
      param: gpt
      timeout: 60000
      onFailure: ''
      silent: true
    - condition: '{{citationCheck.ok}} = yes'
      label: SHOW THE CITATION AUDIT
      type: say
      message: '{{citationCheck.audit}}'
    - label: CHECK ANSWER
      type: js
      args: gpt
      code: >-
        // FULL PAGE VISION · ANSWER CHECK

        // Every generated answer passes through here before the follow up menu
        is offered.

        // An empty or stub answer is not treated as a result, so the menu can
        offer a real

        // retry instead of letting the run end on a blank message.

        const raw = String(args.gpt == null ? '' : args.gpt).trim();

        const chars = raw.length;

        return {
          ok: chars > 20 ? 'yes' : 'no',
          chars: chars,
          words: chars > 0 ? raw.split(/\s+/).length : 0
        };
      param: answerCheck
      timeout: 30000
      onFailure: ''
      silent: true
    - condition: '{{answerCheck.ok}} = no'
      label: WARN EMPTY ANSWER
      type: say
      message: >-
        ⚠️ The model returned nothing usable for this output. The record is
        still loaded — pick **RETURN BACK** below and choose an output again, or
        type what you want instead. No frame is captured a second time.
- label: FOLLOW UP MENU
  type: ask
  message: >-
    Anything else for this page? Pick one, or type another instruction or
    question — the record stays loaded, so nothing is captured again.
  param: change
  options:
    - label: ⬇️ SHORTEN · keep only the essentials
      value: shorten
    - label: 💬 SIMPLIFY · plainer language
      value: simplify
    - label: 🔍 CLARIFY · sharper and less ambiguous
      value: clarify
    - label: 📐 EXPAND · more depth and detail
      value: expand
    - label: 🌍 TRANSLATE · the same result in another language
      value: translate
    - label: 🕵️ FACT CHECK · hand over to the Fact check command
      value: factCheck
    - label: 📦 EXPORT · download or copy the result
      value: export
    - label: ♻️ REPURPOSE · hand over to the Repurpose text command
      value: repurpose
    - label: 🔙 RETURN BACK · another output from the same record
      value: return
    - value: $custom
    - label: 🔄 READING METHOD · switch between the full record and retrieval
      value: method
  default: return
  vision:
    enabled: false
    mode: area
    hint: ''
    send: true
  optionsInvalid: false
- condition: '{{change}} = translate'
  label: TRANSLATE RESULT
  type: group
  steps:
    - label: ASK TARGET LANGUAGE
      type: ask
      message: Into which language should the current result be translated?
      param: targetLanguage
      options: null
      default: English
      vision:
        enabled: false
        mode: area
        hint: ''
        send: true
      optionsInvalid: false
    - label: TRANSLATE THE ANSWER
      type: gpt
      prompt: >-
        Please ignore all previous instructions.


        Translate the [TEXT] into {{targetLanguage}}. Output nothing but the
        translation: no preamble, no quotes around it, no commentary. Keep the
        markdown structure, the headings, the tables and the code blocks exactly
        as they are. Keep numbers, units, dates, proper nouns and quoted
        passages faithful; translate the surrounding prose.


        [TEXT]:

        {{gpt}}


        [TRANSLATION]:
      param: gpt
      isolated: true
    - label: AFTER TRANSLATION
      type: jump
      to: VERIFY OUTPUT
- condition: '{{change}} = export'
  label: ROUTE FOLLOW UP EXPORT
  type: jump
  to: EXPORT ARTIFACTS
- condition: '{{change}} = repurpose'
  label: ROUTE FOLLOW UP REPURPOSE
  type: jump
  to: REPURPOSE HANDOFF
- condition: '{{change}} = return'
  label: ROUTE BACK TO OUTPUT MENU
  type: jump
  to: SELECT OUTPUT
- condition:
    - '{{change}} = shorten'
    - '{{change}} = simplify'
    - '{{change}} = clarify'
    - '{{change}} = expand'
  label: REVISE RESULT
  type: group
  steps:
    - condition: '{{change}} = shorten'
      label: INSTRUCTION SHORTEN
      type: calc
      func: set
      param: changeInstruction
      value: >-
        Make it shorter, keeping only the most essential points. Drop repetition
        and padding, never drop a fact that carries weight.
      format: ''
    - condition: '{{change}} = simplify'
      label: INSTRUCTION SIMPLIFY
      type: calc
      func: set
      param: changeInstruction
      value: >-
        Simplify the text and rewrite it in more accessible language. Explain
        the terms that need explaining, keep the substance intact.
      format: ''
    - condition: '{{change}} = clarify'
      label: INSTRUCTION CLARIFY
      type: calc
      func: set
      param: changeInstruction
      value: >-
        Add clarity and conciseness. The text should be unambiguous, well
        ordered and easy to follow.
      format: ''
    - condition: '{{change}} = expand'
      label: INSTRUCTION EXPAND
      type: calc
      func: set
      param: changeInstruction
      value: >-
        Expand it. Add the depth, detail, figures and context that the record
        contains but the current answer left out.
      format: ''
    - label: APPLY REVISION
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write only in
        {{outputLanguage}}.


        Revise the [TEXT TO CHANGE] according to the [CHANGES TO MAKE]. Output
        nothing but the revised reply: do not echo this command, do not put
        quotes around the result, do not add commentary. Stay grounded in the
        [EVIDENCE] and never add facts it does not contain.


        [CHANGES TO MAKE]:

        {{changeInstruction}}


        [TEXT TO CHANGE]:

        {{gpt}}


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [REVISED REPLY]:
      param: gpt
      isolated: true
    - label: AFTER REVISION
      type: jump
      to: VERIFY OUTPUT
- condition: '{{change}} = factCheck'
  label: FACT CHECK HANDOFF
  type: group
  steps:
    - label: STAGE FACT CHECK INPUT
      type: js
      args: gpt, payload
      code: >-
        // FULL PAGE VISION · STAGE A HANDOVER

        // Picks what to hand to the other command - the current answer if there
        is one, the record

        // otherwise - and armours it for exactly one interpolation hop.

        //

        // This used to be a CALC step, `handoff = '{{gpt}}'`, with a second
        CALC falling back to

        // `{{payload}}`. Both of those are interpolations, and the engine
        consumes one layer of the

        // `{{\` escape per interpolation. So the staged text arrived stripped,
        and the very next hop

        // - `inputs: ['{{handoff}}']` - interpolated it again, this time with
        nothing protecting it:

        // a `{{page}}` or `{{serp x}}` transcribed off the page went live on
        the way into the other

        // command. Selecting the source in JS means no interpolation happens
        here at all, and the one

        // layer added at the end is spent by the handover itself, so the
        receiving command is given

        // the literal text that stood on the page.

        function strip(value) {
          return String(value == null ? '' : value).replace(/\{\{\\+/g, '{{');
        }


        function harden(value) {
          return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
        }


        const answer = strip(args.gpt).trim();

        const record = strip(args.payload).trim();

        const text = answer !== '' ? answer : record;


        if (text === '') return '';

        return harden(text);
      param: handoff
      timeout: 30000
      onFailure: ''
      silent: true
    - label: CALL FACT CHECK COMMAND
      type: command
      name: Fact check
      inputs:
        - '{{handoff}}'
      silent: false
    - label: AFTER FACT CHECK
      type: jump
      to: FOLLOW UP MENU
- condition: '{{change-option}} = $custom'
  label: CUSTOM FOLLOW UP
  type: group
  steps:
    - condition: '{{method}} = retrieval'
      label: PREPARE THE EVIDENCE FOR THE FOLLOW UP
      type: group
      steps:
        - label: BUILD THE FOLLOW UP QUERY
          type: js
          args: format, focus, change
          code: >-
            // FULL PAGE VISION · WHAT THIS REQUEST IS LOOKING FOR

            // Turns the request at hand into something the index can be
            searched with. Three sources, in

            // order of precedence: a follow-up the user just typed, the
            instruction given before the

            // reading, and otherwise the output mode that was picked from the
            menu.

            //

            // The output modes are not questions, so they cannot be searched
            for as if they were. Each

            // one is described by the record labels it lives in and by the
            words the record uses inside

            // those labels - `**Visual:**` for a visual analysis, `**Table:**`
            and `**Data:**` for a data

            // extraction - and the ones that are inherently about the whole
            page are marked `spread`,

            // which makes the selector sample evenly across every frame instead
            of piling up wherever the

            // keywords happen to cluster. A summary built from the top of a
            ranking is not a summary.

            function clean(v) {
              return String(v == null ? '' : v).replace(/\s+/g, ' ').trim();
            }


            const MODES = {
              quick:      { intent: 'SUMMARY', spread: true,  labels: [], text: '' },
              full:       { intent: 'SUMMARY', spread: true,  labels: [], text: '' },
              structured: { intent: 'SUMMARY', spread: true,  labels: [], text: '' },
              rebuild:    { intent: 'SUMMARY', spread: true,  labels: [], text: '' },
              quoting:    { intent: 'SUMMARY', spread: true,  labels: ['Text', 'Heading'], text: '' },
              visuals:    { intent: 'TARGET',  spread: false, labels: ['Visual'],
                            text: 'image illustration photograph icon logo avatar chart diagram map screenshot figure colour layout typeface' },
              data:       { intent: 'TARGET',  spread: false, labels: ['Table', 'Data'],
                            text: 'number value price date percent currency unit table row column axis series total amount rating count' },
              audit:      { intent: 'TARGET',  spread: false, labels: ['UI'],
                            text: 'button link tab menu field form input checkbox dropdown search navigation label placeholder state disabled' },
              links:      { intent: 'TARGET',  spread: false, labels: ['UI'],
                            text: 'link button action navigation download tab menu href anchor call to action' }
            };


            // The values the two menus can put into these slots. A menu value
            is a routing token, not

            // something to search the record for: retrieving the passages that
            contain the word "shorten"

            // would answer a question nobody asked.

            const MENU = {
              shorten: 1, simplify: 1, clarify: 1, expand: 1, translate: 1, factCheck: 1,
              export: 1, repurpose: 1, return: 1, retry: 1, no: 1, record: 1, focus: 1
            };


            const followUp = clean(args.change);

            const mode = clean(args.format).toLowerCase();


            // A follow-up the user just typed outranks everything: it is the
            newest thing they said.

            if (followUp !== '' &&
                !Object.prototype.hasOwnProperty.call(MENU, followUp) &&
                !Object.prototype.hasOwnProperty.call(MODES, followUp)) {
              return { text: followUp, intent: 'QUERY', spread: false, labels: [], skip: false,
                       source: 'follow-up' };
            }


            // The instruction given before the reading, when the focused answer
            is what is being produced.

            let request = '';

            try {
              const f = args.focus;
              if (f && typeof f === 'object' && f.request) request = clean(f.request);
            } catch (e) { request = ''; }


            if (mode === 'focus' && request !== '') {
              return { text: request, intent: 'QUERY', spread: false, labels: [], skip: false,
                       source: 'instruction' };
            }


            // Two outputs never read the evidence at all: SHOW THE RECORD
            prints the whole record and

            // the export writes it to a file. Running a selection for them
            would burn a BM25 pass over

            // every passage to produce something nobody looks at.

            if (mode === 'record' || mode === 'export') {
              return { text: '', intent: 'NONE', spread: true, labels: [], skip: true,
                       source: 'not needed' };
            }


            const preset = Object.prototype.hasOwnProperty.call(MODES, mode) ?
            MODES[mode] : null;

            if (preset) {
              // The instruction from before the reading still colours a preset output, because it is
              // what the record was written to serve.
              const text = (preset.text + ' ' + request).trim();
              return { text: text, intent: preset.intent, spread: preset.spread,
                       labels: preset.labels, skip: false, source: 'output mode' };
            }


            // Anything else in the output menu is a free instruction typed by
            the user.

            if (mode !== '') {
              return { text: clean(args.format), intent: 'QUERY', spread: false, labels: [],
                       skip: false, source: 'instruction' };
            }


            return { text: request, intent: 'SUMMARY', spread: true, labels: [],
            skip: false,
                     source: 'whole record' };
          param: query
          timeout: 30000
          onFailure: ''
          silent: true
        - label: SELECT THE EVIDENCE FOR THE FOLLOW UP
          type: js
          args: passages, query, payload
          code: >-
            // FULL PAGE VISION · EVIDENCE SELECTION

            // Picks the passages this one request needs and lays them out with
            a map that binds every

            // passage number to the frame it came from.

            //

            // The scoring is BM25 over the passage index, run once per keyword
            set and fused by

            // reciprocal rank, which is the standard way to combine several
            rankings without having to

            // make their scores comparable. Three sets are used:

            //

            //   A · the request's own words.

            //   B · dictionary-free prefix stems of them, so an inflected
            language still matches -

            //       "Schriftarten" in the request finds "Schriftart" in the
            record without anybody

            //       shipping a stemmer.

            //   C · the words the record itself uses around the first-pass
            hits. This is the classic

            //       pseudo-relevance feedback step: take the strongest passages
            from pass one, find the

            //       terms that are common inside them and rare everywhere else,
            and search again with

            //       those. It is the same job the source command gives to a
            model - "keywords that would

            //       appear inside the ANSWER passage itself, phrased the way
            this corpus phrases things" -

            //       except that here the corpus is asked instead of guessed,
            which costs nothing, cannot

            //       hallucinate a term, and is automatically in the record's
            own language and wording.

            //

            // Three things make this different from a plain search:

            //

            //   * LABEL BOOST. Our passages are not prose, they are labelled
            bullets. A data extraction

            //     wants the passages carrying `**Table:**` and `**Data:**`, and
            saying so directly beats

            //     hoping the word "table" happens to appear.

            //

            //   * SPREAD. A summary, a report or a page rebuild is about the
            whole page. Ranking cannot

            //     answer that question - the top of any ranking is wherever the
            keywords cluster - so

            //     these sample evenly across the frames instead, from the first
            to the last.

            //

            //   * NEIGHBOURS. A bullet cut at a passage border finishes in the
            next one. After the

            //     selection is made, a few immediate neighbours of the
            strongest hits are pulled in, in

            //     reading order, so a `**Cut:**` never arrives without its
            other half.

            //

            // The output is always in reading order, never in score order: the
            record's own sequence is

            // information, and a model handed frames 12, 3, 40, 7 has to
            reconstruct it before it can use

            // anything.

            const BUDGET = 40000;

            const RRF_K = 60;

            const MAX_PICK = 60;


            function norm(s) {
              return String(s == null ? '' : s)
                .normalize('NFKC')
                .replace(/[\u2010-\u2015\u2212]/g, '-')
                .replace(/[\u2018\u2019\u201C\u201D\u00AB\u00BB]/g, '"')
                .replace(/\s+/g, ' ')
                .toLowerCase()
                .trim();
            }


            const CJK =
            /[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\u0E00-\u0E7F]/;


            function words(s) {
              const out = [];
              const parts = norm(s).split(/[^\p{L}\p{N}\p{M}]+/u);
              for (let i = 0; i < parts.length; i++) {
                const p = parts[i];
                if (!p) continue;
                if (CJK.test(p)) {
                  if (p.length <= 2) { out.push(p); continue; }
                  for (let j = 0; j + 2 <= p.length; j++) out.push(p.substr(j, 2));
                } else if (p.length >= 3) {
                  out.push(p);
                }
              }
              return out;
            }


            function uniq(list, cap) {
              const seen = {}, out = [];
              for (let i = 0; i < list.length; i++) {
                const k = list[i];
                if (!k || seen[k]) continue;
                seen[k] = 1;
                out.push(k);
                if (cap && out.length >= cap) break;
              }
              return out;
            }


            // A hit only counts when the term starts at a word boundary.
            Without that guard "art" scores

            // on "start", "particular" and "Artefakte", and a request about
            typefaces retrieves the

            // sidebar.

            const WORDCH = /[0-9\p{L}\p{M}]/u;

            function tf(hay, kw) {
              let n = 0, i = 0;
              while ((i = hay.indexOf(kw, i)) !== -1) {
                if (i === 0 || !WORDCH.test(hay.charAt(i - 1))) n++;
                i += kw.length;
              }
              return n;
            }


            let index = { chunks: [], totalChunks: 0, totalChars: 0 };

            try {
              const p = args.passages;
              if (p && typeof p === 'object' && p.chunks) index = p;
            } catch (e) { /* fall through to the whole record */ }


            const chunks = index.chunks || [];

            const fallback = String(args.payload == null ? '' : args.payload);


            if (!chunks.length) {
              return { text: fallback, stats: '', picked: 0, total: 0, ok: 'no', mode: 'whole record' };
            }


            let q = { text: '', intent: 'SUMMARY', spread: true, labels: [] };

            try {
              const raw = args.query;
              if (raw && typeof raw === 'object') q = raw;
            } catch (e) { /* keep the default */ }


            const labels = Array.isArray(q.labels) ? q.labels : [];

            const spread = q.spread === true || q.spread === 'true';


            // Some outputs do not read the evidence at all. Selecting for them
            would be a full BM25 pass

            // over every passage to produce something nobody looks at.

            if (q.skip === true || q.skip === 'true') {
              return { text: fallback, stats: '', picked: 0, total: 0, ok: 'no', mode: 'not needed',
                       views: 0, expanded: 0 };
            }


            const N = chunks.length;

            const lower = chunks.map(function (c) { return norm(c.text); });

            const lens = chunks.map(function (c) { return String(c.text).length;
            });

            let avgdl = 0;

            for (let i = 0; i < N; i++) avgdl += lens[i];

            avgdl = avgdl / Math.max(N, 1);


            // Which passages carry the labels this output lives in.

            const labelHit = chunks.map(function (c) {
              if (!labels.length) return false;
              for (let i = 0; i < labels.length; i++) {
                if (String(c.text).indexOf('**' + labels[i] + ':**') !== -1) return true;
              }
              return false;
            });


            function rank(kws) {
              const k1 = 1.5, b = 0.75, df = {};
              kws.forEach(function (kw) {
                let c = 0;
                for (let i = 0; i < N; i++) if (lower[i].indexOf(kw) !== -1) c++;
                df[kw] = c;
              });
              const scored = [];
              for (let i = 0; i < N; i++) {
                let sc = 0;
                for (let j = 0; j < kws.length; j++) {
                  const t = tf(lower[i], kws[j]);
                  if (t <= 0) continue;
                  const idf = Math.log((N - df[kws[j]] + 0.5) / (df[kws[j]] + 0.5) + 1);
                  sc += idf * (t * (k1 + 1)) / (t + k1 * (1 - b + b * lens[i] / avgdl));
                }
                if (sc > 0) scored.push({ i: i, s: sc });
              }
              scored.sort(function (a, b2) { return b2.s - a.s; });
              return scored;
            }


            const surface = uniq(words(q.text || ''), 24);

            const stems = uniq(surface.map(function (w) {
              if (w.length < 6) return '';
              const cut = Math.max(5, Math.ceil(w.length * 0.75));
              return cut < w.length ? w.slice(0, cut) : '';
            }).filter(Boolean), 24).filter(function (x) { return
            surface.indexOf(x) === -1; });


            const sets = [];

            if (surface.length) sets.push(surface);

            if (stems.length) sets.push(stems);


            const rrf = {};

            let bestRaw = 0;

            let views = 0;

            let firstPass = [];


            function fuse(kws) {
              const ranked = rank(kws);
              if (!ranked.length) return [];
              if (ranked[0].s > bestRaw) bestRaw = ranked[0].s;
              for (let r = 0; r < ranked.length && r < 80; r++) {
                rrf[ranked[r].i] = (rrf[ranked[r].i] || 0) + 1 / (RRF_K + r + 1);
              }
              views++;
              return ranked;
            }


            for (let si = 0; si < sets.length; si++) {
              const ranked = fuse(sets[si]);
              if (si === 0) firstPass = ranked;
            }


            // Set C · pseudo-relevance feedback. Only for a question somebody
            actually typed: a canned

            // word list for a preset output has nothing to expand, and
            expanding a spread query would

            // pull the sample towards whatever the first pass happened to like.

            let expansion = [];

            if (String(q.intent || '') === 'QUERY' && firstPass.length) {
              const top = firstPass.slice(0, 5).map(function (x) { return x.i; });
              const inTop = {};
              top.forEach(function (i) {
                const seen = {};
                words(chunks[i].text).forEach(function (w) {
                  if (seen[w]) return;
                  seen[w] = 1;
                  inTop[w] = (inTop[w] || 0) + 1;
                });
              });
              const already = {};
              sets.forEach(function (set) { set.forEach(function (w) { already[w] = 1; }); });
              const cand = [];
              Object.keys(inTop).forEach(function (w) {
                if (already[w] || w.length < 4 || inTop[w] < 2) return;
                let df = 0;
                for (let i = 0; i < N; i++) if (lower[i].indexOf(w) !== -1) df++;
                if (df === 0 || df > N * 0.5) return;
                cand.push({ w: w, s: inTop[w] * Math.log(N / df) });
              });
              cand.sort(function (a, b2) { return b2.s - a.s; });
              expansion = cand.slice(0, 12).map(function (c) { return c.w; });
              if (expansion.length) fuse(expansion);
            }


            // The label boost is additive and deliberately large enough to
            outrank a weak lexical hit:

            // for a labelled output the label IS the query, and the words are
            only a tie-breaker.

            if (labels.length) {
              for (let i = 0; i < N; i++) {
                if (labelHit[i]) rrf[i] = (rrf[i] || 0) + 1 / RRF_K;
              }
            }


            let fused = Object.keys(rrf).map(function (k) {
              return { i: parseInt(k, 10), s: rrf[k] };
            });

            fused.sort(function (a, b2) { return b2.s - a.s; });


            let picked = [];

            let mode;


            if (spread || !fused.length) {
              // Even coverage of the whole record, in reading order, as many as the budget allows.
              // "no lexical match" is only honest when there were words to match with: a summary has no
              // question behind it, so nothing failed - it was never a search in the first place.
              mode = fused.length ? 'spread'
                : (sets.length ? 'spread (no lexical match)' : 'spread (whole record)');
              const step = Math.max(1, Math.floor(N / Math.max(1, Math.min(MAX_PICK, N))));
              for (let i = 0; i < N && picked.length < MAX_PICK; i += step) picked.push(i);
              // The first and the last frame anchor a summary: the page starts and ends somewhere.
              if (picked.indexOf(0) === -1) picked.unshift(0);
              if (picked.indexOf(N - 1) === -1) picked.push(N - 1);
              // Strong lexical hits are added on top, because a spread is a floor and not a ceiling.
              for (let f = 0; f < fused.length && f < 12; f++) {
                if (picked.indexOf(fused[f].i) === -1) picked.push(fused[f].i);
              }
            } else {
              mode = labels.length ? 'labelled + ranked' : 'ranked';
              for (let f = 0; f < fused.length && picked.length < MAX_PICK; f++) picked.push(fused[f].i);
            }


            // Neighbour recovery for the strongest hits, so a bullet cut at a
            border keeps its other half.

            const chosen = {};

            picked.forEach(function (i) { chosen[i] = 1; });

            let grown = 0;

            for (let p = 0; p < picked.length && grown < 8; p++) {
              const i = picked[p];
              if (i > 0 && !chosen[i - 1]) { chosen[i - 1] = 1; picked.push(i - 1); grown++; }
              if (grown >= 8) break;
              if (i + 1 < N && !chosen[i + 1]) { chosen[i + 1] = 1; picked.push(i + 1); grown++; }
            }


            // Budget in order of strength, then restore reading order.

            const strength = {};

            picked.forEach(function (i, r) { strength[i] = r; });

            picked.sort(function (a, b2) { return strength[a] - strength[b2];
            });


            const selected = [];

            let used = 0;

            for (let p = 0; p < picked.length; p++) {
              const i = picked[p];
              if (used + lens[i] > BUDGET && selected.length > 0) continue;
              selected.push(i);
              used += lens[i];
            }

            selected.sort(function (a, b2) { return a - b2; });


            if (!selected.length) {
              return { text: fallback, stats: '', picked: 0, total: N, ok: 'no', mode: 'whole record' };
            }


            let map = '';

            let body = '';

            selected.forEach(function (i, k) {
              const n = k + 1;
              map += '[' + n + '] = ' + chunks[i].frame + '\n';
              body += '[' + n + '] ' + chunks[i].text + '\n\n---\n\n';
            });


            const text =
              '━━━ EVIDENCE MAP ━━━\n' + map +
              '━━━━━━━━━━━━━━━━━━━━\n\n' + body.replace(/\n+---\n+$/, '\n');

            const gaps = N - selected.length;

            const stats = '`' + selected.length + '/' + N + ' passages · ' +
              used.toLocaleString('en-US') + ' chars · ' + mode + ' · ' + views +
              ' query view' + (views === 1 ? '' : 's') +
              (expansion.length ? ' · expanded' : '') +
              (gaps > 0 ? ' · ' + gaps + ' passage(s) not sent' : ' · complete') + '`';

            return { text: text, stats: stats, picked: selected.length, total:
            N, ok: 'yes',
                     mode: mode, views: views, expanded: expansion.length };
          param: selection
          timeout: 60000
          onFailure: ''
          silent: true
        - label: ADOPT THE FOLLOW UP EVIDENCE
          type: calc
          func: set
          param: evidence
          value: '{{selection.text}}'
          format: ''
        - label: HARDEN THE FOLLOW UP EVIDENCE
          type: js
          args: evidence
          code: >-
            // FULL PAGE VISION · RE-ARMOUR THE EVIDENCE

            // The evidence arrives through a CALC step, and a CALC value is
            interpolated, which the

            // engine defines as consuming one layer of the `{{\` escape. So the
            text that reaches this

            // step has none left, and the prompt that receives it next would
            execute any `{{page}}` or

            // `{{serp x}}` the page happened to display. One layer goes back
            on, and the escape is

            // idempotent, so a value that still had one keeps exactly one.

            function harden(value) {
              return String(value == null ? '' : value).replace(/\{\{(?!\\)/g, '{{\\');
            }


            return harden(args.evidence);
          param: evidence
          timeout: 60000
          onFailure: ''
          silent: true
        - label: REPORT THE FOLLOW UP SELECTION
          type: say
          message: 🔎 {{selection.stats}}
    - label: APPLY CUSTOM FOLLOW UP
      type: gpt
      prompt: >-
        Please ignore all previous instructions. Write only in
        {{outputLanguage}}.


        {{buildRule}}


        Whatever stands under [REQUEST] is what you have to do. Apply it to the
        [CURRENT RESULT] where that is what it means, and to the [EVIDENCE]
        where it asks for something the current result does not hold.


        [RULES]

        - Do what the request says and output the result alone: no preamble, no
        account of what you did, no closing remark. Where it asks for code or a
        document, the answer is that code or that document and nothing else.

        - Never reply that the record "does not contain" what was asked for. The
        record contains the page; what was asked for is what you build out of
        it. A request to build something can always be carried out, because the
        material is already there.

        - Take every word, number, heading, label, price, name and image
        description from the record and reproduce it exactly.

        - Use every piece of visual form the record gives you - typefaces,
        sizes, colours, spacing, alignment, layout, order. Where the record is
        silent, choose something consistent with what it does say and carry on;
        do not stop to ask and do not leave a placeholder for a decision you can
        make yourself.

        - Where the record marks a region `[unreadable]` or `NOT READ`, put a
        clearly marked placeholder at that spot and continue. A gap never stops
        the work.

        - If the request is a question rather than a task, answer it from the
        record and name the frame it came from.


        [REQUEST]:

        {{change}}


        [CURRENT RESULT]:

        {{gpt}}


        [EVIDENCE]:

        {{citationRule}}{{evidence}}


        [ANSWER]:
      param: gpt
      isolated: true
    - label: AFTER CUSTOM FOLLOW UP
      type: jump
      to: VERIFY OUTPUT
- condition: '{{change}} = method'
  label: SWITCH THE READING METHOD
  type: group
  steps:
    - label: ANNOUNCE THE METHOD SWITCH
      type: say
      message: >-
        🔄 The record and its index are still loaded — nothing is captured
        again.
    - label: GO BACK TO THE METHOD QUESTION
      type: jump
      to: CHOOSE HOW THE RECORD IS READ
- label: END
  type: stop
- label: READING CANCELLED
  type: say
  message: >-
    ⛔ Stopped before reading. The frames were captured but not sent anywhere, so
    no vision request was spent. Run the command again whenever you want to go
    ahead.
- label: END AFTER CANCEL
  type: stop
Notice: Please read before using

This automation command is created by a community member. HARPA AI team does not audit community commands.

Please review the command carefully and only install if you trust the creator.

Contact us
HomeUse CasesGuidesPrivacy PolicyTerms of Service
CAN WE STORE COOKIES?
Our website uses cookies for the purposes of accessibility and security. They also allow us to gather statistics in order to improve the website for you. More info: Privacy Policy