Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/ultra/interp.ts

Raw
// ultra — interpolation resolver (Task 3).
//
// A closed, deliberately tiny mini-syntax: NOT a general expression evaluator
// and NO `eval`. Every token is parsed against a fixed grammar; anything
// outside it raises a `SpecValidationError`. See the design's
// "Interpolation mini-syntax" section for the authoritative semantics.
//
//   {args.NAME}                 — workflow input value
//   {item}                      — current fanout item (string verbatim; else compact JSON)
//   {PHASE.results}             — a prior phase's results array (nulls RETAINED positionally)
//   {PHASE.failures}            — a prior phase's typed failure array
//   {PHASE.results[].FIELD}              — project each result's FIELD (null/missing values SKIPPED)
//   {PHASE.results[].FIELD[]}             — flatten: concat each result's FIELD array (null elems / missing FIELDs SKIPPED)
//   {EXPR | where FIELD}                  — keep elements whose boolean FIELD is truthy (null elems / missing FIELD SKIPPED)
//   {EXPR | where FIELD == "VALUE"}       — keep elements whose string FIELD exactly equals VALUE
//   {EXPR | groupBy FIELD}                — group records by exact string FIELD value
//
// Pure module: no SDK imports.

/** Raised when a template/selector falls outside the documented v1 grammar. */
export class SpecValidationError extends Error {
	constructor(message: string) {
		super(message);
		this.name = "SpecValidationError";
	}
}

/**
 * Resolution context. This shape is the contract the phase engine (Task 6)
 * populates: `args` from the workflow inputs, `item` for the current fanout
 * element, and `results` mapping each prior phase id to its results array.
 * The three namespaces are segregated so a phase named `args` or `item`
 * cannot collide with the reserved roots.
 */
export interface InterpSource {
	phase: string;
	index: number;
}

export interface InterpContext {
	args?: Record<string, unknown>;
	item?: unknown;
	itemSources?: readonly InterpSource[];
	results?: Record<string, unknown[]>;
	failures?: Record<string, unknown[]>;
}

export interface SourcedValue {
	value: unknown;
	sources: InterpSource[];
}

interface ResolvedNode extends SourcedValue {
	elements?: ResolvedNode[];
}

const IDENT = "[A-Za-z_][A-Za-z0-9_]*";
const RE_ARGS = new RegExp(`^args\\.(${IDENT})$`);
const RE_RESULTS = new RegExp(`^(${IDENT})\\.results$`);
const RE_FAILURES = new RegExp(`^(${IDENT})\\.failures$`);
const RE_FLATTEN = new RegExp(
	`^(${IDENT})\\.results\\[\\]\\.(${IDENT})\\[\\]$`,
);
const RE_PROJECT = new RegExp(`^(${IDENT})\\.results\\[\\]\\.(${IDENT})$`);
const JSON_STRING = String.raw`"(?:\\.|[^"\\])*"`;
const RE_WHERE = new RegExp(
	`^where\\s+(${IDENT})(?:\\s*==\\s*(${JSON_STRING}))?$`,
);
const RE_GROUP_BY = new RegExp(`^groupBy\\s+(${IDENT})$`);
// A selector value (`over`/`when`/`return`/`report`) is exactly one braced token, no nesting.
const RE_SINGLE_TOKEN = /^\{([^{}]*)\}$/;
// Template token scanner: every `{...}` (no nesting in v1).
const RE_TOKEN = /\{([^{}]*)\}/g;

type Base =
	| { kind: "args"; name: string }
	| { kind: "item" }
	| { kind: "results"; phase: string }
	| { kind: "failures"; phase: string }
	| { kind: "flatten"; phase: string; field: string }
	| { kind: "project"; phase: string; field: string };

type Expr =
	| Base
	| { kind: "where"; base: Base; field: string; equals?: string }
	| { kind: "groupBy"; base: Base; field: string };

function isRecord(value: unknown): value is Record<string, unknown> {
	return typeof value === "object" && value !== null && !Array.isArray(value);
}

function parseBase(raw: string): Base {
	const s = raw.trim();
	if (s === "item") return { kind: "item" };

	const args = s.match(RE_ARGS);
	if (args) return { kind: "args", name: args[1] };

	const results = s.match(RE_RESULTS);
	if (results) return { kind: "results", phase: results[1] };

	const failures = s.match(RE_FAILURES);
	if (failures) return { kind: "failures", phase: failures[1] };

	const flatten = s.match(RE_FLATTEN);
	if (flatten) {
		return { kind: "flatten", phase: flatten[1], field: flatten[2] };
	}

	const project = s.match(RE_PROJECT);
	if (project) {
		return { kind: "project", phase: project[1], field: project[2] };
	}

	throw new SpecValidationError(
		`unknown interpolation token "{${raw}}" (outside the v1 grammar; allowed: args.NAME, item, PHASE.results, PHASE.failures, PHASE.results[].FIELD, PHASE.results[].FIELD[], and selector operators where/groupBy)`,
	);
}

