jeopi-ai 16.4.3 → 16.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/types/dialect/index.d.ts +1 -0
- package/dist/types/dialect/salvage.d.ts +44 -0
- package/dist/types/providers/openai-shared.d.ts +2 -0
- package/dist/types/utils/fences.d.ts +14 -0
- package/package.json +4 -4
- package/src/dialect/index.ts +1 -0
- package/src/dialect/salvage.ts +90 -0
- package/src/providers/openai-codex-responses.ts +2 -3
- package/src/providers/openai-shared.ts +6 -3
- package/src/registry/oauth/callback-server.ts +21 -1
- package/src/utils/fences.ts +48 -0
- package/src/utils/harmony-leak.ts +1 -40
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.4.5] - 2026-08-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `salvageLeakedToolCalls` (`jeopi-ai/dialect`), which re-materializes a native-tool-calling turn whose tool call was emitted as visible `<invoke …>` text into real `toolCall` content blocks. It only fires on unambiguous leaks: the turn must have stopped normally, carry no native tool call, keep every `<invoke` outside fenced code blocks, and name only tools available that turn — so quoted documentation is never executed.
|
|
10
|
+
|
|
11
|
+
## [16.4.4] - 2026-08-03
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Escaped OAuth callback state before embedding it in the HTML script payload, preventing callback error text from breaking out of the response document.
|
|
16
|
+
|
|
5
17
|
## [16.4.3] - 2026-07-22
|
|
6
18
|
|
|
7
19
|
### Added
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery for tool calls a native-tool-calling model emitted as plain text.
|
|
3
|
+
*
|
|
4
|
+
* Symptom: the provider returns a normal `end_turn` whose visible text carries
|
|
5
|
+
* a fully-formed Anthropic-style `<invoke name="…">…</invoke>` block instead of
|
|
6
|
+
* native `tool_use` content. The turn then holds zero runnable tool calls, so
|
|
7
|
+
* the agent loop stops with the markup rendered verbatim to the user and the
|
|
8
|
+
* announced work never runs.
|
|
9
|
+
*
|
|
10
|
+
* This module re-materializes such a turn into real `toolCall` blocks using the
|
|
11
|
+
* same in-band scanner the owned-dialect path uses, so the loop dispatches them
|
|
12
|
+
* unchanged. It is deliberately conservative: a turn is salvaged ONLY when the
|
|
13
|
+
* markup is unambiguously a call the model meant to make (see
|
|
14
|
+
* {@link salvageLeakedToolCalls}), never when it is quoted documentation.
|
|
15
|
+
*/
|
|
16
|
+
import type { AssistantMessage, ToolCall } from "../types";
|
|
17
|
+
import type { InbandTool } from "./types";
|
|
18
|
+
export interface SalvagedToolCalls {
|
|
19
|
+
/** The rewritten turn: markup removed from text, real `toolCall` blocks appended. */
|
|
20
|
+
readonly message: AssistantMessage;
|
|
21
|
+
/** The calls recovered from the leaked markup, in emission order. */
|
|
22
|
+
readonly calls: readonly ToolCall[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Recover tool calls a model wrote as visible text instead of native tool_use.
|
|
26
|
+
*
|
|
27
|
+
* Returns `undefined` unless EVERY guard holds — each one exists to keep a turn
|
|
28
|
+
* that merely *talks about* tool syntax from being executed:
|
|
29
|
+
*
|
|
30
|
+
* - The turn ended normally (`stop`). `length` may have truncated the markup
|
|
31
|
+
* mid-argument, and `error`/`aborted` turns are already handled upstream.
|
|
32
|
+
* - The turn carries no native `toolCall` block. A model that called tools
|
|
33
|
+
* natively AND quoted markup is documenting, not leaking.
|
|
34
|
+
* - At least one `<invoke` appears outside a fenced code block, and none appear
|
|
35
|
+
* inside one. Fenced markup is quoted by construction; mixing the two is
|
|
36
|
+
* ambiguous, so the whole turn is left alone.
|
|
37
|
+
* - The scanner recovers at least one call, and every recovered call names a
|
|
38
|
+
* tool that is actually available this turn (matching `name` or
|
|
39
|
+
* `customWireName`, mirroring the loop's own dispatch lookup).
|
|
40
|
+
*
|
|
41
|
+
* The caller decides what to do with the result; this function has no side
|
|
42
|
+
* effects and never mutates `message`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function salvageLeakedToolCalls(message: AssistantMessage, tools: readonly InbandTool[] | undefined): SalvagedToolCalls | undefined;
|
|
@@ -385,6 +385,8 @@ export declare function appendReasoningSummaryTextDelta(item: ResponseReasoningI
|
|
|
385
385
|
export declare function appendReasoningSummaryPartDone(item: ResponseReasoningItem, block: ThinkingContent, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
|
|
386
386
|
export declare function appendMessageContentPart(item: ResponseOutputMessage, part: ResponseContentPartAddedEvent["part"] | undefined): void;
|
|
387
387
|
export declare function appendMessageTextDelta(item: ResponseOutputMessage, block: TextContent, delta: string, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number, partType: "output_text" | "refusal"): void;
|
|
388
|
+
/** Chooses final message text while treating non-empty terminal content as authoritative. */
|
|
389
|
+
export declare function finalizeMessageText(item: ResponseOutputMessage, streamedText: string): string;
|
|
388
390
|
export declare function accumulateToolCallArgumentsDelta(block: ResponsesToolCallBlock, delta: string, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
|
|
389
391
|
/**
|
|
390
392
|
* Finalize streamed function-call arguments from the authoritative `.done`
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced-code-block range computation shared by text scanners that must not
|
|
3
|
+
* trip on markup quoted inside ``` / ~~~ blocks (harmony-leak detection,
|
|
4
|
+
* leaked tool-call salvage).
|
|
5
|
+
*/
|
|
6
|
+
/** A `[start, end)` byte span covered by a fenced code block. */
|
|
7
|
+
export type FenceRange = readonly [number, number];
|
|
8
|
+
/**
|
|
9
|
+
* Precompute fenced-code-block ranges once per text. Each range is a
|
|
10
|
+
* [start, end) span of bytes inside any ```/~~~ fence. O(n) once instead of
|
|
11
|
+
* O(n) per detected match.
|
|
12
|
+
*/
|
|
13
|
+
export declare function computeFenceRanges(text: string): FenceRange[];
|
|
14
|
+
export declare function isInsideFence(ranges: readonly FenceRange[], position: number): boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "jeopi-ai",
|
|
4
|
-
"version": "16.4.
|
|
4
|
+
"version": "16.4.5",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://github.com/akillness/jeopi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"jeopi-catalog": "16.4.
|
|
42
|
-
"jeopi-utils": "16.4.
|
|
43
|
-
"jeopi-wire": "16.4.
|
|
41
|
+
"jeopi-catalog": "16.4.5",
|
|
42
|
+
"jeopi-utils": "16.4.5",
|
|
43
|
+
"jeopi-wire": "16.4.5",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
package/src/dialect/index.ts
CHANGED
|
@@ -11,5 +11,6 @@ export * from "./owned-stream";
|
|
|
11
11
|
// consumer needs (the legacy markdown `/dump` reuses its `<thinking>` envelope
|
|
12
12
|
// unwrap), so re-export only that symbol rather than `export *`-ing the rest.
|
|
13
13
|
export { renderDelimitedThinking } from "./rendering";
|
|
14
|
+
export * from "./salvage";
|
|
14
15
|
export * from "./thinking";
|
|
15
16
|
export * from "./types";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery for tool calls a native-tool-calling model emitted as plain text.
|
|
3
|
+
*
|
|
4
|
+
* Symptom: the provider returns a normal `end_turn` whose visible text carries
|
|
5
|
+
* a fully-formed Anthropic-style `<invoke name="…">…</invoke>` block instead of
|
|
6
|
+
* native `tool_use` content. The turn then holds zero runnable tool calls, so
|
|
7
|
+
* the agent loop stops with the markup rendered verbatim to the user and the
|
|
8
|
+
* announced work never runs.
|
|
9
|
+
*
|
|
10
|
+
* This module re-materializes such a turn into real `toolCall` blocks using the
|
|
11
|
+
* same in-band scanner the owned-dialect path uses, so the loop dispatches them
|
|
12
|
+
* unchanged. It is deliberately conservative: a turn is salvaged ONLY when the
|
|
13
|
+
* markup is unambiguously a call the model meant to make (see
|
|
14
|
+
* {@link salvageLeakedToolCalls}), never when it is quoted documentation.
|
|
15
|
+
*/
|
|
16
|
+
import type { AssistantMessage, ToolCall } from "../types";
|
|
17
|
+
import { computeFenceRanges, isInsideFence } from "../utils/fences";
|
|
18
|
+
import { parseInbandToolMessage } from "./owned-stream";
|
|
19
|
+
import type { InbandTool } from "./types";
|
|
20
|
+
|
|
21
|
+
/** Matches an opening invoke tag, with or without a dialect prefix (`x:invoke`). */
|
|
22
|
+
const INVOKE_OPEN_RE = /<\s*(?:[A-Za-z_][\w.-]*:)?invoke\b/g;
|
|
23
|
+
|
|
24
|
+
export interface SalvagedToolCalls {
|
|
25
|
+
/** The rewritten turn: markup removed from text, real `toolCall` blocks appended. */
|
|
26
|
+
readonly message: AssistantMessage;
|
|
27
|
+
/** The calls recovered from the leaked markup, in emission order. */
|
|
28
|
+
readonly calls: readonly ToolCall[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Recover tool calls a model wrote as visible text instead of native tool_use.
|
|
33
|
+
*
|
|
34
|
+
* Returns `undefined` unless EVERY guard holds — each one exists to keep a turn
|
|
35
|
+
* that merely *talks about* tool syntax from being executed:
|
|
36
|
+
*
|
|
37
|
+
* - The turn ended normally (`stop`). `length` may have truncated the markup
|
|
38
|
+
* mid-argument, and `error`/`aborted` turns are already handled upstream.
|
|
39
|
+
* - The turn carries no native `toolCall` block. A model that called tools
|
|
40
|
+
* natively AND quoted markup is documenting, not leaking.
|
|
41
|
+
* - At least one `<invoke` appears outside a fenced code block, and none appear
|
|
42
|
+
* inside one. Fenced markup is quoted by construction; mixing the two is
|
|
43
|
+
* ambiguous, so the whole turn is left alone.
|
|
44
|
+
* - The scanner recovers at least one call, and every recovered call names a
|
|
45
|
+
* tool that is actually available this turn (matching `name` or
|
|
46
|
+
* `customWireName`, mirroring the loop's own dispatch lookup).
|
|
47
|
+
*
|
|
48
|
+
* The caller decides what to do with the result; this function has no side
|
|
49
|
+
* effects and never mutates `message`.
|
|
50
|
+
*/
|
|
51
|
+
export function salvageLeakedToolCalls(
|
|
52
|
+
message: AssistantMessage,
|
|
53
|
+
tools: readonly InbandTool[] | undefined,
|
|
54
|
+
): SalvagedToolCalls | undefined {
|
|
55
|
+
if (message.stopReason !== "stop") return undefined;
|
|
56
|
+
if (!tools || tools.length === 0) return undefined;
|
|
57
|
+
if (message.content.some(block => block.type === "toolCall")) return undefined;
|
|
58
|
+
|
|
59
|
+
let text = "";
|
|
60
|
+
for (const block of message.content) {
|
|
61
|
+
if (block.type === "text") text += block.text;
|
|
62
|
+
}
|
|
63
|
+
if (text.length === 0) return undefined;
|
|
64
|
+
|
|
65
|
+
const fences = computeFenceRanges(text);
|
|
66
|
+
let bare = 0;
|
|
67
|
+
INVOKE_OPEN_RE.lastIndex = 0;
|
|
68
|
+
for (const match of text.matchAll(INVOKE_OPEN_RE)) {
|
|
69
|
+
if (isInsideFence(fences, match.index ?? 0)) return undefined;
|
|
70
|
+
bare++;
|
|
71
|
+
}
|
|
72
|
+
if (bare === 0) return undefined;
|
|
73
|
+
|
|
74
|
+
const parsed = parseInbandToolMessage(message, "anthropic", tools);
|
|
75
|
+
const calls = parsed.content.filter((block): block is ToolCall => block.type === "toolCall");
|
|
76
|
+
if (calls.length === 0) return undefined;
|
|
77
|
+
|
|
78
|
+
const available = new Set<string>();
|
|
79
|
+
for (const tool of tools) {
|
|
80
|
+
available.add(tool.name);
|
|
81
|
+
if (tool.customWireName !== undefined) available.add(tool.customWireName);
|
|
82
|
+
}
|
|
83
|
+
if (!calls.every(call => available.has(call.name))) return undefined;
|
|
84
|
+
|
|
85
|
+
// `stopReason` is restated rather than inherited: the projector happens to
|
|
86
|
+
// derive `toolUse` today, but this function's contract is "the loop must see
|
|
87
|
+
// a runnable tool turn, not a finished answer" — that must not silently
|
|
88
|
+
// depend on an internal of the owned-stream projector.
|
|
89
|
+
return { message: { ...parsed, stopReason: "toolUse" }, calls };
|
|
90
|
+
}
|
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
encodeResponsesToolCallId,
|
|
87
87
|
encodeTextSignatureV1,
|
|
88
88
|
finalizeCustomToolCallInputDone,
|
|
89
|
+
finalizeMessageText,
|
|
89
90
|
finalizePendingResponsesToolCalls,
|
|
90
91
|
finalizeToolCallArgumentsDone,
|
|
91
92
|
isOpenAIResponsesProgressEvent,
|
|
@@ -1458,9 +1459,7 @@ class CodexStreamProcessor {
|
|
|
1458
1459
|
}
|
|
1459
1460
|
|
|
1460
1461
|
if (item.type === "message" && block?.type === "text") {
|
|
1461
|
-
block.text = item.
|
|
1462
|
-
.map(content => (content.type === "output_text" ? content.text : content.refusal))
|
|
1463
|
-
.join("");
|
|
1462
|
+
block.text = finalizeMessageText(item, block.text);
|
|
1464
1463
|
const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
|
|
1465
1464
|
block.textSignature = encodeTextSignatureV1(item.id, phase);
|
|
1466
1465
|
stream.push({
|
|
@@ -1744,6 +1744,11 @@ export function appendMessageTextDelta(
|
|
|
1744
1744
|
}
|
|
1745
1745
|
stream.push({ type: "text_delta", contentIndex, delta, partial: output });
|
|
1746
1746
|
}
|
|
1747
|
+
/** Chooses final message text while treating non-empty terminal content as authoritative. */
|
|
1748
|
+
export function finalizeMessageText(item: ResponseOutputMessage, streamedText: string): string {
|
|
1749
|
+
if (!item.content?.length) return streamedText || "";
|
|
1750
|
+
return item.content.map(part => (part.type === "output_text" ? (part.text ?? "") : (part.refusal ?? ""))).join("");
|
|
1751
|
+
}
|
|
1747
1752
|
|
|
1748
1753
|
export function accumulateToolCallArgumentsDelta(
|
|
1749
1754
|
block: ResponsesToolCallBlock,
|
|
@@ -2136,9 +2141,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
2136
2141
|
closeOpenItem(event.output_index, item.id, entry);
|
|
2137
2142
|
} else if (item.type === "message") {
|
|
2138
2143
|
const block = entry?.block.type === "text" ? entry.block : undefined;
|
|
2139
|
-
const text = item
|
|
2140
|
-
.map(part => (part.type === "output_text" ? (part.text ?? "") : (part.refusal ?? "")))
|
|
2141
|
-
.join("");
|
|
2144
|
+
const text = finalizeMessageText(item, block?.text ?? "");
|
|
2142
2145
|
const textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
|
|
2143
2146
|
let contentIndex: number;
|
|
2144
2147
|
if (block) {
|
|
@@ -18,6 +18,26 @@ const DEFAULT_TIMEOUT = 300_000;
|
|
|
18
18
|
const DEFAULT_HOSTNAME = "localhost";
|
|
19
19
|
const CALLBACK_PATH = "/callback";
|
|
20
20
|
|
|
21
|
+
function escapeJsonForHtmlScript(value: object): string {
|
|
22
|
+
const json = JSON.stringify(value) ?? "null";
|
|
23
|
+
return json.replace(/[<>&\u2028\u2029]/g, character => {
|
|
24
|
+
switch (character) {
|
|
25
|
+
case "<":
|
|
26
|
+
return "\\u003C";
|
|
27
|
+
case ">":
|
|
28
|
+
return "\\u003E";
|
|
29
|
+
case "&":
|
|
30
|
+
return "\\u0026";
|
|
31
|
+
case "\u2028":
|
|
32
|
+
return "\\u2028";
|
|
33
|
+
case "\u2029":
|
|
34
|
+
return "\\u2029";
|
|
35
|
+
default:
|
|
36
|
+
return character;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
export type CallbackResult = { code: string; state: string };
|
|
22
42
|
|
|
23
43
|
export interface OAuthCallbackFlowOptions {
|
|
@@ -230,7 +250,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
230
250
|
});
|
|
231
251
|
|
|
232
252
|
return new Response(
|
|
233
|
-
(templateHtml as unknown as string).replaceAll("__OAUTH_STATE__",
|
|
253
|
+
(templateHtml as unknown as string).replaceAll("__OAUTH_STATE__", () => escapeJsonForHtmlScript(resultState)),
|
|
234
254
|
{
|
|
235
255
|
status: resultState.ok ? 200 : 500,
|
|
236
256
|
headers: { "Content-Type": "text/html" },
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced-code-block range computation shared by text scanners that must not
|
|
3
|
+
* trip on markup quoted inside ``` / ~~~ blocks (harmony-leak detection,
|
|
4
|
+
* leaked tool-call salvage).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const FENCE_RE = /^\s*(?:```+|~~~+)/;
|
|
8
|
+
|
|
9
|
+
/** A `[start, end)` byte span covered by a fenced code block. */
|
|
10
|
+
export type FenceRange = readonly [number, number];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Precompute fenced-code-block ranges once per text. Each range is a
|
|
14
|
+
* [start, end) span of bytes inside any ```/~~~ fence. O(n) once instead of
|
|
15
|
+
* O(n) per detected match.
|
|
16
|
+
*/
|
|
17
|
+
export function computeFenceRanges(text: string): FenceRange[] {
|
|
18
|
+
const ranges: FenceRange[] = [];
|
|
19
|
+
let inFence = false;
|
|
20
|
+
let fenceStart = 0;
|
|
21
|
+
let lineStart = 0;
|
|
22
|
+
while (lineStart <= text.length) {
|
|
23
|
+
const newline = text.indexOf("\n", lineStart);
|
|
24
|
+
const lineEnd = newline === -1 ? text.length : newline;
|
|
25
|
+
const line = text.slice(lineStart, lineEnd);
|
|
26
|
+
if (FENCE_RE.test(line)) {
|
|
27
|
+
if (inFence) {
|
|
28
|
+
ranges.push([fenceStart, lineEnd]);
|
|
29
|
+
inFence = false;
|
|
30
|
+
} else {
|
|
31
|
+
fenceStart = lineStart;
|
|
32
|
+
inFence = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (newline === -1) break;
|
|
36
|
+
lineStart = newline + 1;
|
|
37
|
+
}
|
|
38
|
+
if (inFence) ranges.push([fenceStart, text.length]);
|
|
39
|
+
return ranges;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isInsideFence(ranges: readonly FenceRange[], position: number): boolean {
|
|
43
|
+
for (const [start, end] of ranges) {
|
|
44
|
+
if (position >= start && position < end) return true;
|
|
45
|
+
if (start > position) break;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* abort-and-retry handled by the agent loop.
|
|
9
9
|
*/
|
|
10
10
|
import type { AssistantMessage, Model, ToolCall } from "../types";
|
|
11
|
+
import { computeFenceRanges, isInsideFence } from "./fences";
|
|
11
12
|
|
|
12
13
|
// Single source of truth for the marker pattern. `M` in the errata.
|
|
13
14
|
// Use a fresh non-global instance for `.test()` to avoid lastIndex pitfalls.
|
|
@@ -29,8 +30,6 @@ const BODY_CASCADE_RE = /to=functions\.\w+\s+code\b[\s\S]{0,200}?to=functions\./
|
|
|
29
30
|
// Fake-result framing (`R`): marker followed within 80 chars by Cell N: framing.
|
|
30
31
|
const FAKE_RESULT_RE = /to=functions\.\w+[\s\S]{0,80}?code_output\s*\nCell\s+\d+:/;
|
|
31
32
|
|
|
32
|
-
const FENCE_RE = /^\s*(?:```+|~~~+)/;
|
|
33
|
-
|
|
34
33
|
// Non-Latin scripts seen in the corpus: CJK + ext, Cyrillic, Thai, Georgian,
|
|
35
34
|
// Armenian, Kannada, Telugu, Devanagari, Arabic, Malayalam.
|
|
36
35
|
const SCRIPT_CLASS =
|
|
@@ -342,44 +341,6 @@ function makeSignal(classes: HarmonySignalClass[], start: number, end: number, t
|
|
|
342
341
|
return { classes: sorted, start, end, text };
|
|
343
342
|
}
|
|
344
343
|
|
|
345
|
-
/**
|
|
346
|
-
* Precompute fenced-code-block ranges once per text. Each range is a
|
|
347
|
-
* [start, end) span of bytes inside any ```/~~~ fence. O(n) once instead of
|
|
348
|
-
* O(n) per detected match.
|
|
349
|
-
*/
|
|
350
|
-
function computeFenceRanges(text: string): Array<[number, number]> {
|
|
351
|
-
const ranges: Array<[number, number]> = [];
|
|
352
|
-
let inFence = false;
|
|
353
|
-
let fenceStart = 0;
|
|
354
|
-
let lineStart = 0;
|
|
355
|
-
while (lineStart <= text.length) {
|
|
356
|
-
const newline = text.indexOf("\n", lineStart);
|
|
357
|
-
const lineEnd = newline === -1 ? text.length : newline;
|
|
358
|
-
const line = text.slice(lineStart, lineEnd);
|
|
359
|
-
if (FENCE_RE.test(line)) {
|
|
360
|
-
if (inFence) {
|
|
361
|
-
ranges.push([fenceStart, lineEnd]);
|
|
362
|
-
inFence = false;
|
|
363
|
-
} else {
|
|
364
|
-
fenceStart = lineStart;
|
|
365
|
-
inFence = true;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
if (newline === -1) break;
|
|
369
|
-
lineStart = newline + 1;
|
|
370
|
-
}
|
|
371
|
-
if (inFence) ranges.push([fenceStart, text.length]);
|
|
372
|
-
return ranges;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function isInsideFence(ranges: Array<[number, number]>, position: number): boolean {
|
|
376
|
-
for (const [start, end] of ranges) {
|
|
377
|
-
if (position >= start && position < end) return true;
|
|
378
|
-
if (start > position) break;
|
|
379
|
-
}
|
|
380
|
-
return false;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
344
|
function hasScriptMismatchNear(text: string, start: number, end: number): boolean {
|
|
384
345
|
const near = text.slice(Math.max(0, start - 32), Math.min(text.length, end + 32));
|
|
385
346
|
if (!SCRIPT_RUN_RE.test(near)) return false;
|