150 lines
5.7 KiB
TypeScript
150 lines
5.7 KiB
TypeScript
import { isUtf8 } from "node:buffer";
|
|||
|
|
|
||
|
|
/** One confirmed token with bounded context from its raw output stream. */
|
||
|
|
export interface ProviderProcessOutputToken {
|
||
|
|
/** Byte offset since construction or reset of the owning supervisor. */
|
||
|
|
readonly offset: number;
|
||
|
|
readonly line: string;
|
||
|
|
readonly truncated: boolean;
|
||
|
|
readonly invalidUtf8?: true;
|
||
|
|
/** True when the line's right edge is cut or its end has not been observed. */
|
||
|
|
readonly trailingContextIncomplete?: true;
|
||
|
|
}
|
||
|
|
export const PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES = 1024;
|
||
|
|
const NEWLINE = 0x0a;
|
||
|
|
const isWordByte = (byte: number): boolean =>
|
||
|
|
(byte >= 0x30 && byte <= 0x39) ||
|
||
|
|
(byte >= 0x41 && byte <= 0x5a) ||
|
||
|
|
(byte >= 0x61 && byte <= 0x7a) ||
|
||
|
|
byte === 0x5f;
|
||
|
|
interface Match {
|
||
|
|
readonly offset: number;
|
||
|
|
readonly prefix: Buffer;
|
||
|
|
readonly prefixCut: boolean;
|
||
|
|
suffix: Buffer;
|
||
|
|
open: boolean;
|
||
|
|
suffixCut: boolean;
|
||
|
|
}
|
||
|
|
/** Bounded literal token capture, delimited on the right by a non-ASCII-word byte or actual EOF. */
|
||
|
|
export class ProcessOutputTokenScanner {
|
||
|
|
readonly #marker: Buffer;
|
||
|
|
#lineTail: Buffer = Buffer.alloc(0);
|
||
|
|
#lineTailCut = false;
|
||
|
|
#latest: Match | undefined;
|
||
|
|
#pending: Match | undefined;
|
||
|
|
constructor(marker: string) {
|
||
|
|
if (marker.length === 0 || marker.includes("\n"))
|
||
|
|
throw new RangeError("Output token must be non-empty single-line text");
|
||
|
|
this.#marker = Buffer.from(marker, "utf8");
|
||
|
|
if (!isWordByte(this.#marker[this.#marker.length - 1]!))
|
||
|
|
throw new RangeError("Output token must end in an ASCII word byte");
|
||
|
|
}
|
||
|
|
|
||
|
|
scan(chunk: Buffer, start: number): void {
|
||
|
|
if (chunk.length === 0) return;
|
||
|
|
// The next byte can invalidate a pending prefix without erasing the last
|
||
|
|
// confirmed token from an earlier line.
|
||
|
|
|
||
|
|
if (this.#pending !== undefined) {
|
||
|
|
if (!isWordByte(chunk[0]!)) this.#latest = this.#pending;
|
||
|
|
this.#pending = undefined;
|
||
|
|
}
|
||
|
|
this.#extendOpenLine(chunk);
|
||
|
|
const carried = this.#lineTail.byteLength;
|
||
|
|
const searched =
|
||
|
|
carried === 0 ? chunk : Buffer.concat([this.#lineTail, chunk]);
|
||
|
|
// A later identifier may share the literal prefix. Search past rejected
|
||
|
|
// candidates to retain the latest confirmed token in this same chunk.
|
||
|
|
let index = searched.lastIndexOf(this.#marker);
|
||
|
|
while (index >= 0 && index + this.#marker.byteLength > carried) {
|
||
|
|
const end = index + this.#marker.byteLength;
|
||
|
|
if (end === searched.length)
|
||
|
|
this.#pending = this.#record(searched, index, start - carried);
|
||
|
|
else if (!isWordByte(searched[end]!)) {
|
||
|
|
this.#latest = this.#record(searched, index, start - carried);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
index = index === 0 ? -1 : searched.lastIndexOf(this.#marker, index - 1);
|
||
|
|
}
|
||
|
|
this.#retainLineTail(searched);
|
||
|
|
}
|
||
|
|
/** Only a true stream end proves the boundary of a trailing bare token. */
|
||
|
|
finish(): void {
|
||
|
|
if (this.#pending !== undefined) this.#latest = this.#pending;
|
||
|
|
this.#pending = undefined;
|
||
|
|
if (this.#latest !== undefined) this.#latest.open = false;
|
||
|
|
}
|
||
|
|
latestWithin(
|
||
|
|
start: number,
|
||
|
|
end: number,
|
||
|
|
): ProviderProcessOutputToken | undefined {
|
||
|
|
const latest = this.#latest;
|
||
|
|
if (latest === undefined || latest.offset < start || latest.offset >= end)
|
||
|
|
return undefined;
|
||
|
|
// Omit a prefix crossing the checkpoint in full: neither its old cause
|
||
|
|
// text nor a credential fragment cut on the left belongs to this scope.
|
||
|
|
const prefixInRange = latest.offset - latest.prefix.length >= start;
|
||
|
|
const bytes = Buffer.concat([
|
||
|
|
...(prefixInRange ? [latest.prefix] : []),
|
||
|
|
latest.suffix,
|
||
|
|
]);
|
||
|
|
return {
|
||
|
|
offset: latest.offset,
|
||
|
|
line: bytes.toString("utf8"),
|
||
|
|
...(!isUtf8(bytes) ? { invalidUtf8: true as const } : {}),
|
||
|
|
truncated: latest.prefixCut || latest.suffixCut || !prefixInRange,
|
||
|
|
...(latest.suffixCut || latest.open
|
||
|
|
? { trailingContextIncomplete: true as const }
|
||
|
|
: {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
#record(searched: Buffer, index: number, searchedStart: number): Match {
|
||
|
|
const lineStart =
|
||
|
|
index === 0 ? 0 : searched.lastIndexOf(NEWLINE, index - 1) + 1;
|
||
|
|
const prefixFits =
|
||
|
|
index - lineStart <= PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES &&
|
||
|
|
(lineStart > 0 || !this.#lineTailCut);
|
||
|
|
const lineEnd = searched.indexOf(NEWLINE, index);
|
||
|
|
const available = lineEnd < 0 ? searched.byteLength : lineEnd;
|
||
|
|
const suffixEnd = Math.min(
|
||
|
|
available,
|
||
|
|
index + this.#marker.byteLength + PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES,
|
||
|
|
);
|
||
|
|
return {
|
||
|
|
offset: searchedStart + index,
|
||
|
|
prefix: prefixFits
|
||
|
|
? Buffer.from(searched.subarray(lineStart, index))
|
||
|
|
: Buffer.alloc(0),
|
||
|
|
prefixCut: !prefixFits,
|
||
|
|
suffix: Buffer.from(searched.subarray(index, suffixEnd)),
|
||
|
|
open: lineEnd < 0 && suffixEnd === available,
|
||
|
|
suffixCut: suffixEnd < available,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
#extendOpenLine(chunk: Buffer): void {
|
||
|
|
const latest = this.#latest;
|
||
|
|
if (latest?.open !== true) return;
|
||
|
|
const lineEnd = chunk.indexOf(NEWLINE);
|
||
|
|
const available = lineEnd < 0 ? chunk.byteLength : lineEnd;
|
||
|
|
const room =
|
||
|
|
this.#marker.byteLength +
|
||
|
|
PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES -
|
||
|
|
latest.suffix.byteLength;
|
||
|
|
const taken = Math.min(available, Math.max(0, room));
|
||
|
|
if (taken > 0)
|
||
|
|
latest.suffix = Buffer.concat([latest.suffix, chunk.subarray(0, taken)]);
|
||
|
|
latest.suffixCut = taken < available;
|
||
|
|
latest.open = lineEnd < 0 && taken === available;
|
||
|
|
}
|
||
|
|
#retainLineTail(searched: Buffer): void {
|
||
|
|
const lineStart = searched.lastIndexOf(NEWLINE) + 1;
|
||
|
|
const limit =
|
||
|
|
PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES + this.#marker.byteLength - 1;
|
||
|
|
this.#lineTailCut =
|
||
|
|
searched.byteLength - lineStart > limit ||
|
||
|
|
(lineStart === 0 && this.#lineTailCut);
|
||
|
|
this.#lineTail = Buffer.from(
|
||
|
|
searched.subarray(Math.max(lineStart, searched.byteLength - limit)),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|