function selectorPipeIndex(input: string, start = 0): number {
	let quoted = false;
	let escaped = false;
	for (let index = start; index < input.length; index++) {
		const char = input[index];
		if (quoted) {
			if (escaped) escaped = false;
			else if (char === "\\") escaped = true;
			else if (char === '"') quoted = false;
		} else if (char === '"') quoted = true;
		else if (char === "|") return index;
	}
	return -1;
}

function parseExpr(inner: string): Expr {
	const trimmed = inner.trim();
	if (trimmed === "") {
		throw new SpecValidationError('empty interpolation token "{}"');
	}

	const pipeIdx = selectorPipeIndex(trimmed);
	if (pipeIdx === -1) return parseBase(trimmed);

	const left = trimmed.slice(0, pipeIdx).trim();
	const right = trimmed.slice(pipeIdx + 1).trim();
	if (selectorPipeIndex(trimmed, pipeIdx + 1) !== -1) {
		throw new SpecValidationError(
			`only a single selector operator is allowed in "{${trimmed}}"`,
		);
	}
	const base = parseBase(left);
	const groupBy = right.match(RE_GROUP_BY);
	if (groupBy) return { kind: "groupBy", base, field: groupBy[1] };

	const filter = right.match(RE_WHERE);
	if (filter) {
		let equals: string | undefined;
		if (filter[2] !== undefined) {
			try {
				equals = JSON.parse(filter[2]) as string;
			} catch {
				throw new SpecValidationError(
					`where equality requires a valid JSON string in "{${trimmed}}"`,
				);
			}
		}
		return {
			kind: "where",
			base,
			field: filter[1],
			...(equals !== undefined ? { equals } : {}),
		};
	}
	throw new SpecValidationError(
		`unsupported selector operator "${right}" in "{${trimmed}}" (use "| where FIELD", '| where FIELD == "VALUE"', or "| groupBy FIELD")`,
	);
}

function getResults(ctx: InterpContext, phase: string): unknown[] {
	const results = ctx.results?.[phase];
	if (results === undefined) {
		throw new SpecValidationError(
			`interpolation references unknown phase "${phase}"`,
		);
	}
	if (!Array.isArray(results)) {
		throw new SpecValidationError(`phase "${phase}" results is not an array`);
	}
	return results;
}

function getFailures(ctx: InterpContext, phase: string): unknown[] {
	const failures = ctx.failures?.[phase];
	if (failures === undefined) {
		throw new SpecValidationError(
			`interpolation references unknown phase "${phase}"`,
		);
	}
	if (!Array.isArray(failures)) {
		throw new SpecValidationError(`phase "${phase}" failures is not an array`);
	}
	return failures;
}

function uniqueSources(sources: readonly InterpSource[]): InterpSource[] {
	const seen = new Set<string>();
	const out: InterpSource[] = [];
	for (const source of sources) {
		const key = `${source.phase}#${source.index}`;
		if (seen.has(key)) continue;
		seen.add(key);
		out.push(source);
	}
	return out;
}

function node(
	value: unknown,
	sources: readonly InterpSource[] = [],
): ResolvedNode {
	const ownSources = uniqueSources(sources);
	return {
		value,
		sources: ownSources,
		...(Array.isArray(value)
			? { elements: value.map((element) => node(element, ownSources)) }
			: {}),
	};
}

function arrayNode(elements: ResolvedNode[]): ResolvedNode {
	return {
		value: elements.map((element) => element.value),
		sources: uniqueSources(elements.flatMap((element) => element.sources)),
		elements,
	};
}

function resultNode(ctx: InterpContext, phase: string): ResolvedNode {
	return arrayNode(
		getResults(ctx, phase).map((value, index) =>
			node(
				value,
				value === null || value === undefined ? [] : [{ phase, index }],
			),
		),
	);
}

function resolveBaseNode(base: Base, ctx: InterpContext): ResolvedNode {
	switch (base.kind) {
		case "item":
			return node(ctx.item, ctx.itemSources);
		case "args":
			return node(ctx.args?.[base.name]);
		case "results":
			// Retain nulls positionally — callers depend on length.
			return resultNode(ctx, base.phase);
		case "failures":
			// Failure records are emitted by Ultra, not authored by the failed agent.
			return node(getFailures(ctx, base.phase));
		case "flatten": {
			const out: ResolvedNode[] = [];
			for (const element of resultNode(ctx, base.phase).elements ?? []) {
				if (element.value === null || element.value === undefined) continue;
				if (!isRecord(element.value)) continue;
				const field = element.value[base.field];
				// Only arrays contribute; missing / null / non-array FIELDs skipped.
				if (!Array.isArray(field)) continue;
				out.push(...field.map((value) => node(value, element.sources)));
			}
			return arrayNode(out);
		}
		case "project": {
			const out: ResolvedNode[] = [];
			for (const element of resultNode(ctx, base.phase).elements ?? []) {
				if (element.value === null || element.value === undefined) continue;
				if (!isRecord(element.value)) continue;
				const field = element.value[base.field];
				// Missing / null FIELDs contribute no projected value.
				if (field !== undefined && field !== null)
					out.push(node(field, element.sources));
			}
			return arrayNode(out);
		}
	}
}

