UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

279 lines • 11.1 kB
"use strict"; /** * codeRegions — the fenced code blocks of an answer, read by one linear pass, * closed where a CommonMark renderer closes them. * * Pattern: pure leaf. Imports nothing — it does not know the gate exists. The * one reader of "which bytes of this answer sit inside a fenced * block", asked by the script match (`../scripts/match.ts`) under * `.namesAndNumbersFromEvidence({ code: 'match' })`. Under the * default `code: 'check'` it is never called. * Role: core/ layer (the evidence gate's folder), no state, no events. * * ## Why it must agree with the renderer * * A region that matches a submission is exempt from the evidence check. If * this reader saw a block where a renderer shows prose, a model could submit a * program holding a sentence, show it fenced so that the sentence renders * outside the block, and the sentence would never be checked. So the reader * closes a block exactly where CommonMark closes it. Where a renderer could * pair fences another way, it stops reading blocks altogether. * * ## The grammar (design: docs/design/2026-10-script-artifact.md § 5.1–5.2, aligned with CommonMark) * * The answer is read line by line. A line ends at an LF, a CR or a CRLF, as * CommonMark ends it. * * - An OPENING fence starts at column 0 with three or more backticks, or three * or more tildes, then an optional info string, and ENDS IN A LINE ENDING. * A backtick fence's info string may not contain a backtick. * - The CLOSING fence uses the same character and is at least as long. As in * CommonMark, it may be indented by up to three spaces and is followed only * by spaces. * - The CONTENT is exactly the bytes between the opening line's line ending * and the ONE line ending before the closing line: every space, tab, CR and * code point as written. Before the closing line, an LF is the wrapper's (a * CR before it is content), and so is a lone CR. A closing fence on the line * right after the opening one gives an empty block (content `''`). * - A fence that never closes is NOT a region: the text from it to the end is * prose. * - At most {@link MAX_CODE_REGIONS} regions are read; the rest are prose and * counted in `dropped`. * * ## Where reading stops (`stoppedAt`) * * The reader models CommonMark's top level only. It does not model list * items, block quotes, HTML blocks or the extensions renderers add. At the * first line where a renderer could pair fences another way, reading STOPS. * No region is read from there on: everything after that point is prose and * checked. * * Outside a block, reading stops at: * - a fence indented by one to three spaces. It is either a list item's fence, * which the item scopes, or an indented fence, whose content a renderer * strips (and "as written" and "with the indentation removed" are two * different programs); * - a line that may start an HTML block (up to three spaces, `<`, then a * letter, `/`, `!` or `?`). Inside an HTML block, a fence is not a fence; * - a line opening display math (`$$`), the block extension chat renderers * commonly add. Inside display math, a fence is not a fence; * - a byte-order mark at the very start, which some renderers drop and some * keep. * * Inside a block, reading stops at: * - a closing run followed by a TAB. CommonMark 0.30 closes the block there * and 0.29 does not, so the block is read (it is code either way) and * reading stops after it; * - a run of the block's own fence character, anywhere in its content, as long * as its opening fence. A renderer laxer than CommonMark could close the * block there, so the block is prose and reading stops at its opening line. * The library's own wrapper never writes such a block * (`../scripts/wrapper.ts` · `fenceFor`: the longest run + 1). * * Nothing here normalizes anything: no trimming, no line-ending rewrite, no * Unicode normalization. A region is the program a reader would copy. * * ## Linear by construction * * The text is model output, so the pass is linear in it (the * `lib/linearText.ts` law, pinned by `test/security/linear-scanners.test.ts`). * Each line is read ONCE, character by character, from where the last one * ended: its indentation, its fence run, the rest of it and its line ending. * No regular expression runs over the answer. * * @example * ```ts * const { regions } = codeRegionsOf('Total:\n```python\nprint(1)\n```\nDone.'); * regions[0].content; // 'print(1)' * regions[0].block; // 1 * codeRegionsOf('```\na\n ```\nRevenue was 4321.').regions[0].content; // 'a' * ``` */ Object.defineProperty(exports, "__esModule", { value: true }); exports.codeRegionsOf = exports.MAX_CODE_REGIONS = void 0; /** Regions read per answer; the rest are prose and counted (`dropped`). Design § 4.4. */ exports.MAX_CODE_REGIONS = 32; const BACKTICK = 0x60; const TILDE = 0x7e; const SPACE = 0x20; const TAB = 0x09; const CR = 0x0d; const LF = 0x0a; const LESS_THAN = 0x3c; const DOLLAR = 0x24; const BYTE_ORDER_MARK = 0xfeff; /** * Read the line starting at `start`, each character once. `watch` is the fence * character of the block that is open (0 when none): the longest run of it on * the line is counted. */ function readLine(text, start, watch) { const length = text.length; let i = start; let c = i < length ? text.charCodeAt(i) : -1; while (c === SPACE) c = ++i < length ? text.charCodeAt(i) : -1; const indent = i - start; const first = c === CR || c === LF ? -1 : c; let char = 0; let run = 0; let current = 0; let longest = 0; if (c === BACKTICK || c === TILDE) { char = c; while (c === char) { run += 1; c = ++i < length ? text.charCodeAt(i) : -1; } if (watch !== 0 && char === watch) longest = current = run; } let restSpaces = true; let restBlank = true; let restBacktick = false; let infoFrom = -1; let infoTo = i; while (c !== -1 && c !== CR && c !== LF) { if (c !== SPACE) { restSpaces = false; if (c !== TAB) restBlank = false; if (infoFrom === -1) infoFrom = i; infoTo = i + 1; } if (c === BACKTICK) restBacktick = true; if (watch !== 0 && c === watch) { current += 1; if (current > longest) longest = current; } else { current = 0; } c = ++i < length ? text.charCodeAt(i) : -1; } const end = i; let next = end; if (c === CR) { next = end + 1; if (next < length && text.charCodeAt(next) === LF) next += 1; } else if (c === LF) { next = end + 1; } return { end, next, terminated: next > end, indent, first, char, run, restSpaces, restBlank, restBacktick, infoFrom: infoFrom === -1 ? infoTo : infoFrom, infoTo, longest, }; } /** * Whether a line outside any block could be read by a renderer as something * that pairs fences another way: an indented fence, an HTML block's start, or * display math. */ function unsettles(text, lineStart, line) { if (line.indent > 3) return false; if (line.run >= 3) return line.indent > 0; if (line.first !== LESS_THAN && line.first !== DOLLAR) return false; const second = text.charCodeAt(lineStart + line.indent + 1); if (line.first === LESS_THAN) { // A letter (an open tag), `/` (a closing tag), `!` (a comment, a // declaration, CDATA) or `?` (a processing instruction). const lower = second | 0x20; return ((lower >= 0x61 && lower <= 0x7a) || second === 0x2f || second === 0x21 || second === 0x3f); } return second === DOLLAR; } /** * The fenced code regions of `text`, in order, how many past the cap, and where * reading stopped. Pure and linear; `max` defaults to {@link MAX_CODE_REGIONS}. */ function codeRegionsOf(text, max = exports.MAX_CODE_REGIONS) { const regions = []; let dropped = 0; if (text.length > 0 && text.charCodeAt(0) === BYTE_ORDER_MARK) { return { regions, dropped, stoppedAt: 0 }; } let open; // The longest run of the open block's fence character in its content so far. let longest = 0; let lineStart = 0; while (lineStart < text.length) { const line = readLine(text, lineStart, open?.char ?? 0); if (open === undefined) { if (line.indent === 0 && line.run >= 3 && line.terminated && !(line.char === BACKTICK && line.restBacktick)) { open = { start: lineStart, contentStart: line.next, char: line.char, length: line.run, info: text.slice(line.infoFrom, line.infoTo), }; longest = 0; } else if (unsettles(text, lineStart, line)) { return { regions, dropped, stoppedAt: lineStart }; } } else if (line.indent <= 3 && line.char === open.char && line.run >= open.length && line.restBlank) { // A laxer renderer could have closed the block inside its content. if (longest >= open.length) return { regions, dropped, stoppedAt: open.start }; if (regions.length < max) { // The ONE line ending before the closing line is the wrapper's: its // last character (the LF of an LF or a CRLF, or a lone CR). A closing // line right after the opening one leaves an empty block. const contentEnd = Math.max(open.contentStart, lineStart - 1); regions.push({ block: regions.length + 1, start: open.start, end: line.next, content: text.slice(open.contentStart, contentEnd), info: open.info, }); } else { dropped += 1; } open = undefined; // A tab after the run: CommonMark versions disagree on what follows. if (!line.restSpaces) return { regions, dropped, stoppedAt: line.next }; } else if (line.longest > longest) { longest = line.longest; } lineStart = line.next; } // A fence still open here never closed: prose to the end, not a region — // and the block a renderer is still inside, for a writer appending after. if (open !== undefined) { return { regions, dropped, openFence: String.fromCharCode(open.char).repeat(open.length) }; } return { regions, dropped }; } exports.codeRegionsOf = codeRegionsOf; //# sourceMappingURL=codeRegions.js.map