function resolveExprNode(expr: Expr, ctx: InterpContext): ResolvedNode {
	if (expr.kind === "where") {
		const source = resolveBaseNode(expr.base, ctx);
		if (!Array.isArray(source.value)) {
			throw new SpecValidationError(
				`"| where ${expr.field}" requires an array on its left-hand side`,
			);
		}
		const out: ResolvedNode[] = [];
		for (const element of source.elements ?? []) {
			if (element.value === null || element.value === undefined) continue;
			if (!isRecord(element.value)) continue;
			const value = element.value[expr.field];
			if (
				expr.equals !== undefined
					? typeof value === "string" && value === expr.equals
					: Boolean(value)
			)
				out.push(element);
		}
		return arrayNode(out);
	}
	if (expr.kind === "groupBy") {
		const source = resolveBaseNode(expr.base, ctx);
		if (!Array.isArray(source.value)) {
			throw new SpecValidationError(
				`"| groupBy ${expr.field}" requires an array on its left-hand side`,
			);
		}
		const groups: Array<{
			key: string;
			items: unknown[];
			sources: InterpSource[];
		}> = [];
		const byKey = new Map<string, (typeof groups)[number]>();
		for (const element of source.elements ?? []) {
			if (!isRecord(element.value)) continue;
			const value = element.value[expr.field];
			if (typeof value !== "string") continue;
			const group = byKey.get(value);
			if (group) {
				group.items.push(element.value);
				group.sources = uniqueSources([...group.sources, ...element.sources]);
				continue;
			}
			const created = {
				key: value,
				items: [element.value],
				sources: [...element.sources],
			};
			byKey.set(value, created);
			groups.push(created);
		}
		return arrayNode(
			groups.map((group) =>
				node({ key: group.key, items: group.items }, group.sources),
			),
		);
	}
	return resolveBaseNode(expr, ctx);
}

function resolveExpr(expr: Expr, ctx: InterpContext): unknown {
	return resolveExprNode(expr, ctx).value;
}

/** Render a resolved value into template text: strings verbatim, else compact JSON. */
function renderValue(value: unknown): string {
	if (typeof value === "string") return value;
	if (value === undefined) return "";
	return JSON.stringify(value);
}

/**
 * Substitute every `{...}` token in a prompt template. Each token is resolved
 * against the grammar and rendered (strings verbatim, objects/arrays/scalars as
 * compact JSON). An out-of-grammar token throws.
 */
export function resolveTemplate(
	template: string,
	ctx: InterpContext,
	render: (value: unknown) => string = renderValue,
): string {
	return template.replace(RE_TOKEN, (_match, inner: string) =>
		render(resolveExpr(parseExpr(inner), ctx)),
	);
}

/** Resolve prompt substitutions with immediate producer metadata while leaving
 * ordinary workflow arguments and literal fanout items unattributed. */
export function resolveTemplateWithSources(
	template: string,
	ctx: InterpContext,
	render: (content: string, sources: readonly InterpSource[]) => string,
): string {
	return template.replace(RE_TOKEN, (_match, inner: string) => {
		const resolved = resolveExprNode(parseExpr(inner), ctx);
		return render(renderValue(resolved.value), resolved.sources);
	});
}

/**
 * Resolve a single braced selector expression (`over` / `when` / `return` /
 * `report`) to its raw
 * value. The whole input must be exactly one `{...}` token.
 */
export function resolveSelector(selector: string, ctx: InterpContext): unknown {
	const match = selector.trim().match(RE_SINGLE_TOKEN);
	if (!match) {
		throw new SpecValidationError(
			`selector must be a single brace-wrapped token, got "${selector}"`,
		);
	}
	return resolveExpr(parseExpr(match[1]), ctx);
}

/**
 * Resolve a `fanout` `over`: an array literal is returned as-is; a string is an
 * interpolation expression that must resolve to an array.
 */
export function resolveOver(over: unknown, ctx: InterpContext): unknown[] {
	return resolveOverWithSources(over, ctx).map((item) => item.value);
}

/** Resolve fanout items together with immediate producer metadata. */
export function resolveOverWithSources(
	over: unknown,
	ctx: InterpContext,
): SourcedValue[] {
	if (Array.isArray(over)) return over.map((value) => ({ value, sources: [] }));
	if (typeof over === "string") {
		const match = over.trim().match(RE_SINGLE_TOKEN);
		if (!match) {
			throw new SpecValidationError(
				`selector must be a single brace-wrapped token, got "${over}"`,
			);
		}
		const resolved = resolveExprNode(parseExpr(match[1]), ctx);
		if (!Array.isArray(resolved.value)) {
			throw new SpecValidationError(
				`"over" interpolation "${over}" did not resolve to an array`,
			);
		}
		return (resolved.elements ?? []).map(({ value, sources }) => ({
			value,
			sources,
		}));
	}
	throw new SpecValidationError(
		`"over" must be an array literal or an interpolation string, got ${typeof over}`,
	);
}